GitHub Action commited on
Commit ·
17a4ea1
0
Parent(s):
Automated sync to Hugging Face
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +68 -0
- .gitattributes +3 -0
- .github/prompts/Solution_Architecture.prompt.md +169 -0
- .github/workflows/sync_to_hf.yml +33 -0
- .gitignore +91 -0
- ARCHITECTURE.md +297 -0
- QUICKSTART.md +265 -0
- README.md +456 -0
- data/uploads/.gitkeep +1 -0
- fix_datetime.py +21 -0
- fix_timezone_imports.py +33 -0
- frontend-react/.gitignore +24 -0
- frontend-react/README.md +73 -0
- frontend-react/eslint.config.js +23 -0
- frontend-react/index.html +13 -0
- frontend-react/package-lock.json +0 -0
- frontend-react/package.json +46 -0
- frontend-react/public/_redirects +1 -0
- frontend-react/public/favicon.svg +1 -0
- frontend-react/public/icons.svg +24 -0
- frontend-react/public/thumbnail.png +3 -0
- frontend-react/src/App.css +1 -0
- frontend-react/src/App.tsx +179 -0
- frontend-react/src/assets/hero.png +3 -0
- frontend-react/src/assets/react.svg +1 -0
- frontend-react/src/assets/vite.svg +1 -0
- frontend-react/src/components/GraphCanvas.tsx +570 -0
- frontend-react/src/context/AuthContext.tsx +64 -0
- frontend-react/src/index.css +494 -0
- frontend-react/src/main.tsx +10 -0
- frontend-react/src/types/api.ts +182 -0
- frontend-react/src/views/AdminDashboard.tsx +762 -0
- frontend-react/src/views/Home.tsx +692 -0
- frontend-react/src/views/InsightsView.tsx +872 -0
- frontend-react/src/views/InteractionView.tsx +1396 -0
- frontend-react/src/views/Login.tsx +644 -0
- frontend-react/src/views/Ontology.tsx +803 -0
- frontend-react/src/views/Process.tsx +763 -0
- frontend-react/src/views/SimulationRunView.tsx +690 -0
- frontend-react/tsconfig.app.json +28 -0
- frontend-react/tsconfig.json +7 -0
- frontend-react/tsconfig.node.json +26 -0
- frontend-react/vite.config.ts +14 -0
- main.py +9 -0
- package-lock.json +373 -0
- package.json +16 -0
- pyproject.toml +84 -0
- refactor_di.py +33 -0
- src/graph_rag_service/__init__.py +5 -0
- src/graph_rag_service/api/__init__.py +1 -0
.env.example
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Application
|
| 2 |
+
APP_NAME="Graph RAG Service"
|
| 3 |
+
DEBUG=false
|
| 4 |
+
ENVIRONMENT=development
|
| 5 |
+
|
| 6 |
+
# API Server
|
| 7 |
+
API_HOST=0.0.0.0
|
| 8 |
+
API_PORT=8000
|
| 9 |
+
|
| 10 |
+
# Security
|
| 11 |
+
# ⚠️ CRITICAL: Change SECRET_KEY before ANY deployment.
|
| 12 |
+
# Generate one with: python -c "import secrets; print(secrets.token_hex(32))"
|
| 13 |
+
SECRET_KEY=change-this-in-production-to-a-secure-random-key
|
| 14 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
| 15 |
+
|
| 16 |
+
# CORS: comma-separated list of allowed origins.
|
| 17 |
+
# Default allows only the local Vite dev server.
|
| 18 |
+
# Example for production: CORS_ORIGINS=https://yourdomain.com
|
| 19 |
+
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
| 20 |
+
|
| 21 |
+
# Neo4j
|
| 22 |
+
NEO4J_URI=bolt://localhost:7687
|
| 23 |
+
NEO4J_USER=neo4j
|
| 24 |
+
NEO4J_PASSWORD=password
|
| 25 |
+
NEO4J_DATABASE=neo4j
|
| 26 |
+
|
| 27 |
+
# Redis
|
| 28 |
+
REDIS_HOST=localhost
|
| 29 |
+
REDIS_PORT=6379
|
| 30 |
+
REDIS_DB=0
|
| 31 |
+
|
| 32 |
+
# Celery
|
| 33 |
+
CELERY_BROKER_URL=redis://localhost:6379/0
|
| 34 |
+
CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
| 35 |
+
|
| 36 |
+
# LLMs
|
| 37 |
+
DEFAULT_LLM_PROVIDER=ollama
|
| 38 |
+
|
| 39 |
+
# OpenAI
|
| 40 |
+
OPENAI_API_KEY=
|
| 41 |
+
|
| 42 |
+
# Anthropic
|
| 43 |
+
ANTHROPIC_API_KEY=
|
| 44 |
+
|
| 45 |
+
# Google Gemini
|
| 46 |
+
GOOGLE_API_KEY=
|
| 47 |
+
|
| 48 |
+
# LlamaCloud (for LlamaParse)
|
| 49 |
+
LLAMA_CLOUD_API_KEY=
|
| 50 |
+
USE_LLAMA_PARSE=true
|
| 51 |
+
|
| 52 |
+
# Ollama
|
| 53 |
+
OLLAMA_BASE_URL=http://localhost:11434
|
| 54 |
+
OLLAMA_MODEL=deepseek-v3.1:671b-cloud
|
| 55 |
+
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
| 56 |
+
|
| 57 |
+
# Embedding
|
| 58 |
+
EMBEDDING_PROVIDER=ollama
|
| 59 |
+
EMBEDDING_DIMENSION=768
|
| 60 |
+
|
| 61 |
+
# Agent Configuration
|
| 62 |
+
MAX_AGENT_ITERATIONS=5
|
| 63 |
+
AGENT_TIMEOUT_SECONDS=30
|
| 64 |
+
|
| 65 |
+
# Observability
|
| 66 |
+
ENABLE_TRACING=true
|
| 67 |
+
ENABLE_METRICS=true
|
| 68 |
+
LOG_LEVEL=INFO
|
.gitattributes
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
.github/prompts/Solution_Architecture.prompt.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Solution Architecture: Agentic Graph RAG as a Service
|
| 2 |
+
|
| 3 |
+
This document outlines a detailed technical approach to building the Agentic Graph RAG platform. It focuses on modularity, scalability, and the specific requirements of the Lyzr Hackathon, with a strong emphasis on production-grade robustness.
|
| 4 |
+
|
| 5 |
+
## 1. High-Level Architecture
|
| 6 |
+
|
| 7 |
+
The system is designed as a set of modular services centered around a shared Knowledge Graph and Vector Store, fortified with enterprise-grade security and observability layers.
|
| 8 |
+
|
| 9 |
+
```mermaid
|
| 10 |
+
graph TD
|
| 11 |
+
User[User / Client] --> Auth[Auth & Access Control]
|
| 12 |
+
Auth --> API[Unified API Gateway]
|
| 13 |
+
|
| 14 |
+
subgraph "Observability Layer (OpenTelemetry)"
|
| 15 |
+
Logs[Structured Logging]
|
| 16 |
+
Traces[Agent Traces]
|
| 17 |
+
Metrics[Performance Metrics]
|
| 18 |
+
end
|
| 19 |
+
|
| 20 |
+
API -.-> Logs & Traces & Metrics
|
| 21 |
+
|
| 22 |
+
subgraph "Ingestion Pipeline (Async Workers)"
|
| 23 |
+
API --> Queue[Task Queue (Redis/Celery)]
|
| 24 |
+
Queue --> Ingest[Ingestion Worker]
|
| 25 |
+
Ingest --> Chunking[Text Chunking]
|
| 26 |
+
Chunking --> OntologyGen[LLM Ontology Gen (Versioned)]
|
| 27 |
+
OntologyGen --> Extract[Entity & Relation Extraction]
|
| 28 |
+
Extract --> Resolution[Entity Resolution / Dedup]
|
| 29 |
+
Resolution --> GraphDB[(Neo4j / Neptune)]
|
| 30 |
+
Resolution --> VectorDB[(Vector Store)]
|
| 31 |
+
end
|
| 32 |
+
|
| 33 |
+
subgraph "Retrieval Context"
|
| 34 |
+
API --> Agent[Agent Orchestrator]
|
| 35 |
+
Agent --> Decomp[Query Decomposer]
|
| 36 |
+
Decomp --> Router[Query Router / Planner]
|
| 37 |
+
|
| 38 |
+
Router --> |Semantic Query| VectorSearch[Vector Search]
|
| 39 |
+
Router --> |Deep Relation| GraphSearch[Graph Traversal / Cypher]
|
| 40 |
+
Router --> |Structured| FilterSearch[Metadata Filter]
|
| 41 |
+
|
| 42 |
+
VectorSearch & GraphSearch & FilterSearch --> Validator[Hallucination Guard / Schema Validator]
|
| 43 |
+
Validator --> Synthesizer[Response Synthesizer]
|
| 44 |
+
Synthesizer --> Agent
|
| 45 |
+
end
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## 2. Technology Stack Selection
|
| 49 |
+
|
| 50 |
+
* **Language:** Python 3.12 (Standard for AI/ML engineering).
|
| 51 |
+
* **API Framework:** FastAPI (Async support, auto-documentation).
|
| 52 |
+
* **Orchestration:** LlamaIndex (Preferred for Graph RAG).
|
| 53 |
+
* **LLM:** multi-LLM support like ollama, open ai, gemini,claude (use lang-graph) (Reasoning & Extraction) & `BAAI bge-m3` this model is available on ollama so we will use from ollama (Embeddings).
|
| 54 |
+
* **Graph Database:** Neo4j (Primary) .
|
| 55 |
+
* **Vector Store:** Neo4j Vector Index (for unified storage) or Qdrant/Chroma.
|
| 56 |
+
* **Task Queue:** Celery with Redis (for async ingestion).
|
| 57 |
+
* **Monitoring:** OpenTelemetry + Prometheus/Grafana.
|
| 58 |
+
* **Frontend:** React vite + tailwind css (for Visual Ontology Editor).
|
| 59 |
+
|
| 60 |
+
## 3. Production-Grade Components
|
| 61 |
+
|
| 62 |
+
### A. Document-to-Graph Pipeline (Ingestion)
|
| 63 |
+
|
| 64 |
+
This pipeline converts unstructured text into a structured Knowledge Graph, robust to schema changes and duplicates.
|
| 65 |
+
|
| 66 |
+
1. **Ontology Generation & Evolution:**
|
| 67 |
+
* *Initial:* Ask LLM to identify high-level concepts (nodes) and interactions (edges) from first $N$ chunks.
|
| 68 |
+
* *Visual Editor:* Human approval step to refine the JSON schema.
|
| 69 |
+
* **Drift Handling:** Incorporate an "Ontology Versioning" system. Every node/edge is tagged with `ontology_version: v1.0`. New documents causing schema changes trigger a "Migration Proposal" for approval.
|
| 70 |
+
|
| 71 |
+
2. **Extraction & Embedding:**
|
| 72 |
+
* **Prompt Engineering:** "Given text + Ontology v1.0, extract entities/relationships."
|
| 73 |
+
* **Hybrid Nodes:** Create `(:Chunk)` nodes linked to `(:Entity)` nodes (`(:Chunk)-[:MENTIONS]->(:Entity)`). This preserves ground truth source text alongside abstract graph relationships.
|
| 74 |
+
|
| 75 |
+
3. **Advanced Entity Resolution:**
|
| 76 |
+
* *Naive:* Exact string match.
|
| 77 |
+
* *Production:* Multi-stage blocking and merging.
|
| 78 |
+
1. **Blocking:** Group entities by Label and similar name (e.g., phonetic match).
|
| 79 |
+
2. **Semantic Check:** Compare embeddings of candidates.
|
| 80 |
+
3. **Threshold:** If similarity > 0.95 -> Auto-merge. If 0.85-0.95 -> Flag for "Human Review Queue".
|
| 81 |
+
|
| 82 |
+
### B. The Agentic Retrieval System (The Brain)
|
| 83 |
+
|
| 84 |
+
A state machine loop designed for accuracy and fail-safe operation.
|
| 85 |
+
|
| 86 |
+
**1. Query Decomposition & Routing**
|
| 87 |
+
Instead of a single step, the Agent breaks down complexity:
|
| 88 |
+
* *User Query:* "How is the CEO of Lyzr related to OpenAI?"
|
| 89 |
+
* *Decomposition:*
|
| 90 |
+
1. "Identify Lyzr CEO" (Vector/Graph lookup) -> *Result: user_X*
|
| 91 |
+
2. "Find path between user_X and OpenAI" (Graph traversal).
|
| 92 |
+
* *Router:* Dynamically selects tools for each sub-step.
|
| 93 |
+
|
| 94 |
+
**2. Tool Implementation with Guardrails:**
|
| 95 |
+
* **Vector Tool:** Top-k retrieval using embedding similarity.
|
| 96 |
+
* **Graph Tool (Text-to-Cypher):** Uses LLM to generate Cypher.
|
| 97 |
+
* **Hallucination Guard:** The tool injects the *strict* allowed schema into the prompt. Generated Cypher is parsed and validated against a "Relationship Whitelist" before execution to prevent schema injection or invalid edge types.
|
| 98 |
+
* **Filter Tool:** Converts natural language to structured DB filters (WHERE clauses).
|
| 99 |
+
|
| 100 |
+
**3. Latency & Performance Strategy:**
|
| 101 |
+
* **Timeouts:** Hard limit on agent reasoning steps (e.g., max 5 loops).
|
| 102 |
+
* **Fallback:** If Graph tool fails or times out, degrade gracefully to pure Vector Search for a "best effort" answer.
|
| 103 |
+
|
| 104 |
+
### C. Parity & Extensibility Layer
|
| 105 |
+
|
| 106 |
+
We define abstract base class interfaces to ensure no vendor lock-in.
|
| 107 |
+
|
| 108 |
+
```python
|
| 109 |
+
class GraphStore(ABC):
|
| 110 |
+
@abstractmethod
|
| 111 |
+
def execute_query(self, query: str, params: dict): pass
|
| 112 |
+
|
| 113 |
+
class VectorStore(ABC):
|
| 114 |
+
@abstractmethod
|
| 115 |
+
def search(self, query_vector: List[float], k: int): pass
|
| 116 |
+
|
| 117 |
+
class LLMProvider(ABC):
|
| 118 |
+
@abstractmethod
|
| 119 |
+
def complete(self, prompt: str): pass
|
| 120 |
+
|
| 121 |
+
# Implementations: Neo4jStore, NeptuneStore, QdrantStore, OpenAIProvider, etc.
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## 4. Scalability, Security & Observability
|
| 125 |
+
|
| 126 |
+
To meet "Production-Grade" criteria, these non-functional requirements are critical:
|
| 127 |
+
|
| 128 |
+
1. **Access Control (RBAC):**
|
| 129 |
+
* Pre-retrieval enforcement.
|
| 130 |
+
* All queries filter by `user.tenant_id` or `user.permissions` to ensure users only retrieve data they are authorized to see.
|
| 131 |
+
|
| 132 |
+
2. **Observability:**
|
| 133 |
+
* **Tracing:** Log every step of the Agent's reasoning chain (Input -> Decomp -> Tool Call -> Result). This is vital for debugging "why did the bot say that?".
|
| 134 |
+
* **Metrics:** Track Token Usage, Latency p95, and Cache Hit Rates.
|
| 135 |
+
|
| 136 |
+
3. **Async Ingestion:**
|
| 137 |
+
* Ingestion is decoupled from the user request loop.
|
| 138 |
+
* File Upload API -> Pushes ID to Redis Queue -> Background Worker picks up -> Runs Extraction -> Updates Graph.
|
| 139 |
+
|
| 140 |
+
4. **Caching Strategy:**
|
| 141 |
+
* **Semantic Cache (Redis):** Before hitting the LLM, check if a semantically similar query has been answered recently. reduces cost and latency.
|
| 142 |
+
* **Embedding Cache:** Store computed embeddings to avoid re-calculation for identical text chunks.
|
| 143 |
+
|
| 144 |
+
## 5. Implementation Plan
|
| 145 |
+
|
| 146 |
+
### Phase 1: Foundation (Hours 1-4)
|
| 147 |
+
1. Set up Repository, Python envf (Neo4j/Redis).
|
| 148 |
+
2. Implement `GraphStore` & `VectorStore` abstractions.
|
| 149 |
+
3. Create Basic Auth & Middleware logging.
|
| 150 |
+
|
| 151 |
+
### Phase 2: Ingestion Engine (Hours 5-12)
|
| 152 |
+
1. Implement PDF extractor & Async Worker skeleton.
|
| 153 |
+
2. Build "Ontology Proposer" & "Graph Extractor" prompts.
|
| 154 |
+
3. Implement Entity Resolution logic.
|
| 155 |
+
|
| 156 |
+
### Phase 3: The Retrieval Agent (Hours 13-20)
|
| 157 |
+
1. Set up Agent loop with Query Decomposition.
|
| 158 |
+
2. Implement `Text2Cypher` with schema validation.
|
| 159 |
+
3. Implement Latency Timeouts & Fallbacks.
|
| 160 |
+
|
| 161 |
+
### Phase 4: Refinement & UI (Hours 21-24)
|
| 162 |
+
1. Build Visual Editor (Streamlit).
|
| 163 |
+
2. Add simple Evaluation Script (run known queries, check answers).
|
| 164 |
+
3. Write `README.md` highlighting the "Production Thinking" (RBAC, Async, Observability).
|
| 165 |
+
|
| 166 |
+
## 6. Key Innovations
|
| 167 |
+
1. **Hybrid Chunk Nodes:** Storing source text explicitly in the graph for ground-truth verification.
|
| 168 |
+
2. **Self-Correcting Cypher:** If Cypher execution fails, feed the error back to the LLM to fix syntax automatically.
|
| 169 |
+
3. **Adaptive Retrieval:** The agent assigns a "confidence score" to each retrieval method. If Vector Search confidence is low (<0.7), it automatically triggers Graph Traversal to boost context.
|
.github/workflows/sync_to_hf.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
branches: [master]
|
| 5 |
+
|
| 6 |
+
jobs:
|
| 7 |
+
sync-to-hub:
|
| 8 |
+
runs-on: ubuntu-latest
|
| 9 |
+
steps:
|
| 10 |
+
- uses: actions/checkout@v3
|
| 11 |
+
- name: Push to hub
|
| 12 |
+
env:
|
| 13 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 14 |
+
run: |
|
| 15 |
+
# 1. Turn on Large File Storage (LFS)
|
| 16 |
+
git lfs install
|
| 17 |
+
|
| 18 |
+
# 2. Tell Hugging Face to properly handle images
|
| 19 |
+
echo "*.png filter=lfs diff=lfs merge=lfs -text" >> .gitattributes
|
| 20 |
+
echo "*.jpg filter=lfs diff=lfs merge=lfs -text" >> .gitattributes
|
| 21 |
+
echo "*.ico filter=lfs diff=lfs merge=lfs -text" >> .gitattributes
|
| 22 |
+
|
| 23 |
+
# 3. Wipe the hidden git history in the runner to clear past image errors
|
| 24 |
+
rm -rf .git
|
| 25 |
+
git config --global user.email "action@github.com"
|
| 26 |
+
git config --global user.name "GitHub Action"
|
| 27 |
+
|
| 28 |
+
# 4. Create a fresh, clean package and force push it
|
| 29 |
+
git init
|
| 30 |
+
git checkout -b main
|
| 31 |
+
git add .
|
| 32 |
+
git commit -m "Automated sync to Hugging Face"
|
| 33 |
+
git push --force https://anky2002:$HF_TOKEN@huggingface.co/spaces/anky2002/graph-rag main
|
.gitignore
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Python ───────────────────────────────────────────────────────────────────
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
build/
|
| 7 |
+
dist/
|
| 8 |
+
wheels/
|
| 9 |
+
*.egg-info/
|
| 10 |
+
.eggs/
|
| 11 |
+
*.egg
|
| 12 |
+
|
| 13 |
+
# ── Virtual environments ──────────────────────────────────────────────────────
|
| 14 |
+
.venv/
|
| 15 |
+
venv/
|
| 16 |
+
ENV/
|
| 17 |
+
env/
|
| 18 |
+
|
| 19 |
+
# ── Environment / Secrets (NEVER commit real keys) ────────────────────────────
|
| 20 |
+
.env
|
| 21 |
+
.env.local
|
| 22 |
+
.env.*.local
|
| 23 |
+
*.secret
|
| 24 |
+
|
| 25 |
+
# ── Node.js / NPM ─────────────────────────────────────────────────────────────
|
| 26 |
+
node_modules/
|
| 27 |
+
npm-debug.log*
|
| 28 |
+
yarn-debug.log*
|
| 29 |
+
yarn-error.log*
|
| 30 |
+
pnpm-debug.log*
|
| 31 |
+
.npm/
|
| 32 |
+
|
| 33 |
+
# ── Uploaded user data ────────────────────────────────────────────────────────
|
| 34 |
+
data/uploads/*
|
| 35 |
+
!data/uploads/.gitkeep
|
| 36 |
+
|
| 37 |
+
# ── One-off debug / experiment test files ─────────────────────────────────────
|
| 38 |
+
test_combinations.py
|
| 39 |
+
test_document_embedding.py
|
| 40 |
+
test_embedding.py
|
| 41 |
+
test_exact_combination.py
|
| 42 |
+
test_narrow_down.py
|
| 43 |
+
test_nomic.py
|
| 44 |
+
test_resume_text.py
|
| 45 |
+
|
| 46 |
+
# ── IDE / OS ──────────────────────────────────────────────────────────────────
|
| 47 |
+
.idea/
|
| 48 |
+
.vscode/
|
| 49 |
+
*.swp
|
| 50 |
+
*.swo
|
| 51 |
+
.DS_Store
|
| 52 |
+
Thumbs.db
|
| 53 |
+
|
| 54 |
+
# ── Logs & temp ───────────────────────────────────────────────────────────────
|
| 55 |
+
*.log
|
| 56 |
+
*.tmp
|
| 57 |
+
*.temp
|
| 58 |
+
htmlcov/
|
| 59 |
+
.coverage
|
| 60 |
+
.pytest_cache/
|
| 61 |
+
.mypy_cache/
|
| 62 |
+
.ruff_cache/
|
| 63 |
+
|
| 64 |
+
# ── Docs that are project-internal notes (not needed in repo) ─────────────────
|
| 65 |
+
BGE_M3_ISSUE_ANALYSIS.md
|
| 66 |
+
CONFIGURATION_CHANGES.md
|
| 67 |
+
PROJECT_COMPLETION_CHECKLIST.md
|
| 68 |
+
LLAMAPARSE_SETUP.md
|
| 69 |
+
|
| 70 |
+
# ── Lyzr Hackathon source material (not part of the submitted codebase) ────────
|
| 71 |
+
Lyzr_Hackathon_Problem_Statement.md
|
| 72 |
+
|
| 73 |
+
# ── UV lock / python version pins (repo-specific, not portable) ─────────────────
|
| 74 |
+
# uv.lock # keep: ensures reproducible installs for reviewers
|
| 75 |
+
.python-version
|
| 76 |
+
|
| 77 |
+
# ── pyvis / notebook temp files ──────────────────────────────────────────────
|
| 78 |
+
*.html
|
| 79 |
+
!frontend-react/index.html
|
| 80 |
+
!frontend-react/*.html
|
| 81 |
+
!frontend/*.html
|
| 82 |
+
|
| 83 |
+
# ── Celery / task artefacts ───────────────────────────────────────────────────
|
| 84 |
+
celerybeat-schedule
|
| 85 |
+
celerybeat.pid
|
| 86 |
+
|
| 87 |
+
# ── Neo4j local data (if ever mounted) ───────────────────────────────────────
|
| 88 |
+
neo4j_data/
|
| 89 |
+
|
| 90 |
+
# ── Windows shortcuts / artifacts ────────────────────────────────────────────
|
| 91 |
+
*.lnk
|
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Graph RAG Service - Project Documentation
|
| 2 |
+
|
| 3 |
+
## System Architecture
|
| 4 |
+
|
| 5 |
+
### Overview
|
| 6 |
+
The Graph RAG Service is built as a modular, production-grade platform with the following key components:
|
| 7 |
+
|
| 8 |
+
1. **API Gateway (FastAPI)**: Handles all HTTP requests, authentication, and routing
|
| 9 |
+
2. **Ingestion Pipeline**: Processes documents and constructs knowledge graphs
|
| 10 |
+
3. **Retrieval Agent (LangGraph)**: Intelligent query routing and response synthesis
|
| 11 |
+
4. **Storage Layer**: Neo4j for graph + vector storage
|
| 12 |
+
5. **Task Queue**: Celery + Redis for async processing
|
| 13 |
+
6. **Observability**: OpenTelemetry for tracing and metrics
|
| 14 |
+
|
| 15 |
+
### Design Principles
|
| 16 |
+
|
| 17 |
+
#### 1. No Vendor Lock-in
|
| 18 |
+
All core components are abstracted behind interfaces:
|
| 19 |
+
- `GraphStore`: Can swap Neo4j for AWS Neptune
|
| 20 |
+
- `VectorStore`: Supports multiple vector databases
|
| 21 |
+
- `LLMProvider`: Works with any LLM (OpenAI, Anthropic, Gemini, Ollama)
|
| 22 |
+
|
| 23 |
+
#### 2. Production-Ready
|
| 24 |
+
- **Async Processing**: Non-blocking I/O for all database operations
|
| 25 |
+
- **Background Jobs**: Celery workers handle heavy ingestion tasks
|
| 26 |
+
- **Authentication**: JWT-based with RBAC support
|
| 27 |
+
- **Error Handling**: Graceful degradation and fallback mechanisms
|
| 28 |
+
- **Observability**: Full tracing and metrics collection
|
| 29 |
+
|
| 30 |
+
#### 3. Intelligent Retrieval
|
| 31 |
+
The agentic system:
|
| 32 |
+
- Decomposes complex queries into sub-queries
|
| 33 |
+
- Dynamically selects retrieval methods (vector vs graph vs cypher)
|
| 34 |
+
- Validates outputs against schema (hallucination guard)
|
| 35 |
+
- Provides reasoning chains for transparency
|
| 36 |
+
|
| 37 |
+
## Components Deep Dive
|
| 38 |
+
|
| 39 |
+
### Core Abstractions (`src/graph_rag_service/core/`)
|
| 40 |
+
|
| 41 |
+
#### GraphStore Interface
|
| 42 |
+
```python
|
| 43 |
+
class GraphStore(ABC):
|
| 44 |
+
@abstractmethod
|
| 45 |
+
async def create_node(entity: Entity) -> str
|
| 46 |
+
@abstractmethod
|
| 47 |
+
async def create_relationship(relationship: Relationship) -> str
|
| 48 |
+
@abstractmethod
|
| 49 |
+
async def execute_query(query: str, params: dict) -> List[dict]
|
| 50 |
+
@abstractmethod
|
| 51 |
+
async def find_path(source: str, target: str, max_depth: int) -> List[dict]
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Implementation: `Neo4jStore` provides unified graph + vector storage using Neo4j 5.x vector capabilities.
|
| 55 |
+
|
| 56 |
+
#### LLMProvider Interface
|
| 57 |
+
```python
|
| 58 |
+
class LLMProvider(ABC):
|
| 59 |
+
@abstractmethod
|
| 60 |
+
async def complete(prompt: str, **kwargs) -> str
|
| 61 |
+
@abstractmethod
|
| 62 |
+
async def embed(text: str) -> List[float]
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
Implementation: `UnifiedLLMProvider` wraps OpenAI, Anthropic, Gemini, and Ollama with a consistent interface.
|
| 66 |
+
|
| 67 |
+
#### Entity Resolution
|
| 68 |
+
Multi-stage resolution:
|
| 69 |
+
1. **Blocking**: Group by entity type and name similarity (fast reject)
|
| 70 |
+
2. **Semantic Check**: Compare embeddings for deep similarity
|
| 71 |
+
3. **Threshold Matching**: Configurable thresholds (0.85 default)
|
| 72 |
+
4. **Auto-merge**: High confidence merges (>0.95)
|
| 73 |
+
5. **Human Review Queue**: Medium confidence flagged for review (0.85-0.95)
|
| 74 |
+
|
| 75 |
+
### Ingestion Pipeline (`src/graph_rag_service/ingestion/`)
|
| 76 |
+
|
| 77 |
+
#### Flow
|
| 78 |
+
1. **Document Processing**: Extract text from PDF/TXT/MD/DOCX
|
| 79 |
+
2. **Chunking**: Split into overlapping chunks (1024 tokens, 200 overlap)
|
| 80 |
+
3. **Ontology Generation**: LLM analyzes samples to propose entity/relationship types
|
| 81 |
+
4. **Entity Extraction**: Extract entities and relationships per chunk
|
| 82 |
+
5. **Entity Resolution**: Deduplicate and merge entities
|
| 83 |
+
6. **Embedding Generation**: Create vector embeddings (BGE-M3)
|
| 84 |
+
7. **Graph Construction**: Store in Neo4j with hybrid nodes
|
| 85 |
+
|
| 86 |
+
#### Hybrid Nodes
|
| 87 |
+
Each chunk is stored as both:
|
| 88 |
+
- A `(:Chunk)` node with text and embedding
|
| 89 |
+
- Connected to `(:Entity)` nodes via `[:MENTIONS]` relationships
|
| 90 |
+
|
| 91 |
+
This preserves source text for grounding while enabling abstract graph queries.
|
| 92 |
+
|
| 93 |
+
### Retrieval System (`src/graph_rag_service/retrieval/`)
|
| 94 |
+
|
| 95 |
+
#### Tools
|
| 96 |
+
1. **VectorSearchTool**: Semantic similarity using embeddings
|
| 97 |
+
2. **GraphTraversalTool**: Relationship exploration and path finding
|
| 98 |
+
3. **CypherGenerationTool**: Text-to-Cypher with validation
|
| 99 |
+
4. **MetadataFilterTool**: Structured queries on attributes
|
| 100 |
+
|
| 101 |
+
#### Agent Workflow (LangGraph)
|
| 102 |
+
```
|
| 103 |
+
[Query] → [Decompose] → [Route] → [Vector/Graph/Cypher] → [Synthesize] → [Response]
|
| 104 |
+
↑ ↓
|
| 105 |
+
└─────────────────────────────────────┘
|
| 106 |
+
(Iterative refinement)
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
#### Hallucination Guards
|
| 110 |
+
- **Schema Injection**: Prompt includes allowed entity/relationship types
|
| 111 |
+
- **Cypher Validation**: Parse and validate against whitelist
|
| 112 |
+
- **Self-Correction**: Feed errors back to LLM to fix syntax
|
| 113 |
+
- **Fallback**: If graph fails, degrade to vector search
|
| 114 |
+
|
| 115 |
+
### API Layer (`src/graph_rag_service/api/`)
|
| 116 |
+
|
| 117 |
+
#### Endpoints
|
| 118 |
+
- `POST /api/auth/login`: Get JWT token
|
| 119 |
+
- `POST /api/documents/upload`: Upload document (returns task ID)
|
| 120 |
+
- `GET /api/documents/status/{task_id}`: Check ingestion progress
|
| 121 |
+
- `POST /api/query`: Execute agentic query
|
| 122 |
+
- `GET /api/ontology`: Get current ontology schema
|
| 123 |
+
- `PUT /api/ontology`: Update ontology (admin only)
|
| 124 |
+
- `GET /api/graph/visualization`: Get graph data for visualization
|
| 125 |
+
- `GET /api/system/health`: System health check
|
| 126 |
+
- `GET /api/system/stats`: System statistics
|
| 127 |
+
|
| 128 |
+
#### Authentication
|
| 129 |
+
- JWT tokens with configurable expiration (default: 30 min)
|
| 130 |
+
- RBAC with scopes: `read`, `write`, `admin`
|
| 131 |
+
- Dependency injection for protected endpoints
|
| 132 |
+
|
| 133 |
+
### Workers (`src/graph_rag_service/workers/`)
|
| 134 |
+
|
| 135 |
+
#### Celery Tasks
|
| 136 |
+
- `ingest_document`: Process single document
|
| 137 |
+
- `ingest_documents_batch`: Process multiple documents
|
| 138 |
+
- `health_check`: Worker health verification
|
| 139 |
+
|
| 140 |
+
#### Configuration
|
| 141 |
+
- Broker: Redis
|
| 142 |
+
- Result Backend: Redis
|
| 143 |
+
- Serializer: JSON
|
| 144 |
+
- Task timeout: 1 hour (configurable)
|
| 145 |
+
|
| 146 |
+
### Observability (`src/graph_rag_service/observability/`)
|
| 147 |
+
|
| 148 |
+
#### OpenTelemetry Integration
|
| 149 |
+
- **Traces**: Agent reasoning steps, tool calls, database queries
|
| 150 |
+
- **Metrics**:
|
| 151 |
+
- `documents_ingested`: Counter
|
| 152 |
+
- `queries_executed`: Counter
|
| 153 |
+
- `query_duration_seconds`: Histogram
|
| 154 |
+
- `entities_extracted`: Counter
|
| 155 |
+
|
| 156 |
+
#### Structured Logging
|
| 157 |
+
- Log level: INFO (configurable)
|
| 158 |
+
- Format: `%(asctime)s - %(name)s - %(levelname)s - %(message)s`
|
| 159 |
+
- All async operations logged with context
|
| 160 |
+
|
| 161 |
+
## Configuration
|
| 162 |
+
|
| 163 |
+
### Environment Variables
|
| 164 |
+
Key settings in `.env`:
|
| 165 |
+
- **Neo4j**: `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`
|
| 166 |
+
- **Redis**: `REDIS_HOST`, `REDIS_PORT`
|
| 167 |
+
- **LLM Provider**: `DEFAULT_LLM_PROVIDER` (openai/anthropic/gemini/ollama)
|
| 168 |
+
- **API Keys**: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`
|
| 169 |
+
- **Ollama**: `OLLAMA_BASE_URL`, `OLLAMA_MODEL`, `OLLAMA_EMBEDDING_MODEL`
|
| 170 |
+
- **Security**: `SECRET_KEY`, `ACCESS_TOKEN_EXPIRE_MINUTES`
|
| 171 |
+
|
| 172 |
+
### Tuning Parameters
|
| 173 |
+
- `CHUNK_SIZE`: 1024 (text chunk size)
|
| 174 |
+
- `CHUNK_OVERLAP`: 200 (overlap between chunks)
|
| 175 |
+
- `MAX_AGENT_ITERATIONS`: 5 (max reasoning steps)
|
| 176 |
+
- `AGENT_TIMEOUT_SECONDS`: 30 (query timeout)
|
| 177 |
+
- `ENTITY_RESOLUTION_THRESHOLD`: 0.85 (similarity threshold)
|
| 178 |
+
- `DEFAULT_TOP_K`: 5 (retrieval results)
|
| 179 |
+
- `GRAPH_MAX_DEPTH`: 3 (graph traversal depth)
|
| 180 |
+
|
| 181 |
+
## Deployment
|
| 182 |
+
|
| 183 |
+
### Local Development
|
| 184 |
+
```bash
|
| 185 |
+
# 1. Ensure Neo4j and Redis are running
|
| 186 |
+
# 2. Configure .env with connection details
|
| 187 |
+
|
| 188 |
+
# 3. Start API server
|
| 189 |
+
./start-server.sh # or start-server.bat on Windows
|
| 190 |
+
|
| 191 |
+
# 4. Start workers
|
| 192 |
+
./start-worker.sh # or start-worker.bat on Windows
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
### Production Considerations
|
| 196 |
+
1. **Database**: Use managed Neo4j (Aura) or self-hosted cluster
|
| 197 |
+
2. **Redis**: Use managed Redis (AWS ElastiCache, Redis Cloud)
|
| 198 |
+
3. **Worker Scaling**: Add more Celery workers based on ingestion load
|
| 199 |
+
4. **API Scaling**: Run multiple API instances behind load balancer
|
| 200 |
+
5. **Monitoring**: Integrate with Prometheus/Grafana for metrics
|
| 201 |
+
6. **Secrets**: Use secret management (AWS Secrets Manager, HashiCorp Vault)
|
| 202 |
+
|
| 203 |
+
## Extensibility
|
| 204 |
+
|
| 205 |
+
### Adding New LLM Provider
|
| 206 |
+
1. Implement `LLMProvider` interface
|
| 207 |
+
2. Add to `LLMFactory.create()` method
|
| 208 |
+
3. Update config with new provider settings
|
| 209 |
+
|
| 210 |
+
### Adding New Graph Database
|
| 211 |
+
1. Implement `GraphStore` interface
|
| 212 |
+
2. Update `IngestionPipeline` to use new store
|
| 213 |
+
3. Test with existing workflows
|
| 214 |
+
|
| 215 |
+
### Custom Retrieval Tools
|
| 216 |
+
1. Create new tool class with `run()` method
|
| 217 |
+
2. Add to `AgentRetrievalSystem.tools`
|
| 218 |
+
3. Update routing logic in `_route_query()`
|
| 219 |
+
|
| 220 |
+
## Testing Strategy
|
| 221 |
+
|
| 222 |
+
### Unit Tests
|
| 223 |
+
- Test each component independently
|
| 224 |
+
- Mock external dependencies (Neo4j, Redis, LLMs)
|
| 225 |
+
- Focus on business logic
|
| 226 |
+
|
| 227 |
+
### Integration Tests
|
| 228 |
+
- Test component interactions
|
| 229 |
+
- Use test database instances
|
| 230 |
+
- Verify end-to-end flows
|
| 231 |
+
|
| 232 |
+
### Performance Tests
|
| 233 |
+
- Benchmark ingestion throughput
|
| 234 |
+
- Measure query latencies
|
| 235 |
+
- Stress test with concurrent requests
|
| 236 |
+
|
| 237 |
+
## Future Enhancements
|
| 238 |
+
|
| 239 |
+
### Phase 1 (Current MVP)
|
| 240 |
+
- ✅ Core ingestion pipeline
|
| 241 |
+
- ✅ Agentic retrieval system
|
| 242 |
+
- ✅ Multi-LLM support
|
| 243 |
+
- ✅ Entity resolution
|
| 244 |
+
- ✅ Async workers
|
| 245 |
+
|
| 246 |
+
### Phase 2 (Next Steps)
|
| 247 |
+
- [ ] React frontend with visual ontology editor
|
| 248 |
+
- [ ] Graph visualization (D3.js/Cytoscape)
|
| 249 |
+
- [ ] Advanced ontology evolution with migrations
|
| 250 |
+
- [ ] Semantic cache with Redis
|
| 251 |
+
- [ ] Batch ingestion optimization
|
| 252 |
+
|
| 253 |
+
### Phase 3 (Advanced Features)
|
| 254 |
+
- [ ] Multi-tenant support with data isolation
|
| 255 |
+
- [ ] Fine-tuned entity extraction models
|
| 256 |
+
- [ ] Graph neural network embeddings
|
| 257 |
+
- [ ] Automated ontology quality metrics
|
| 258 |
+
- [ ] Export/import ontology schemas
|
| 259 |
+
|
| 260 |
+
## Troubleshooting
|
| 261 |
+
|
| 262 |
+
### Common Issues
|
| 263 |
+
|
| 264 |
+
#### Neo4j Connection Failed
|
| 265 |
+
- Verify Neo4j is running and accessible
|
| 266 |
+
- Verify credentials in `.env`
|
| 267 |
+
- Try connecting with cypher-shell: `cypher-shell -u neo4j -p password`
|
| 268 |
+
|
| 269 |
+
#### Celery Worker Not Processing
|
| 270 |
+
- Check Redis is running: `redis-cli ping`
|
| 271 |
+
- Verify broker URL in `.env`
|
| 272 |
+
- Check worker logs
|
| 273 |
+
|
| 274 |
+
#### Ollama Models Not Found
|
| 275 |
+
- Pull models: `ollama pull llama3.2 && ollama pull bge-m3`
|
| 276 |
+
- Verify Ollama is running: `curl http://localhost:11434/api/tags`
|
| 277 |
+
|
| 278 |
+
#### Query Returns No Results
|
| 279 |
+
- Verify documents are ingested: `GET /api/system/stats`
|
| 280 |
+
- Check ontology exists: `GET /api/ontology`
|
| 281 |
+
- Try simpler queries first
|
| 282 |
+
|
| 283 |
+
## Support
|
| 284 |
+
|
| 285 |
+
For issues or questions:
|
| 286 |
+
1. Check documentation and troubleshooting guide
|
| 287 |
+
2. Search existing GitHub issues
|
| 288 |
+
3. Open new issue with:
|
| 289 |
+
- Clear description
|
| 290 |
+
- Steps to reproduce
|
| 291 |
+
- Environment details
|
| 292 |
+
- Relevant logs
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
**Last Updated**: February 2026
|
| 297 |
+
**Version**: 0.1.0
|
QUICKSTART.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Quick Start Guide
|
| 2 |
+
|
| 3 |
+
Get the Graph RAG Service up and running in 10 minutes!
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
Before starting, make sure you have:
|
| 8 |
+
- Python 3.12 or higher
|
| 9 |
+
- Neo4j database (running locally or remotely)
|
| 10 |
+
- Redis server (running locally or remotely)
|
| 11 |
+
- UV package manager (will be installed if missing)
|
| 12 |
+
|
| 13 |
+
## Step 1: Clone and Setup
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
cd graph-RAG
|
| 17 |
+
|
| 18 |
+
# Install UV if you don't have it
|
| 19 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 20 |
+
|
| 21 |
+
# Install dependencies
|
| 22 |
+
uv sync
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Step 2: Configure Environment
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
# Copy environment template
|
| 29 |
+
cp .env.example .env
|
| 30 |
+
|
| 31 |
+
# Edit .env with your settings
|
| 32 |
+
# Configure NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD
|
| 33 |
+
# Configure REDIS_URL
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Step 3: Ensure Backend Services Are Running
|
| 37 |
+
|
| 38 |
+
Make sure Neo4j and Redis are running and accessible:
|
| 39 |
+
- Neo4j should be available at the URI specified in your .env file (default: bolt://localhost:7687)
|
| 40 |
+
- Redis should be available at the URL specified in your .env file (default: redis://localhost:6379)
|
| 41 |
+
|
| 42 |
+
## Step 4: Start the API Server
|
| 43 |
+
|
| 44 |
+
### On Windows:
|
| 45 |
+
```bash
|
| 46 |
+
start-server.bat
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### On Mac/Linux:
|
| 50 |
+
```bash
|
| 51 |
+
chmod +x start-server.sh
|
| 52 |
+
./start-server.sh
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
### Or directly (any OS):
|
| 56 |
+
```bash
|
| 57 |
+
uv run python main.py
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
The API server will start on `http://localhost:8000`
|
| 61 |
+
|
| 62 |
+
## Step 5: Start Celery Worker
|
| 63 |
+
|
| 64 |
+
For asynchronous document ingestion, start a worker in a **new terminal**:
|
| 65 |
+
|
| 66 |
+
### On Windows:
|
| 67 |
+
```bash
|
| 68 |
+
start-worker.bat
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
### On Mac/Linux:
|
| 72 |
+
```bash
|
| 73 |
+
chmod +x start-worker.sh
|
| 74 |
+
./start-worker.sh
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
### Or directly:
|
| 78 |
+
```bash
|
| 79 |
+
uv run celery -A src.graph_rag_service.workers.celery_worker worker --loglevel=info
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## Step 6: Start the React Frontend
|
| 83 |
+
|
| 84 |
+
In a **new terminal**:
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
cd frontend-react
|
| 88 |
+
|
| 89 |
+
# Install Node dependencies (first time only)
|
| 90 |
+
npm install
|
| 91 |
+
|
| 92 |
+
# Start the dev server
|
| 93 |
+
npm run dev
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
The React UI will open at `http://localhost:5173`
|
| 97 |
+
|
| 98 |
+
> **Note:** The project uses a **React/Vite** frontend (`frontend-react/`).
|
| 99 |
+
> There is no `streamlit run app.py` — any references to Streamlit in older docs are outdated.
|
| 100 |
+
|
| 101 |
+
Login with: **username** `admin` / **password** `admin`
|
| 102 |
+
|
| 103 |
+
## Step 7: Test the API
|
| 104 |
+
|
| 105 |
+
### Using cURL
|
| 106 |
+
|
| 107 |
+
1. **Get Access Token**
|
| 108 |
+
```bash
|
| 109 |
+
curl -X POST "http://localhost:8000/api/auth/login" \
|
| 110 |
+
-H "Content-Type: application/json" \
|
| 111 |
+
-d '{"username": "demo", "password": "demo"}'
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
Save the `access_token` from the response.
|
| 115 |
+
|
| 116 |
+
2. **Upload a Document**
|
| 117 |
+
```bash
|
| 118 |
+
curl -X POST "http://localhost:8000/api/documents/upload" \
|
| 119 |
+
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
| 120 |
+
-F "file=@sample.pdf"
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
3. **Query the Knowledge Base**
|
| 124 |
+
```bash
|
| 125 |
+
curl -X POST "http://localhost:8000/api/query" \
|
| 126 |
+
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
| 127 |
+
-H "Content-Type: application/json" \
|
| 128 |
+
-d '{
|
| 129 |
+
"query": "What is this document about?",
|
| 130 |
+
"top_k": 5
|
| 131 |
+
}'
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
### Using the Interactive Docs
|
| 135 |
+
|
| 136 |
+
Open your browser and go to:
|
| 137 |
+
- Swagger UI: `http://localhost:8000/docs`
|
| 138 |
+
- ReDoc: `http://localhost:8000/redoc`
|
| 139 |
+
|
| 140 |
+
Click "Authorize" and enter your access token to test endpoints interactively.
|
| 141 |
+
|
| 142 |
+
## Step 8: Setup Ollama (Optional)
|
| 143 |
+
|
| 144 |
+
If you want to use local LLMs instead of OpenAI/Anthropic:
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
# Install Ollama (https://ollama.ai)
|
| 148 |
+
# Then pull models:
|
| 149 |
+
ollama pull llama3.2
|
| 150 |
+
ollama pull bge-m3
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
Update `.env`:
|
| 154 |
+
```
|
| 155 |
+
DEFAULT_LLM_PROVIDER=ollama
|
| 156 |
+
OLLAMA_BASE_URL=http://localhost:11434
|
| 157 |
+
OLLAMA_MODEL=llama3.2
|
| 158 |
+
OLLAMA_EMBEDDING_MODEL=bge-m3
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
## Common Commands
|
| 162 |
+
|
| 163 |
+
### Check System Health
|
| 164 |
+
```bash
|
| 165 |
+
curl http://localhost:8000/api/system/health
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
### Get System Stats
|
| 169 |
+
```bash
|
| 170 |
+
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
| 171 |
+
http://localhost:8000/api/system/stats
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### View Ontology
|
| 175 |
+
```bash
|
| 176 |
+
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
| 177 |
+
http://localhost:8000/api/ontology
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
### Visualize Graph
|
| 181 |
+
```bash
|
| 182 |
+
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
| 183 |
+
"http://localhost:8000/api/graph/visualization?limit=50"
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
## Troubleshooting
|
| 187 |
+
|
| 188 |
+
### Neo4j Connection Error
|
| 189 |
+
```bash
|
| 190 |
+
# Verify credentials in .env
|
| 191 |
+
# Check Neo4j is accessible
|
| 192 |
+
|
| 193 |
+
# Access Neo4j browser
|
| 194 |
+
open http://localhost:7474
|
| 195 |
+
|
| 196 |
+
# Try connecting with cypher-shell
|
| 197 |
+
cypher-shell -u neo4j -p password
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
### Redis Connection Error
|
| 201 |
+
```bash
|
| 202 |
+
# Check Redis is running
|
| 203 |
+
redis-cli ping
|
| 204 |
+
|
| 205 |
+
# Test Redis connection
|
| 206 |
+
redis-cli -h localhost -p 6379 ping
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
### Worker Not Processing
|
| 210 |
+
```bash
|
| 211 |
+
# Check worker logs
|
| 212 |
+
# If running locally:
|
| 213 |
+
celery -A src.graph_rag_service.workers.celery_worker inspect active
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
### API Server Won't Start
|
| 217 |
+
```bash
|
| 218 |
+
# Check for port conflicts
|
| 219 |
+
lsof -i :8000 # On Mac/Linux
|
| 220 |
+
netstat -ano | findstr :8000 # On Windows
|
| 221 |
+
|
| 222 |
+
# View detailed logs
|
| 223 |
+
uv run python main.py
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
## Next Steps
|
| 227 |
+
|
| 228 |
+
- Read the [README.md](README.md) for comprehensive documentation
|
| 229 |
+
- Check [ARCHITECTURE.md](ARCHITECTURE.md) for system design details
|
| 230 |
+
- Explore the API docs at `http://localhost:8000/docs`
|
| 231 |
+
|
| 232 |
+
## Using with Different LLM Providers
|
| 233 |
+
|
| 234 |
+
### OpenAI
|
| 235 |
+
```bash
|
| 236 |
+
# In .env:
|
| 237 |
+
DEFAULT_LLM_PROVIDER=openai
|
| 238 |
+
OPENAI_API_KEY=sk-your-key-here
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
### Anthropic
|
| 242 |
+
```bash
|
| 243 |
+
# In .env:
|
| 244 |
+
DEFAULT_LLM_PROVIDER=anthropic
|
| 245 |
+
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
### Google Gemini
|
| 249 |
+
```bash
|
| 250 |
+
# In .env:
|
| 251 |
+
DEFAULT_LLM_PROVIDER=gemini
|
| 252 |
+
GOOGLE_API_KEY=your-key-here
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
Services will be available at:
|
| 256 |
+
- API: `http://localhost:8000`
|
| 257 |
+
- Neo4j Browser: `http://localhost:7474`
|
| 258 |
+
|
| 259 |
+
## Need Help?
|
| 260 |
+
|
| 261 |
+
- Check the troubleshooting section above
|
| 262 |
+
- Read the full [README.md](README.md)
|
| 263 |
+
- Open an issue on GitHub
|
| 264 |
+
|
| 265 |
+
Happy building! 🚀
|
README.md
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CORTEX — Agentic Graph RAG Platform
|
| 2 |
+
|
| 3 |
+
> **CORTEX** is a production-grade, agentic Knowledge Graph platform that transforms unstructured documents and web content into an intelligent, queryable knowledge graph — with a full-featured React UI, streaming AI chat, real-time graph visualization, simulation personas, and deep ontology governance.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## ✨ What's Been Built
|
| 8 |
+
|
| 9 |
+
### 🖥️ Full-Stack Application
|
| 10 |
+
|
| 11 |
+
| Layer | Stack |
|
| 12 |
+
|---|---|
|
| 13 |
+
| **Backend API** | FastAPI (async) + Python 3.12 |
|
| 14 |
+
| **Task Queue** | Celery + Redis |
|
| 15 |
+
| **Graph + Vector DB** | Neo4j 5.x (unified) |
|
| 16 |
+
| **LLM Layer** | OpenAI, Anthropic, Google Gemini, Ollama |
|
| 17 |
+
| **Frontend** | React 18 + TypeScript + Vite |
|
| 18 |
+
| **Unified Start** | `npm run rag` (concurrently launches all 3 processes) |
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## 🚀 Features
|
| 23 |
+
|
| 24 |
+
### 📥 Document Ingestion Pipeline
|
| 25 |
+
|
| 26 |
+
- **Multi-format ingestion**: PDF, TXT, MD, DOCX, CSV, XLSX, PPTX, JSON
|
| 27 |
+
- **Web scraping**: Single-page scrape via `POST /api/documents/scrape`
|
| 28 |
+
- **Deep web crawling**: Multi-depth Playwright-powered crawler (`POST /api/documents/crawl`) via Crawl4AI
|
| 29 |
+
- **Async Celery workers**: Upload returns instantly with a `task_id`; background workers build the graph
|
| 30 |
+
- **Re-ingest**: Admin can trigger re-processing of any stored document
|
| 31 |
+
- **Document preview & download**: In-browser preview of text/Markdown; PDF download via API
|
| 32 |
+
|
| 33 |
+
### 🔭 Ontology Management
|
| 34 |
+
|
| 35 |
+
- **Auto-generation**: LLM analyzes document chunks to propose entity types & relationship types
|
| 36 |
+
- **LLM-powered refinement**: `POST /api/ontology/refine` — refine schema with optional human feedback
|
| 37 |
+
- **Versioning**: Each schema change bumps the version (`v1.0` → `v1.1`, etc.)
|
| 38 |
+
- **Document-scoped stats**: `/api/ontology/stats?document_id=...` returns entity/relationship breakdowns for a specific document
|
| 39 |
+
- **Visual editor**: Ontology view in UI with editable entity types and relationship types
|
| 40 |
+
- **Ontology Drift Detection**: Automated drift detection compares live graph against new chunk samples; exposes pending/approved/rejected drift reports with admin approve/reject workflow
|
| 41 |
+
|
| 42 |
+
### 🤖 Agentic Retrieval System
|
| 43 |
+
|
| 44 |
+
- **LangGraph orchestration**: State-machine ReACT agent with multi-step reasoning and fallback mechanisms
|
| 45 |
+
- **Tool routing**: Dynamically selects from Vector Search, Graph Traversal, Cypher Generation, Metadata Filtering, Community Search, and Temporal Queries
|
| 46 |
+
- **Streaming responses**: Server-Sent Events (SSE) with real-time reasoning steps surfaced in the UI
|
| 47 |
+
- **Multi-turn conversations**: Persistent conversation threads stored in Neo4j, per-user
|
| 48 |
+
- **Document-scoped queries**: Filter retrieval to a specific document via `document_id`
|
| 49 |
+
- **Graph of Thoughts (GoT)**: Optional GoT reasoning mode for complex multi-hop queries
|
| 50 |
+
- **LLM-as-a-Judge (inline)**: Optional per-response quality scoring with hallucination risk, grounded/ungrounded claims, and confidence reasoning displayed in chat
|
| 51 |
+
- **Confidence display**: Confidence score, hallucination risk, and judge reasoning shown directly in the chat bubble
|
| 52 |
+
|
| 53 |
+
### 📊 RAGAS Evaluation & Quality Dashboard
|
| 54 |
+
|
| 55 |
+
- **`POST /api/eval/score`**: Run RAGAS-style evaluation on any Q&A pair (faithfulness, relevancy, context precision, hallucination detection)
|
| 56 |
+
- **`GET /api/eval/dashboard`**: Aggregate evaluation history — avg scores, hallucination rate, trend timeline
|
| 57 |
+
- Results persisted in Neo4j for longitudinal quality tracking
|
| 58 |
+
|
| 59 |
+
### 🗺️ Graph Intelligence
|
| 60 |
+
|
| 61 |
+
- **D3 force-directed visualization**: Interactive knowledge graph with zoom, pan, node selection, and a details modal
|
| 62 |
+
- **Graph Export**: Export full or document-scoped graph as JSON, Cypher, or GraphML
|
| 63 |
+
- **Community Detection**: Weakly-connected-components (WCC) community assignment with `POST /api/graph/communities/assign`
|
| 64 |
+
- **Community listing**: `GET /api/graph/communities` — top communities by entity count
|
| 65 |
+
- **Temporal Queries**: `GET /api/entities/{entity_name}/at-time` — retrieve entity relationships at a historical point in time
|
| 66 |
+
- **Semantic Entity Deduplication**: Multi-stage entity resolution with configurable similarity thresholds (`POST /api/entities/deduplicate`)
|
| 67 |
+
- **Entity Enrichment**: LLM-synthesized profile summaries for every entity, stored as `e.summary` (`POST /api/entities/enrich`)
|
| 68 |
+
- **Entity Chat (scoped)**: `POST /api/entities/{entity_name}/chat` — multi-turn conversation scoped entirely to a single entity's graph neighborhood
|
| 69 |
+
- **Graph Memory Updater**: Push raw text directly into the live knowledge graph without re-ingesting a document (`POST /api/graph/update`)
|
| 70 |
+
|
| 71 |
+
### 📝 Analytical Report Agent (ReACT)
|
| 72 |
+
|
| 73 |
+
- **`POST /api/report`**: ReACT multi-step report agent using InsightForge / PanoramaSearch / QuickSearch tools
|
| 74 |
+
- Decomposes topic into sub-questions → retrieves graph data → synthesizes sections → compiles structured markdown report
|
| 75 |
+
- Exposed in the **Insights** view (copy/download report as Markdown)
|
| 76 |
+
|
| 77 |
+
### 🎭 Simulation & Persona Engine
|
| 78 |
+
|
| 79 |
+
- **Persona generation**: Celery task that generates personas from graph entities (`POST /api/v1/simulation/generate_personas`)
|
| 80 |
+
- **Simulation ticks**: Background tick loop (`POST /api/v1/simulation/tick`)
|
| 81 |
+
- **Live persona interview**: `POST /api/v1/simulation/interview` — roleplay chat with any graph entity injecting their Neo4j memory as system context
|
| 82 |
+
- **SimulationRunView**: Dedicated UI view for managing and interacting with simulation personas
|
| 83 |
+
|
| 84 |
+
### 🛡️ Admin Dashboard
|
| 85 |
+
|
| 86 |
+
- **System statistics**: Node count, relationship count, LLM provider, environment
|
| 87 |
+
- **User management**: List users, update scopes/roles (RBAC)
|
| 88 |
+
- **Document vault**: View and delete all ingested documents
|
| 89 |
+
- **Graph CRUD**: Search, inspect, and delete graph nodes from the admin panel
|
| 90 |
+
- **Ontology governance**: Review and approve/reject pending ontology proposals
|
| 91 |
+
- **Celery task monitor**: View active and reserved tasks from the admin panel
|
| 92 |
+
- **Self-demotion guard**: Admins cannot demote their own account
|
| 93 |
+
- **Re-ingest button**: Re-queue any stored document from the document vault
|
| 94 |
+
- **User activity metrics**: Per-user conversation count, message count, last active timestamp
|
| 95 |
+
|
| 96 |
+
### 🔐 Authentication & Security
|
| 97 |
+
|
| 98 |
+
- **JWT authentication**: Token-based auth with configurable expiry
|
| 99 |
+
- **RBAC scopes**: `read`, `write`, `admin` scopes enforced per endpoint
|
| 100 |
+
- **User registration**: `POST /api/auth/register`
|
| 101 |
+
- **Pydantic validation**: All API inputs validated at the model layer
|
| 102 |
+
- **Cypher injection prevention**: Schema validation and query whitelisting
|
| 103 |
+
- **File upload limits**: File size and MIME type enforcement
|
| 104 |
+
|
| 105 |
+
### 🌐 Frontend (React/TypeScript)
|
| 106 |
+
|
| 107 |
+
Seven fully implemented views accessible from the `CORTEX` top navigation bar:
|
| 108 |
+
|
| 109 |
+
| Route | View | Description |
|
| 110 |
+
|---|---|---|
|
| 111 |
+
| `/` | **Home** | Animated stats dashboard — documents, entities, relationships, graph health |
|
| 112 |
+
| `/process` | **Process** | Upload files or scrape/crawl URLs; view ingestion queue and document list |
|
| 113 |
+
| `/ontology` | **Ontology** | View/edit the live ontology schema; run LLM refinement; inspect entity/relationship stats per doc |
|
| 114 |
+
| `/interact` | **Interact** | Streaming AI chat with reasoning steps, confidence, hallucination risk; conversation history |
|
| 115 |
+
| `/simulate` | **Simulate** | Simulation persona management and live interview interface |
|
| 116 |
+
| `/insights` | **Insights** | Topic-driven analytical report generation with copy/download |
|
| 117 |
+
| `/admin` | **Admin** _(admin-only)_ | Full admin panel for users, docs, tasks, ontology governance |
|
| 118 |
+
|
| 119 |
+
### 🔭 Observability
|
| 120 |
+
|
| 121 |
+
- **OpenTelemetry**: Distributed tracing (silenced from console; configured for export)
|
| 122 |
+
- **Health check**: `GET /api/system/health` — Neo4j, Redis, Celery worker status
|
| 123 |
+
- **System stats**: `GET /api/system/stats` — document, entity, relationship, chunk counts
|
| 124 |
+
- **User stats**: `GET /api/system/my-stats` — per-user conversation and message activity
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## 🏗️ Architecture
|
| 129 |
+
|
| 130 |
+
```
|
| 131 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 132 |
+
│ React Frontend (CORTEX) │
|
| 133 |
+
│ Home │ Process │ Ontology │ Interact │ Simulate │ Insights │ Admin │
|
| 134 |
+
└─────────────────────────────┬───────────────────────────────────────────────┘
|
| 135 |
+
│ HTTP / SSE
|
| 136 |
+
┌─────────────────────────────▼───────────────────────────────────────────────┐
|
| 137 |
+
│ FastAPI Gateway (port 8000) │
|
| 138 |
+
│ JWT Auth · RBAC Scopes · CORS · OpenTelemetry │
|
| 139 |
+
└──────┬──────────────────────┬──────────────────────┬────────────────────────┘
|
| 140 |
+
│ │ │
|
| 141 |
+
┌──────▼──────┐ ┌───────────▼──────────┐ ┌───────▼────────────────────┐
|
| 142 |
+
│ Ingestion │ │ ReACT Agent System │ │ Report Agent (ReACT) │
|
| 143 |
+
│ Pipeline │ │ - Vector Search │ │ - InsightForge │
|
| 144 |
+
│ - Parser │ │ - Graph Traversal │ │ - PanoramaSearch │
|
| 145 |
+
│ - Ontology │ │ - Cypher Gen (GoT) │ │ - QuickSearch │
|
| 146 |
+
│ - Extractor│ │ - Community Search │ │ - Markdown output │
|
| 147 |
+
│ - Web │ │ - Temporal Queries │ └────────────────────────────┘
|
| 148 |
+
│ Crawler │ │ - LLM-as-a-Judge │
|
| 149 |
+
└──────┬──────┘ └─────────┬────────────┘
|
| 150 |
+
│ │
|
| 151 |
+
┌──────▼────────────────────▼──────────────────┐
|
| 152 |
+
│ Neo4j 5.x Database │
|
| 153 |
+
│ Entities · Chunks · Relationships · │
|
| 154 |
+
│ Vector Index · Conversations · │
|
| 155 |
+
│ EvalResults · DriftReports · Users │
|
| 156 |
+
└───────────────────────────────────────────────┘
|
| 157 |
+
│
|
| 158 |
+
┌──────▼──────────────────────┐
|
| 159 |
+
│ Celery Workers (Redis) │
|
| 160 |
+
│ - Async document ingestion │
|
| 161 |
+
│ - Persona generation │
|
| 162 |
+
│ - Simulation ticks │
|
| 163 |
+
└─────────────────────────────┘
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
| 168 |
+
## 📦 Project Structure
|
| 169 |
+
|
| 170 |
+
```
|
| 171 |
+
graph-RAG/
|
| 172 |
+
├── src/graph_rag_service/
|
| 173 |
+
│ ├── api/
|
| 174 |
+
│ │ ├── server.py # Main FastAPI app + all API routes (1900 lines)
|
| 175 |
+
│ │ ├── auth.py # JWT auth + RBAC helpers
|
| 176 |
+
│ │ ├── admin.py # Admin sub-router
|
| 177 |
+
│ │ ├── simulation.py # Simulation / persona interview router
|
| 178 |
+
│ │ └── models.py # All Pydantic request/response models
|
| 179 |
+
│ ├── core/
|
| 180 |
+
│ │ ├── abstractions.py # Abstract base classes (GraphStore, VectorStore, LLMProvider)
|
| 181 |
+
│ │ ├── models.py # Domain data models
|
| 182 |
+
│ │ ├── neo4j_store.py # Full Neo4j implementation (graph + vector)
|
| 183 |
+
│ │ ├── llm_factory.py # Multi-LLM provider factory + UnifiedLLMProvider
|
| 184 |
+
│ │ ├── entity_resolver.py # Semantic entity deduplication
|
| 185 |
+
│ │ └── storage.py # File storage abstraction
|
| 186 |
+
│ ├── ingestion/
|
| 187 |
+
│ │ ├── pipeline.py # End-to-end ingestion orchestrator
|
| 188 |
+
│ │ ├── document_processor.py # Multi-format document parsing
|
| 189 |
+
│ │ ├── ontology_generator.py # LLM ontology generation + refinement
|
| 190 |
+
│ │ ├── extractor.py # Entity + relationship extraction
|
| 191 |
+
│ │ ├── web_crawler.py # Playwright-based deep web crawler (Crawl4AI)
|
| 192 |
+
│ │ └── persona_generator.py # Simulation persona generation
|
| 193 |
+
│ ├── retrieval/
|
| 194 |
+
│ │ ├── agent.py # LangGraph ReACT retrieval agent
|
| 195 |
+
│ │ ├── tools.py # Retrieval tools + RAGEvaluator (RAGAS)
|
| 196 |
+
│ │ └── report_agent.py # ReACT analytical report agent
|
| 197 |
+
│ ├── services/
|
| 198 |
+
│ │ ├── graph_memory_updater.py # Push raw text → live graph
|
| 199 |
+
│ │ ├── entity_enricher.py # LLM entity profile summaries
|
| 200 |
+
│ │ └── ontology_drift_detector.py # Automated schema drift detection
|
| 201 |
+
│ ├── workers/
|
| 202 |
+
│ │ └── celery_worker.py # Celery app + ingest_document_task
|
| 203 |
+
│ ├── observability/
|
| 204 |
+
│ │ └── tracing.py # OpenTelemetry setup (console suppressed)
|
| 205 |
+
│ ├── config.py # Pydantic settings (all env vars)
|
| 206 |
+
│ └── main.py # Uvicorn entry point
|
| 207 |
+
├── frontend-react/
|
| 208 |
+
│ └── src/
|
| 209 |
+
│ ├── views/
|
| 210 |
+
│ │ ├── Home.tsx # Animated stats dashboard
|
| 211 |
+
│ │ ├── Process.tsx # Document upload + URL scrape/crawl
|
| 212 |
+
│ │ ├── Ontology.tsx # Schema editor + stats
|
| 213 |
+
│ │ ├── InteractionView.tsx # Streaming chat + conversation history
|
| 214 |
+
│ │ ├── SimulationRunView.tsx # Persona simulation UI
|
| 215 |
+
│ │ ├── InsightsView.tsx # Report generation + copy/download
|
| 216 |
+
│ │ ├── AdminDashboard.tsx # Full admin panel
|
| 217 |
+
│ │ └── Login.tsx # Login page
|
| 218 |
+
│ ├── components/
|
| 219 |
+
│ │ └── GraphCanvas.tsx # D3 force-directed graph + node modal
|
| 220 |
+
│ ├── context/
|
| 221 |
+
│ │ └── AuthContext.tsx # JWT auth context + hooks
|
| 222 |
+
│ └── App.tsx # Router + top-nav (CORTEX branding)
|
| 223 |
+
├── tests/ # Test suite
|
| 224 |
+
├── data/uploads/ # Uploaded documents (local storage)
|
| 225 |
+
├── .env.example # All configurable environment variables
|
| 226 |
+
├── pyproject.toml # Python project + uv dependencies
|
| 227 |
+
├── package.json # Unified start scripts (npm run rag)
|
| 228 |
+
├── ARCHITECTURE.md # Detailed architecture design doc
|
| 229 |
+
└── QUICKSTART.md # 5-minute quick start guide
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
---
|
| 233 |
+
|
| 234 |
+
## ⚡ Quick Start
|
| 235 |
+
|
| 236 |
+
### Prerequisites
|
| 237 |
+
|
| 238 |
+
- Python 3.12+
|
| 239 |
+
- Node.js 18+
|
| 240 |
+
- Neo4j 5.x (running)
|
| 241 |
+
- Redis (running)
|
| 242 |
+
- Ollama *(optional, for local LLMs)*
|
| 243 |
+
|
| 244 |
+
### 1. Clone & Install
|
| 245 |
+
|
| 246 |
+
```bash
|
| 247 |
+
git clone <repository-url>
|
| 248 |
+
cd graph-RAG
|
| 249 |
+
|
| 250 |
+
# Installs Python deps (uv), frontend (npm), and Playwright Chromium
|
| 251 |
+
npm install
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
### 2. Configure Environment
|
| 255 |
+
|
| 256 |
+
```bash
|
| 257 |
+
cp .env.example .env
|
| 258 |
+
# Fill in NEO4J_URI, NEO4J_PASSWORD, and your LLM API keys
|
| 259 |
+
```
|
| 260 |
+
|
| 261 |
+
### 3. Start Neo4j
|
| 262 |
+
|
| 263 |
+
```bash
|
| 264 |
+
docker run -d --name neo4j \
|
| 265 |
+
-p 7474:7474 -p 7687:7687 \
|
| 266 |
+
-e NEO4J_AUTH=neo4j/password \
|
| 267 |
+
neo4j:latest
|
| 268 |
+
```
|
| 269 |
+
|
| 270 |
+
### 4. Start Redis
|
| 271 |
+
|
| 272 |
+
```bash
|
| 273 |
+
docker run -d --name redis -p 6379:6379 redis:alpine
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
### 5. Launch Everything
|
| 277 |
+
|
| 278 |
+
```bash
|
| 279 |
+
npm run rag
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
This starts three color-coded processes concurrently:
|
| 283 |
+
|
| 284 |
+
| Process | URL |
|
| 285 |
+
|---|---|
|
| 286 |
+
| **API Server** | `http://localhost:8000` |
|
| 287 |
+
| **API Docs** | `http://localhost:8000/docs` |
|
| 288 |
+
| **React Frontend** | `http://localhost:5173` |
|
| 289 |
+
|
| 290 |
+
> Default credentials: `admin` / `admin`
|
| 291 |
+
|
| 292 |
+
---
|
| 293 |
+
|
| 294 |
+
## 🔑 Environment Variables
|
| 295 |
+
|
| 296 |
+
Copy `.env.example` to `.env` and configure:
|
| 297 |
+
|
| 298 |
+
```env
|
| 299 |
+
# Neo4j
|
| 300 |
+
NEO4J_URI=bolt://localhost:7687
|
| 301 |
+
NEO4J_USER=neo4j
|
| 302 |
+
NEO4J_PASSWORD=password
|
| 303 |
+
|
| 304 |
+
# Redis
|
| 305 |
+
REDIS_HOST=localhost
|
| 306 |
+
REDIS_PORT=6379
|
| 307 |
+
|
| 308 |
+
# LLM Provider (openai | anthropic | gemini | ollama)
|
| 309 |
+
DEFAULT_LLM_PROVIDER=gemini
|
| 310 |
+
GOOGLE_API_KEY=your-key-here
|
| 311 |
+
|
| 312 |
+
# Optional: OpenAI / Anthropic
|
| 313 |
+
OPENAI_API_KEY=sk-...
|
| 314 |
+
ANTHROPIC_API_KEY=sk-ant-...
|
| 315 |
+
|
| 316 |
+
# Optional: Ollama (local)
|
| 317 |
+
OLLAMA_BASE_URL=http://localhost:11434
|
| 318 |
+
OLLAMA_MODEL=deepseek-r1:7b
|
| 319 |
+
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
| 320 |
+
|
| 321 |
+
# Feature flags
|
| 322 |
+
ENABLE_LLM_JUDGE=true
|
| 323 |
+
|
| 324 |
+
# Security
|
| 325 |
+
SECRET_KEY=change-this-in-production
|
| 326 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## 🌐 API Reference
|
| 332 |
+
|
| 333 |
+
### Authentication
|
| 334 |
+
| Method | Endpoint | Description |
|
| 335 |
+
|---|---|---|
|
| 336 |
+
| `POST` | `/api/auth/register` | Register new user |
|
| 337 |
+
| `POST` | `/api/auth/login` | Login → JWT token |
|
| 338 |
+
| `GET` | `/api/auth/me` | Get current user info |
|
| 339 |
+
|
| 340 |
+
### Documents
|
| 341 |
+
| Method | Endpoint | Description |
|
| 342 |
+
|---|---|---|
|
| 343 |
+
| `POST` | `/api/documents/upload` | Upload file (PDF, DOCX, TXT, MD, CSV, XLSX, PPTX, JSON) |
|
| 344 |
+
| `POST` | `/api/documents/scrape` | Scrape single URL → ingest |
|
| 345 |
+
| `POST` | `/api/documents/crawl` | Deep multi-page Playwright crawl → ingest *(API Only)* |
|
| 346 |
+
| `GET` | `/api/documents` | List all ingested documents |
|
| 347 |
+
| `DELETE` | `/api/documents/{id}` | Delete document + graph chunks |
|
| 348 |
+
| `GET` | `/api/documents/{id}/download` | Download source file |
|
| 349 |
+
| `GET` | `/api/documents/{id}/preview` | Preview text content |
|
| 350 |
+
| `GET` | `/api/documents/status/{task_id}` | Ingestion task status |
|
| 351 |
+
|
| 352 |
+
### Query & Chat
|
| 353 |
+
| Method | Endpoint | Description |
|
| 354 |
+
|---|---|---|
|
| 355 |
+
| `POST` | `/api/query` | Agentic query (streaming or JSON); supports `document_id`, `use_got` |
|
| 356 |
+
| `GET` | `/api/conversations` | List conversation threads |
|
| 357 |
+
| `GET` | `/api/conversations/{id}` | Get conversation + messages |
|
| 358 |
+
| `DELETE` | `/api/conversations/{id}` | Delete conversation |
|
| 359 |
+
|
| 360 |
+
### Ontology
|
| 361 |
+
| Method | Endpoint | Description |
|
| 362 |
+
|---|---|---|
|
| 363 |
+
| `GET` | `/api/ontology` | Get current ontology |
|
| 364 |
+
| `PUT` | `/api/ontology` | Update ontology (admin) |
|
| 365 |
+
| `POST` | `/api/ontology/refine` | LLM-powered ontology refinement |
|
| 366 |
+
| `GET` | `/api/ontology/stats` | Entity/relationship counts (optional doc filter) |
|
| 367 |
+
| `POST` | `/api/ontology/drift/detect` | Trigger drift detection |
|
| 368 |
+
| `GET` | `/api/ontology/drift` | List drift reports |
|
| 369 |
+
| `POST` | `/api/ontology/drift/{id}/approve` | Approve drift → merge into ontology |
|
| 370 |
+
| `POST` | `/api/ontology/drift/{id}/reject` | Reject drift report |
|
| 371 |
+
|
| 372 |
+
### Graph
|
| 373 |
+
| Method | Endpoint | Description |
|
| 374 |
+
|---|---|---|
|
| 375 |
+
| `GET` | `/api/graph/visualization` | Graph nodes + edges for D3 rendering |
|
| 376 |
+
| `GET` | `/api/graph/export` | Export graph (json \| cypher \| graphml) |
|
| 377 |
+
| `POST` | `/api/graph/update` | Push raw text → merge into live graph |
|
| 378 |
+
| `POST` | `/api/graph/communities/assign` | Run WCC community detection |
|
| 379 |
+
| `GET` | `/api/graph/communities` | List top communities |
|
| 380 |
+
|
| 381 |
+
### Entities
|
| 382 |
+
| Method | Endpoint | Description |
|
| 383 |
+
|---|---|---|
|
| 384 |
+
| `POST` | `/api/entities/deduplicate` | Semantic entity resolution + merge |
|
| 385 |
+
| `POST` | `/api/entities/enrich` | Generate LLM summaries for all entities |
|
| 386 |
+
| `GET` | `/api/entities/{name}/summary` | Get enriched entity profile |
|
| 387 |
+
| `POST` | `/api/entities/{name}/chat` | Multi-turn entity-scoped chat |
|
| 388 |
+
| `GET` | `/api/entities/{name}/at-time` | Temporal query (ISO 8601 date) |
|
| 389 |
+
|
| 390 |
+
### Reports & Evaluation
|
| 391 |
+
| Method | Endpoint | Description |
|
| 392 |
+
|---|---|---|
|
| 393 |
+
| `POST` | `/api/report` | Generate ReACT analytical report (markdown) |
|
| 394 |
+
| `POST` | `/api/eval/score` | RAGAS evaluation of a Q&A pair |
|
| 395 |
+
| `GET` | `/api/eval/dashboard` | Evaluation history dashboard |
|
| 396 |
+
|
| 397 |
+
### Simulation
|
| 398 |
+
| Method | Endpoint | Description |
|
| 399 |
+
|---|---|---|
|
| 400 |
+
| `POST` | `/api/v1/simulation/interview` | Live persona interview (in-character LLM) |
|
| 401 |
+
| `GET` | `/api/v1/simulation/report` | Sandbox analytical report *(API Only)* |
|
| 402 |
+
| `POST` | `/api/v1/simulation/generate_personas` | Queue persona generation task *(API Only)* |
|
| 403 |
+
| `POST` | `/api/v1/simulation/tick` | Advance simulation tick *(API Only)* |
|
| 404 |
+
|
| 405 |
+
### System & Admin
|
| 406 |
+
| Method | Endpoint | Description |
|
| 407 |
+
|---|---|---|
|
| 408 |
+
| `GET` | `/api/system/health` | Neo4j + Redis + Celery health |
|
| 409 |
+
| `GET` | `/api/system/stats` | Document, entity, relationship counts |
|
| 410 |
+
| `GET` | `/api/system/my-stats` | Current user's activity stats |
|
| 411 |
+
| `GET` | `/api/system/formats` | Supported ingestion file formats |
|
| 412 |
+
| `GET` | `/api/admin/stats` | Admin-only system stats |
|
| 413 |
+
| `GET` | `/api/admin/users` | List all users |
|
| 414 |
+
| `PUT` | `/api/admin/users/{username}/role` | Update user scopes |
|
| 415 |
+
| `GET` | `/api/admin/tasks` | View Celery tasks |
|
| 416 |
+
| `GET` | `/api/admin/documents` | Admin document vault |
|
| 417 |
+
| `POST` | `/api/admin/documents/{id}/reingest` | Re-queue document for ingestion |
|
| 418 |
+
| `GET` | `/api/admin/graph/nodes` | Search graph nodes |
|
| 419 |
+
| `DELETE` | `/api/admin/graph/nodes/{id}` | Delete a graph node |
|
| 420 |
+
|
| 421 |
+
---
|
| 422 |
+
|
| 423 |
+
## 🧪 Testing
|
| 424 |
+
|
| 425 |
+
```bash
|
| 426 |
+
# Run tests
|
| 427 |
+
uv run pytest
|
| 428 |
+
|
| 429 |
+
# With coverage
|
| 430 |
+
uv run pytest --cov=src/graph_rag_service
|
| 431 |
+
```
|
| 432 |
+
|
| 433 |
+
---
|
| 434 |
+
|
| 435 |
+
## 🚀 Production Deployment
|
| 436 |
+
|
| 437 |
+
| Process | Command |
|
| 438 |
+
|---|---|
|
| 439 |
+
| **API Server** | `uv run python main.py` |
|
| 440 |
+
| **Celery Worker** | `uv run celery -A src.graph_rag_service.workers.celery_worker worker --loglevel=info --concurrency=4 --pool=threads` |
|
| 441 |
+
| **React Build** | `cd frontend-react && npm run build` |
|
| 442 |
+
|
| 443 |
+
The built React assets can be served directly by FastAPI (static file mount), or deployed to a CDN separately. Neo4j and Redis can be run via Docker, managed cloud services (AuraDB, Redis Cloud), or self-hosted.
|
| 444 |
+
|
| 445 |
+
---
|
| 446 |
+
|
| 447 |
+
## 📄 Additional Documentation
|
| 448 |
+
|
| 449 |
+
- **[ARCHITECTURE.md](./ARCHITECTURE.md)** — Deep dive into the system design, data flow, and component interactions
|
| 450 |
+
- **[QUICKSTART.md](./QUICKSTART.md)** — 5-minute environment setup guide
|
| 451 |
+
- **`/docs`** — Interactive Swagger UI (auto-generated from FastAPI)
|
| 452 |
+
|
| 453 |
+
---
|
| 454 |
+
|
| 455 |
+
**Project Status**: Production-grade MVP · Actively developed
|
| 456 |
+
**License**: Proprietary — all rights reserved
|
data/uploads/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# This file ensures the data/uploads directory is created in git
|
fix_datetime.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
target_dir = r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service"
|
| 4 |
+
|
| 5 |
+
for root, dirs, files in os.walk(target_dir):
|
| 6 |
+
for f in files:
|
| 7 |
+
if f.endswith(".py"):
|
| 8 |
+
p = os.path.join(root, f)
|
| 9 |
+
with open(p, "r", encoding="utf-8") as file:
|
| 10 |
+
content = file.read()
|
| 11 |
+
|
| 12 |
+
if "datetime.utcnow()" in content:
|
| 13 |
+
content = content.replace("datetime.utcnow()", "datetime.now(timezone.utc).replace(tzinfo=None)")
|
| 14 |
+
if "from datetime import datetime" in content and "timezone" not in content:
|
| 15 |
+
content = content.replace("from datetime import datetime", "from datetime import datetime, timezone")
|
| 16 |
+
elif "import datetime" in content and "timezone" not in content:
|
| 17 |
+
content = "from datetime import timezone\n" + content
|
| 18 |
+
|
| 19 |
+
with open(p, "w", encoding="utf-8") as file:
|
| 20 |
+
file.write(content)
|
| 21 |
+
print(f"Updated {p}")
|
fix_timezone_imports.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
files = [
|
| 4 |
+
r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\api\auth.py",
|
| 5 |
+
r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\api\routers\ontology.py",
|
| 6 |
+
r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\api\routers\system.py",
|
| 7 |
+
r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\ingestion\document_processor.py",
|
| 8 |
+
r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\ingestion\ontology_generator.py",
|
| 9 |
+
r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\retrieval\report_agent.py",
|
| 10 |
+
r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\services\graph_memory_updater.py",
|
| 11 |
+
r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\services\ontology_drift_detector.py",
|
| 12 |
+
r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\workers\simulation_runner.py"
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
for f in files:
|
| 16 |
+
with open(f, 'r', encoding='utf-8') as file:
|
| 17 |
+
content = file.read()
|
| 18 |
+
|
| 19 |
+
# Check if 'from datetime import datetime' exists
|
| 20 |
+
if "from datetime import datetime\n" in content:
|
| 21 |
+
content = content.replace("from datetime import datetime\n", "from datetime import datetime, timezone\n")
|
| 22 |
+
elif "from datetime import datetime," in content:
|
| 23 |
+
if "timezone" not in content:
|
| 24 |
+
content = content.replace("from datetime import datetime,", "from datetime import datetime, timezone,")
|
| 25 |
+
elif "import datetime\n" in content:
|
| 26 |
+
content = content.replace("import datetime\n", "import datetime\nfrom datetime import timezone\n")
|
| 27 |
+
else:
|
| 28 |
+
# Just put it at the top
|
| 29 |
+
content = "from datetime import timezone\n" + content
|
| 30 |
+
|
| 31 |
+
with open(f, 'w', encoding='utf-8') as file:
|
| 32 |
+
file.write(content)
|
| 33 |
+
print(f"Fixed {f}")
|
frontend-react/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend-react/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
frontend-react/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
frontend-react/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend-react</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend-react/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend-react/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend-react",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview",
|
| 11 |
+
"server": "cd ../ && .\\venv\\Scripts\\python.exe -m uvicorn src.graph_rag_service.api.server:app --reload",
|
| 12 |
+
"worker": "cd ../ && .\\venv\\Scripts\\celery.exe -A src.graph_rag_service.workers.celery_worker worker --loglevel=info --pool=solo",
|
| 13 |
+
"backend": "concurrently \"npm run server\" \"npm run worker\""
|
| 14 |
+
},
|
| 15 |
+
"dependencies": {
|
| 16 |
+
"@heroicons/react": "^2.2.0",
|
| 17 |
+
"@types/d3": "^7.4.3",
|
| 18 |
+
"d3": "^7.9.0",
|
| 19 |
+
"lucide-react": "^1.7.0",
|
| 20 |
+
"react": "^19.2.4",
|
| 21 |
+
"react-dom": "^19.2.4",
|
| 22 |
+
"react-markdown": "^10.1.0",
|
| 23 |
+
"react-router-dom": "^7.13.2",
|
| 24 |
+
"remark-gfm": "^4.0.1"
|
| 25 |
+
},
|
| 26 |
+
"devDependencies": {
|
| 27 |
+
"@eslint/js": "^9.39.4",
|
| 28 |
+
"@tailwindcss/vite": "^4.2.4",
|
| 29 |
+
"@types/node": "^24.12.0",
|
| 30 |
+
"@types/react": "^19.2.14",
|
| 31 |
+
"@types/react-dom": "^19.2.3",
|
| 32 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 33 |
+
"autoprefixer": "^10.5.0",
|
| 34 |
+
"concurrently": "^9.2.1",
|
| 35 |
+
"eslint": "^9.39.4",
|
| 36 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 37 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 38 |
+
"globals": "^17.4.0",
|
| 39 |
+
"openapi-typescript-codegen": "^0.30.0",
|
| 40 |
+
"postcss": "^8.5.10",
|
| 41 |
+
"tailwindcss": "^4.2.4",
|
| 42 |
+
"typescript": "~5.9.3",
|
| 43 |
+
"typescript-eslint": "^8.57.0",
|
| 44 |
+
"vite": "^8.0.1"
|
| 45 |
+
}
|
| 46 |
+
}
|
frontend-react/public/_redirects
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/* /index.html 200
|
frontend-react/public/favicon.svg
ADDED
|
|
frontend-react/public/icons.svg
ADDED
|
|
frontend-react/public/thumbnail.png
ADDED
|
|
Git LFS Details
|
frontend-react/src/App.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/* Empty file to override default vite styles */
|
frontend-react/src/App.tsx
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { BrowserRouter, Routes, Route, Navigate, NavLink } from 'react-router-dom';
|
| 3 |
+
import { AuthProvider, useAuth } from './context/AuthContext';
|
| 4 |
+
|
| 5 |
+
import Login from './views/Login';
|
| 6 |
+
import Home from './views/Home';
|
| 7 |
+
import Process from './views/Process';
|
| 8 |
+
import InteractionView from './views/InteractionView';
|
| 9 |
+
import SimulationRunView from './views/SimulationRunView';
|
| 10 |
+
import Ontology from './views/Ontology';
|
| 11 |
+
import InsightsView from './views/InsightsView';
|
| 12 |
+
import AdminDashboard from './views/AdminDashboard';
|
| 13 |
+
|
| 14 |
+
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
| 15 |
+
const { isAuthenticated } = useAuth();
|
| 16 |
+
if (!isAuthenticated) return <Navigate to="/login" />;
|
| 17 |
+
return children;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
const Navigation: React.FC = () => {
|
| 21 |
+
const { logout, user } = useAuth();
|
| 22 |
+
return (
|
| 23 |
+
<nav className="top-nav">
|
| 24 |
+
<div className="nav-brand">CORTEX</div>
|
| 25 |
+
|
| 26 |
+
<div className="nav-links">
|
| 27 |
+
<NavLink to="/" end className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>HOME</NavLink>
|
| 28 |
+
<NavLink to="/process" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>PROCESS</NavLink>
|
| 29 |
+
<NavLink to="/ontology" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>ONTOLOGY</NavLink>
|
| 30 |
+
<NavLink to="/interact" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>INTERACT</NavLink>
|
| 31 |
+
<NavLink to="/simulate" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>SIMULATE</NavLink>
|
| 32 |
+
<NavLink to="/insights" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>INSIGHTS</NavLink>
|
| 33 |
+
{user?.scopes?.includes('admin') && (
|
| 34 |
+
<NavLink to="/admin" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>ADMIN</NavLink>
|
| 35 |
+
)}
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div className="nav-right">
|
| 39 |
+
<span className="user-badge" title={`Logged in as ${user?.username}`}>
|
| 40 |
+
{user?.username}
|
| 41 |
+
</span>
|
| 42 |
+
<button onClick={logout} className="logout-btn">LOGOUT</button>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<style>{`
|
| 46 |
+
.top-nav {
|
| 47 |
+
position: sticky;
|
| 48 |
+
top: 0;
|
| 49 |
+
z-index: 1000;
|
| 50 |
+
padding: 0 2rem;
|
| 51 |
+
height: 60px;
|
| 52 |
+
border-bottom: 2px solid #000;
|
| 53 |
+
background: #fff;
|
| 54 |
+
display: flex;
|
| 55 |
+
align-items: center;
|
| 56 |
+
justify-content: space-between;
|
| 57 |
+
gap: 1rem;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.nav-brand {
|
| 61 |
+
font-family: var(--font-mono);
|
| 62 |
+
font-weight: 700;
|
| 63 |
+
letter-spacing: 3px;
|
| 64 |
+
font-size: 1rem;
|
| 65 |
+
white-space: nowrap;
|
| 66 |
+
flex-shrink: 0;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.nav-links {
|
| 70 |
+
display: flex;
|
| 71 |
+
gap: 0;
|
| 72 |
+
align-items: stretch;
|
| 73 |
+
height: 100%;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.nav-link {
|
| 77 |
+
display: flex;
|
| 78 |
+
align-items: center;
|
| 79 |
+
padding: 0 1rem;
|
| 80 |
+
font-family: var(--font-mono);
|
| 81 |
+
font-size: 0.78rem;
|
| 82 |
+
font-weight: 600;
|
| 83 |
+
letter-spacing: 1px;
|
| 84 |
+
text-decoration: none;
|
| 85 |
+
color: #000;
|
| 86 |
+
border-left: 1px solid transparent;
|
| 87 |
+
border-right: 1px solid transparent;
|
| 88 |
+
transition: background 0.15s ease, color 0.15s ease;
|
| 89 |
+
white-space: nowrap;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.nav-link:hover {
|
| 93 |
+
background: #f0f0f0;
|
| 94 |
+
color: #000;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.nav-link.active {
|
| 98 |
+
background: #000;
|
| 99 |
+
color: #fff;
|
| 100 |
+
border-color: #000;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.nav-right {
|
| 104 |
+
display: flex;
|
| 105 |
+
align-items: center;
|
| 106 |
+
gap: 0.75rem;
|
| 107 |
+
flex-shrink: 0;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.user-badge {
|
| 111 |
+
font-family: var(--font-mono);
|
| 112 |
+
font-size: 0.75rem;
|
| 113 |
+
font-weight: 700;
|
| 114 |
+
background: #000;
|
| 115 |
+
color: #fff;
|
| 116 |
+
padding: 0.2rem 0.6rem;
|
| 117 |
+
letter-spacing: 0.5px;
|
| 118 |
+
max-width: 120px;
|
| 119 |
+
overflow: hidden;
|
| 120 |
+
text-overflow: ellipsis;
|
| 121 |
+
white-space: nowrap;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.logout-btn {
|
| 125 |
+
font-family: var(--font-mono);
|
| 126 |
+
font-size: 0.75rem;
|
| 127 |
+
font-weight: 700;
|
| 128 |
+
background: transparent;
|
| 129 |
+
color: #000;
|
| 130 |
+
border: 1.5px solid #000;
|
| 131 |
+
padding: 0.25rem 0.75rem;
|
| 132 |
+
cursor: pointer;
|
| 133 |
+
letter-spacing: 0.5px;
|
| 134 |
+
transition: all 0.15s ease;
|
| 135 |
+
white-space: nowrap;
|
| 136 |
+
}
|
| 137 |
+
.logout-btn:hover {
|
| 138 |
+
background: #000;
|
| 139 |
+
color: #fff;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
@media (max-width: 768px) {
|
| 143 |
+
.top-nav { padding: 0 1rem; height: auto; flex-wrap: wrap; padding: 0.5rem 1rem; }
|
| 144 |
+
.nav-link { padding: 0.4rem 0.6rem; font-size: 0.7rem; }
|
| 145 |
+
.nav-brand { width: 100%; padding: 0.25rem 0; }
|
| 146 |
+
}
|
| 147 |
+
`}</style>
|
| 148 |
+
</nav>
|
| 149 |
+
);
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
const Layout = ({ children }: { children: React.ReactNode }) => (
|
| 153 |
+
<>
|
| 154 |
+
<Navigation />
|
| 155 |
+
{children}
|
| 156 |
+
</>
|
| 157 |
+
);
|
| 158 |
+
|
| 159 |
+
function App() {
|
| 160 |
+
return (
|
| 161 |
+
<AuthProvider>
|
| 162 |
+
<BrowserRouter>
|
| 163 |
+
<Routes>
|
| 164 |
+
<Route path="/login" element={<Login />} />
|
| 165 |
+
|
| 166 |
+
<Route path="/" element={<ProtectedRoute><Layout><Home /></Layout></ProtectedRoute>} />
|
| 167 |
+
<Route path="/process" element={<ProtectedRoute><Layout><Process /></Layout></ProtectedRoute>} />
|
| 168 |
+
<Route path="/ontology" element={<ProtectedRoute><Layout><Ontology /></Layout></ProtectedRoute>} />
|
| 169 |
+
<Route path="/interact" element={<ProtectedRoute><Layout><InteractionView /></Layout></ProtectedRoute>} />
|
| 170 |
+
<Route path="/simulate" element={<ProtectedRoute><Layout><SimulationRunView /></Layout></ProtectedRoute>} />
|
| 171 |
+
<Route path="/insights" element={<ProtectedRoute><Layout><InsightsView /></Layout></ProtectedRoute>} />
|
| 172 |
+
<Route path="/admin" element={<ProtectedRoute><Layout><AdminDashboard /></Layout></ProtectedRoute>} />
|
| 173 |
+
</Routes>
|
| 174 |
+
</BrowserRouter>
|
| 175 |
+
</AuthProvider>
|
| 176 |
+
);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
export default App;
|
frontend-react/src/assets/hero.png
ADDED
|
Git LFS Details
|
frontend-react/src/assets/react.svg
ADDED
|
|
frontend-react/src/assets/vite.svg
ADDED
|
|
frontend-react/src/components/GraphCanvas.tsx
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from 'react';
|
| 2 |
+
import * as d3 from 'd3';
|
| 3 |
+
import { X, Tag, FileText, Database } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
// 12-color categorical palette for node types
|
| 6 |
+
const TYPE_COLORS = [
|
| 7 |
+
'#e63946', '#457b9d', '#2a9d8f', '#e9c46a', '#f4a261',
|
| 8 |
+
'#6a4c93', '#1982c4', '#8ac926', '#ff595e', '#6a994e',
|
| 9 |
+
'#bc4749', '#a8dadc'
|
| 10 |
+
];
|
| 11 |
+
|
| 12 |
+
export interface GraphOptions {
|
| 13 |
+
colorByType: boolean;
|
| 14 |
+
showLabels: boolean;
|
| 15 |
+
showEdgeLabels: boolean;
|
| 16 |
+
nodeRadius: number;
|
| 17 |
+
linkDistance: number;
|
| 18 |
+
chargeStrength: number;
|
| 19 |
+
showCurvedEdges: boolean;
|
| 20 |
+
nodeSizeByDegree: boolean;
|
| 21 |
+
centerGravity: number; // 0 = no gravity, 0.1 = default
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export const DEFAULT_OPTIONS: GraphOptions = {
|
| 25 |
+
colorByType: true,
|
| 26 |
+
showLabels: true,
|
| 27 |
+
showEdgeLabels: false,
|
| 28 |
+
nodeRadius: 16,
|
| 29 |
+
linkDistance: 120,
|
| 30 |
+
chargeStrength: -300,
|
| 31 |
+
showCurvedEdges: false,
|
| 32 |
+
nodeSizeByDegree: false,
|
| 33 |
+
centerGravity: 0.05,
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
interface GraphCanvasProps {
|
| 37 |
+
data: { nodes: any[]; edges: any[] };
|
| 38 |
+
onNodeUpdate?: (nodeId: string, newName: string) => void;
|
| 39 |
+
options?: GraphOptions;
|
| 40 |
+
highlightNodeIds?: Set<string>; // nodes to highlight (from search)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export interface GraphCanvasHandle {
|
| 44 |
+
exportPNG: () => void;
|
| 45 |
+
exportSVG: () => void;
|
| 46 |
+
fitView: () => void;
|
| 47 |
+
highlightNode: (id: string) => void;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
| 51 |
+
({ data, onNodeUpdate, options = DEFAULT_OPTIONS, highlightNodeIds }, ref) => {
|
| 52 |
+
const [activeNode, setActiveNode] = useState<any>(null);
|
| 53 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 54 |
+
const svgRef = useRef<SVGSVGElement>(null);
|
| 55 |
+
const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
|
| 56 |
+
const gRef = useRef<d3.Selection<SVGGElement, unknown, null, undefined> | null>(null);
|
| 57 |
+
const simulationRef = useRef<d3.Simulation<any, any> | null>(null);
|
| 58 |
+
const typeColorMap = useRef<Map<string, string>>(new Map());
|
| 59 |
+
|
| 60 |
+
// ── Imperative API ─────────────────────────────────────────────────────
|
| 61 |
+
useImperativeHandle(ref, () => ({
|
| 62 |
+
exportPNG() {
|
| 63 |
+
if (!svgRef.current) return;
|
| 64 |
+
const svgEl = svgRef.current;
|
| 65 |
+
const serializer = new XMLSerializer();
|
| 66 |
+
const svgStr = serializer.serializeToString(svgEl);
|
| 67 |
+
const canvas = document.createElement('canvas');
|
| 68 |
+
canvas.width = svgEl.clientWidth * 2;
|
| 69 |
+
canvas.height = svgEl.clientHeight * 2;
|
| 70 |
+
const ctx = canvas.getContext('2d')!;
|
| 71 |
+
const img = new Image();
|
| 72 |
+
const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
| 73 |
+
const url = URL.createObjectURL(blob);
|
| 74 |
+
img.onload = () => {
|
| 75 |
+
ctx.fillStyle = '#fff';
|
| 76 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 77 |
+
ctx.scale(2, 2);
|
| 78 |
+
ctx.drawImage(img, 0, 0);
|
| 79 |
+
URL.revokeObjectURL(url);
|
| 80 |
+
const a = document.createElement('a');
|
| 81 |
+
a.download = 'graph.png';
|
| 82 |
+
a.href = canvas.toDataURL('image/png');
|
| 83 |
+
a.click();
|
| 84 |
+
};
|
| 85 |
+
img.src = url;
|
| 86 |
+
},
|
| 87 |
+
exportSVG() {
|
| 88 |
+
if (!svgRef.current) return;
|
| 89 |
+
const serializer = new XMLSerializer();
|
| 90 |
+
const svgStr = serializer.serializeToString(svgRef.current);
|
| 91 |
+
const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
| 92 |
+
const url = URL.createObjectURL(blob);
|
| 93 |
+
const a = document.createElement('a');
|
| 94 |
+
a.download = 'graph.svg';
|
| 95 |
+
a.href = url;
|
| 96 |
+
a.click();
|
| 97 |
+
URL.revokeObjectURL(url);
|
| 98 |
+
},
|
| 99 |
+
fitView() {
|
| 100 |
+
if (!svgRef.current || !zoomRef.current) return;
|
| 101 |
+
d3.select(svgRef.current).transition().duration(600).call(
|
| 102 |
+
zoomRef.current.transform, d3.zoomIdentity
|
| 103 |
+
);
|
| 104 |
+
},
|
| 105 |
+
highlightNode(id: string) {
|
| 106 |
+
if (!svgRef.current || !zoomRef.current) return;
|
| 107 |
+
const node = simulationRef.current?.nodes().find((n: any) => n.id === id);
|
| 108 |
+
if (!node || node.x === undefined) return;
|
| 109 |
+
const svg = d3.select(svgRef.current);
|
| 110 |
+
const w = svgRef.current.clientWidth;
|
| 111 |
+
const h = svgRef.current.clientHeight;
|
| 112 |
+
const t = d3.zoomIdentity.translate(w / 2 - node.x, h / 2 - node.y);
|
| 113 |
+
svg.transition().duration(700).call(zoomRef.current.transform, t);
|
| 114 |
+
}
|
| 115 |
+
}));
|
| 116 |
+
|
| 117 |
+
// ── Main D3 render effect ──────────────────────────────────────────────
|
| 118 |
+
useEffect(() => {
|
| 119 |
+
if (!data.nodes.length || !containerRef.current || !svgRef.current) return;
|
| 120 |
+
|
| 121 |
+
const width = containerRef.current.clientWidth;
|
| 122 |
+
const height = containerRef.current.clientHeight;
|
| 123 |
+
|
| 124 |
+
// Build type→color map (stable)
|
| 125 |
+
const types = [...new Set(data.nodes.map(n => n.type || 'Unknown'))];
|
| 126 |
+
types.forEach((t, i) => {
|
| 127 |
+
if (!typeColorMap.current.has(t)) {
|
| 128 |
+
typeColorMap.current.set(t, TYPE_COLORS[i % TYPE_COLORS.length]);
|
| 129 |
+
}
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
const svg = d3.select(svgRef.current);
|
| 133 |
+
svg.selectAll('*').remove();
|
| 134 |
+
|
| 135 |
+
const nodes: any[] = data.nodes.map(d => ({ ...d }));
|
| 136 |
+
const nodeIds = new Set(nodes.map(n => n.id));
|
| 137 |
+
const links: any[] = data.edges
|
| 138 |
+
.filter(d => nodeIds.has(d.source) && nodeIds.has(d.target))
|
| 139 |
+
.map(d => ({ ...d }));
|
| 140 |
+
|
| 141 |
+
// Degree map for node-size-by-degree
|
| 142 |
+
const degreeMap = new Map<string, number>();
|
| 143 |
+
nodes.forEach(n => degreeMap.set(n.id, 0));
|
| 144 |
+
links.forEach(l => {
|
| 145 |
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
| 146 |
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
| 147 |
+
degreeMap.set(sid, (degreeMap.get(sid) || 0) + 1);
|
| 148 |
+
degreeMap.set(tid, (degreeMap.get(tid) || 0) + 1);
|
| 149 |
+
});
|
| 150 |
+
const maxDegree = Math.max(1, ...degreeMap.values());
|
| 151 |
+
|
| 152 |
+
const nodeR = (d: any) => {
|
| 153 |
+
if (!options.nodeSizeByDegree) return options.nodeRadius;
|
| 154 |
+
const deg = degreeMap.get(d.id) || 0;
|
| 155 |
+
return Math.max(8, options.nodeRadius * (0.5 + 1.0 * (deg / maxDegree)));
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
// ── Defs: arrowhead markers ──────────────────────────────────────────
|
| 159 |
+
const defs = svg.append('defs');
|
| 160 |
+
if (options.colorByType) {
|
| 161 |
+
types.forEach(t => {
|
| 162 |
+
const color = typeColorMap.current.get(t) || '#000';
|
| 163 |
+
defs.append('marker')
|
| 164 |
+
.attr('id', `arrow-${t.replace(/\s+/g, '_')}`)
|
| 165 |
+
.attr('viewBox', '-0 -5 10 10').attr('refX', options.nodeRadius + 10)
|
| 166 |
+
.attr('refY', 0).attr('orient', 'auto')
|
| 167 |
+
.attr('markerWidth', 6).attr('markerHeight', 6)
|
| 168 |
+
.append('path').attr('d', 'M 0,-5 L 10,0 L 0,5').attr('fill', color);
|
| 169 |
+
});
|
| 170 |
+
} else {
|
| 171 |
+
defs.append('marker')
|
| 172 |
+
.attr('id', 'arrow-default')
|
| 173 |
+
.attr('viewBox', '-0 -5 10 10').attr('refX', options.nodeRadius + 10)
|
| 174 |
+
.attr('refY', 0).attr('orient', 'auto')
|
| 175 |
+
.attr('markerWidth', 6).attr('markerHeight', 6)
|
| 176 |
+
.append('path').attr('d', 'M 0,-5 L 10,0 L 0,5').attr('fill', '#666');
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// ── Force simulation ──────────────────────────────────────────────────
|
| 180 |
+
const sim = d3.forceSimulation(nodes)
|
| 181 |
+
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(options.linkDistance))
|
| 182 |
+
.force('charge', d3.forceManyBody().strength(options.chargeStrength))
|
| 183 |
+
.force('center', d3.forceCenter(width / 2, height / 2).strength(options.centerGravity))
|
| 184 |
+
.force('collide', d3.forceCollide().radius((d: any) => nodeR(d) + 14));
|
| 185 |
+
|
| 186 |
+
simulationRef.current = sim;
|
| 187 |
+
|
| 188 |
+
const g = svg.append('g').attr('class', 'graph-root');
|
| 189 |
+
gRef.current = g;
|
| 190 |
+
|
| 191 |
+
// ── Tooltip ───────────────────────────────────────────────────────────
|
| 192 |
+
const tooltip = d3.select(containerRef.current)
|
| 193 |
+
.selectAll('.graph-tooltip').data([null]).join('div')
|
| 194 |
+
.attr('class', 'graph-tooltip')
|
| 195 |
+
.style('position', 'absolute')
|
| 196 |
+
.style('pointer-events', 'none')
|
| 197 |
+
.style('background', '#000')
|
| 198 |
+
.style('color', '#fff')
|
| 199 |
+
.style('padding', '6px 12px')
|
| 200 |
+
.style('font-family', '"JetBrains Mono", monospace')
|
| 201 |
+
.style('font-size', '11px')
|
| 202 |
+
.style('line-height', '1.5')
|
| 203 |
+
.style('opacity', 0)
|
| 204 |
+
.style('border', '1px solid #333')
|
| 205 |
+
.style('z-index', '999')
|
| 206 |
+
.style('max-width', '220px')
|
| 207 |
+
.style('word-break', 'break-word');
|
| 208 |
+
|
| 209 |
+
// ── Links ────────────────────────────────────────────────────────────
|
| 210 |
+
const linkG = g.append('g').attr('class', 'links');
|
| 211 |
+
|
| 212 |
+
// Adjacency set for hover highlight
|
| 213 |
+
const adjacentIds = new Set<string>();
|
| 214 |
+
|
| 215 |
+
// Straight lines or curved paths
|
| 216 |
+
const linkEl = options.showCurvedEdges
|
| 217 |
+
? linkG.selectAll('path').data(links).enter().append('path')
|
| 218 |
+
.attr('fill', 'none')
|
| 219 |
+
.attr('stroke', (d: any) => {
|
| 220 |
+
if (!options.colorByType) return '#aaa';
|
| 221 |
+
const srcNode = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
|
| 222 |
+
return srcNode ? (typeColorMap.current.get(srcNode.type) || '#aaa') : '#aaa';
|
| 223 |
+
})
|
| 224 |
+
.attr('stroke-width', 1.5)
|
| 225 |
+
.attr('stroke-opacity', 0.55)
|
| 226 |
+
.attr('marker-end', (d: any) => {
|
| 227 |
+
if (!options.colorByType) return 'url(#arrow-default)';
|
| 228 |
+
const srcNode = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
|
| 229 |
+
const t = srcNode?.type?.replace(/\s+/g, '_') || 'Unknown';
|
| 230 |
+
return `url(#arrow-${t})`;
|
| 231 |
+
})
|
| 232 |
+
: linkG.selectAll('line').data(links).enter().append('line')
|
| 233 |
+
.attr('stroke', (d: any) => {
|
| 234 |
+
if (!options.colorByType) return '#aaa';
|
| 235 |
+
const srcNode = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
|
| 236 |
+
return srcNode ? (typeColorMap.current.get(srcNode.type) || '#aaa') : '#aaa';
|
| 237 |
+
})
|
| 238 |
+
.attr('stroke-width', 1.5)
|
| 239 |
+
.attr('stroke-opacity', 0.55)
|
| 240 |
+
.attr('marker-end', (d: any) => {
|
| 241 |
+
if (!options.colorByType) return 'url(#arrow-default)';
|
| 242 |
+
const srcNode = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
|
| 243 |
+
const t = srcNode?.type?.replace(/\s+/g, '_') || 'Unknown';
|
| 244 |
+
return `url(#arrow-${t})`;
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
// ── Nodes ─────────────────────────────────────────────────────────────
|
| 248 |
+
const node = g.append('g').attr('class', 'nodes')
|
| 249 |
+
.selectAll<SVGGElement, any>('g').data(nodes).enter().append('g')
|
| 250 |
+
.call(d3.drag<SVGGElement, any>()
|
| 251 |
+
.on('start', (event, d) => { if (!event.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
|
| 252 |
+
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
|
| 253 |
+
.on('end', (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
|
| 254 |
+
)
|
| 255 |
+
.on('mouseover', (_, d: any) => {
|
| 256 |
+
// Build adjacent set
|
| 257 |
+
adjacentIds.clear();
|
| 258 |
+
adjacentIds.add(d.id);
|
| 259 |
+
links.forEach(l => {
|
| 260 |
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
| 261 |
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
| 262 |
+
if (sid === d.id) adjacentIds.add(tid);
|
| 263 |
+
if (tid === d.id) adjacentIds.add(sid);
|
| 264 |
+
});
|
| 265 |
+
// Dim non-adjacent
|
| 266 |
+
node.select('circle')
|
| 267 |
+
.style('opacity', (n: any) => adjacentIds.has(n.id) ? 1 : 0.15)
|
| 268 |
+
.style('stroke-width', (n: any) => n.id === d.id ? 4 : 2);
|
| 269 |
+
(linkEl as any)
|
| 270 |
+
.style('stroke-opacity', (l: any) => {
|
| 271 |
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
| 272 |
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
| 273 |
+
return (sid === d.id || tid === d.id) ? 0.9 : 0.04;
|
| 274 |
+
});
|
| 275 |
+
tooltip
|
| 276 |
+
.style('opacity', 1)
|
| 277 |
+
.html(`<strong>${d.label}</strong><br/>ID: ${d.id}<br/>Type: ${d.type || '—'}<br/>Degree: ${degreeMap.get(d.id) || 0}`);
|
| 278 |
+
})
|
| 279 |
+
.on('mousemove', (event) => {
|
| 280 |
+
const rect = containerRef.current!.getBoundingClientRect();
|
| 281 |
+
tooltip
|
| 282 |
+
.style('left', (event.clientX - rect.left + 14) + 'px')
|
| 283 |
+
.style('top', (event.clientY - rect.top - 32) + 'px');
|
| 284 |
+
})
|
| 285 |
+
.on('mouseout', () => {
|
| 286 |
+
adjacentIds.clear();
|
| 287 |
+
node.select('circle')
|
| 288 |
+
.style('opacity', (n: any) => {
|
| 289 |
+
if (!highlightNodeIds || highlightNodeIds.size === 0) return 1;
|
| 290 |
+
return highlightNodeIds.has(n.id) ? 1 : 0.2;
|
| 291 |
+
})
|
| 292 |
+
.style('stroke-width', (n: any) => highlightNodeIds?.has(n.id) ? 4 : 2);
|
| 293 |
+
(linkEl as any).style('stroke-opacity', 0.55);
|
| 294 |
+
tooltip.style('opacity', 0);
|
| 295 |
+
})
|
| 296 |
+
.on('click', (event, d: any) => {
|
| 297 |
+
setActiveNode(d);
|
| 298 |
+
// Zoom to node on single click
|
| 299 |
+
if (!svgRef.current || !zoomRef.current) return;
|
| 300 |
+
const w = svgRef.current.clientWidth;
|
| 301 |
+
const h = svgRef.current.clientHeight;
|
| 302 |
+
const t = d3.zoomIdentity.translate(w / 2 - d.x, h / 2 - d.y).scale(1.4);
|
| 303 |
+
d3.select(svgRef.current).transition().duration(500).call(zoomRef.current.transform, t);
|
| 304 |
+
event.stopPropagation();
|
| 305 |
+
})
|
| 306 |
+
.on('dblclick', (event, d: any) => {
|
| 307 |
+
const newName = window.prompt('Update entity name:', d.label);
|
| 308 |
+
if (newName && newName.trim() && newName.trim() !== d.label) {
|
| 309 |
+
const updated = newName.trim();
|
| 310 |
+
d.label = updated;
|
| 311 |
+
d3.select(event.currentTarget).select('text.node-label').text(
|
| 312 |
+
updated.length > 18 ? updated.substring(0, 16) + '…' : updated
|
| 313 |
+
);
|
| 314 |
+
if (onNodeUpdate) onNodeUpdate(d.id, updated);
|
| 315 |
+
}
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
// Circle
|
| 319 |
+
node.append('circle')
|
| 320 |
+
.attr('r', (d: any) => nodeR(d))
|
| 321 |
+
.attr('fill', (d: any) => options.colorByType
|
| 322 |
+
? (typeColorMap.current.get(d.type) || '#ccc')
|
| 323 |
+
: '#fff')
|
| 324 |
+
.attr('stroke', (d: any) => {
|
| 325 |
+
if (highlightNodeIds && highlightNodeIds.size > 0) {
|
| 326 |
+
return highlightNodeIds.has(d.id) ? '#ff0' : (options.colorByType
|
| 327 |
+
? d3.color(typeColorMap.current.get(d.type) || '#ccc')!.darker(1).toString()
|
| 328 |
+
: '#000');
|
| 329 |
+
}
|
| 330 |
+
return options.colorByType
|
| 331 |
+
? d3.color(typeColorMap.current.get(d.type) || '#ccc')!.darker(1).toString()
|
| 332 |
+
: '#000';
|
| 333 |
+
})
|
| 334 |
+
.attr('stroke-width', (d: any) => highlightNodeIds?.has(d.id) ? 4 : 2)
|
| 335 |
+
.style('opacity', (d: any) => {
|
| 336 |
+
if (!highlightNodeIds || highlightNodeIds.size === 0) return 1;
|
| 337 |
+
return highlightNodeIds.has(d.id) ? 1 : 0.2;
|
| 338 |
+
})
|
| 339 |
+
.style('filter', 'drop-shadow(1px 2px 3px rgba(0,0,0,0.15))')
|
| 340 |
+
.style('cursor', 'pointer');
|
| 341 |
+
|
| 342 |
+
// Type abbreviation inside circle
|
| 343 |
+
node.append('text')
|
| 344 |
+
.attr('class', 'node-type-badge')
|
| 345 |
+
.text((d: any) => (d.type || '?').substring(0, 2).toUpperCase())
|
| 346 |
+
.attr('text-anchor', 'middle').attr('dy', '0.35em')
|
| 347 |
+
.style('font-family', '"JetBrains Mono", monospace')
|
| 348 |
+
.style('font-size', (d: any) => `${Math.max(8, nodeR(d) - 6)}px`)
|
| 349 |
+
.style('font-weight', '700')
|
| 350 |
+
.style('fill', (d: any) => {
|
| 351 |
+
if (!options.colorByType) return '#000';
|
| 352 |
+
const c = d3.color(typeColorMap.current.get(d.type) || '#ccc');
|
| 353 |
+
if (!c) return '#000';
|
| 354 |
+
const { r, g: gv, b } = c.rgb();
|
| 355 |
+
return (r * 0.299 + gv * 0.587 + b * 0.114) > 150 ? '#111' : '#fff';
|
| 356 |
+
})
|
| 357 |
+
.style('pointer-events', 'none');
|
| 358 |
+
|
| 359 |
+
// Node name label below circle
|
| 360 |
+
if (options.showLabels) {
|
| 361 |
+
node.append('text')
|
| 362 |
+
.attr('class', 'node-label')
|
| 363 |
+
.text((d: any) => d.label && d.label.length > 18 ? d.label.substring(0, 16) + '…' : d.label)
|
| 364 |
+
.attr('text-anchor', 'middle')
|
| 365 |
+
.attr('dy', (d: any) => nodeR(d) + 14)
|
| 366 |
+
.style('font-family', '"JetBrains Mono", monospace')
|
| 367 |
+
.style('font-size', '10px')
|
| 368 |
+
.style('font-weight', '600')
|
| 369 |
+
.style('fill', '#222')
|
| 370 |
+
.style('pointer-events', 'none');
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// Edge labels
|
| 374 |
+
let edgeLabel: d3.Selection<SVGTextElement, any, SVGGElement, unknown> | null = null;
|
| 375 |
+
if (options.showEdgeLabels) {
|
| 376 |
+
edgeLabel = g.append('g').attr('class', 'edge-labels')
|
| 377 |
+
.selectAll('text').data(links).enter().append('text')
|
| 378 |
+
.text((d: any) => d.type || '')
|
| 379 |
+
.style('font-family', '"JetBrains Mono", monospace')
|
| 380 |
+
.style('font-size', '9px')
|
| 381 |
+
.style('fill', '#777')
|
| 382 |
+
.style('text-anchor', 'middle')
|
| 383 |
+
.style('pointer-events', 'none')
|
| 384 |
+
.attr('dy', -5);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
// ── Tick ──────────────────────────────────────────────────────────────
|
| 388 |
+
sim.on('tick', () => {
|
| 389 |
+
if (options.showCurvedEdges) {
|
| 390 |
+
(linkEl as d3.Selection<SVGPathElement, any, SVGGElement, unknown>)
|
| 391 |
+
.attr('d', (d: any) => {
|
| 392 |
+
const sx = d.source.x, sy = d.source.y;
|
| 393 |
+
const tx = d.target.x, ty = d.target.y;
|
| 394 |
+
const dx = tx - sx, dy = ty - sy;
|
| 395 |
+
const dr = Math.sqrt(dx * dx + dy * dy) * 0.8;
|
| 396 |
+
return `M${sx},${sy}A${dr},${dr} 0 0,1 ${tx},${ty}`;
|
| 397 |
+
});
|
| 398 |
+
} else {
|
| 399 |
+
(linkEl as d3.Selection<SVGLineElement, any, SVGGElement, unknown>)
|
| 400 |
+
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
|
| 401 |
+
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y);
|
| 402 |
+
}
|
| 403 |
+
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`);
|
| 404 |
+
if (edgeLabel) {
|
| 405 |
+
edgeLabel
|
| 406 |
+
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
|
| 407 |
+
.attr('y', (d: any) => (d.source.y + d.target.y) / 2);
|
| 408 |
+
}
|
| 409 |
+
});
|
| 410 |
+
|
| 411 |
+
// ── Zoom / Pan ────────────────────────────────────────────────────────
|
| 412 |
+
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
| 413 |
+
.scaleExtent([0.03, 8])
|
| 414 |
+
.on('zoom', (event) => g.attr('transform', event.transform));
|
| 415 |
+
|
| 416 |
+
svg.call(zoom);
|
| 417 |
+
// Click on SVG background resets highlighting and active node
|
| 418 |
+
svg.on('click', () => {
|
| 419 |
+
setActiveNode(null);
|
| 420 |
+
node.select('circle').style('opacity', 1).style('stroke-width', 2);
|
| 421 |
+
(linkEl as any).style('stroke-opacity', 0.55);
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
zoomRef.current = zoom;
|
| 425 |
+
|
| 426 |
+
return () => { sim.stop(); };
|
| 427 |
+
}, [data, options, highlightNodeIds]);
|
| 428 |
+
|
| 429 |
+
return (
|
| 430 |
+
<div ref={containerRef} style={{ width: '100%', height: '100%', position: 'relative', overflow: 'hidden' }}>
|
| 431 |
+
<svg ref={svgRef} width="100%" height="100%" />
|
| 432 |
+
|
| 433 |
+
{/* ── Node details modal ────────────────────────────────────────────── */}
|
| 434 |
+
{activeNode && (
|
| 435 |
+
<div className="gc-node-modal">
|
| 436 |
+
<div className="gc-node-modal-header">
|
| 437 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
| 438 |
+
<h3 className="mono-text" style={{ margin: 0, fontSize: '0.85rem' }}>NODE DETAILS</h3>
|
| 439 |
+
<span className="gc-node-badge" style={{ background: typeColorMap.current.get(activeNode.type) || '#000' }}>
|
| 440 |
+
{activeNode.type || 'Unknown'}
|
| 441 |
+
</span>
|
| 442 |
+
</div>
|
| 443 |
+
<button className="gc-node-close" onClick={() => setActiveNode(null)}>
|
| 444 |
+
<X size={16} />
|
| 445 |
+
</button>
|
| 446 |
+
</div>
|
| 447 |
+
|
| 448 |
+
<div className="gc-node-modal-body">
|
| 449 |
+
<div className="gc-node-row">
|
| 450 |
+
<span className="gc-node-key"><Tag size={12}/> Name:</span>
|
| 451 |
+
<span className="gc-node-val" style={{ fontWeight: 600 }}>{activeNode.label || '—'}</span>
|
| 452 |
+
</div>
|
| 453 |
+
<div className="gc-node-row">
|
| 454 |
+
<span className="gc-node-key"><Database size={12}/> UUID:</span>
|
| 455 |
+
<span className="gc-node-val" style={{ wordBreak: 'break-all', fontSize: '0.7em' }}>{activeNode.id}</span>
|
| 456 |
+
</div>
|
| 457 |
+
|
| 458 |
+
{activeNode.properties && Object.keys(activeNode.properties).length > 0 && (
|
| 459 |
+
<>
|
| 460 |
+
<div className="gc-node-divider" />
|
| 461 |
+
<div className="gc-node-section-title">PROPERTIES</div>
|
| 462 |
+
<div className="gc-node-props">
|
| 463 |
+
{Object.entries(activeNode.properties).map(([k, v]) => (
|
| 464 |
+
<div className="gc-node-prop-item" key={k}>
|
| 465 |
+
<span className="gc-node-prop-k">{k}:</span>
|
| 466 |
+
<span className="gc-node-prop-v">{String(v)}</span>
|
| 467 |
+
</div>
|
| 468 |
+
))}
|
| 469 |
+
</div>
|
| 470 |
+
</>
|
| 471 |
+
)}
|
| 472 |
+
|
| 473 |
+
{activeNode.description && (
|
| 474 |
+
<>
|
| 475 |
+
<div className="gc-node-divider" />
|
| 476 |
+
<div className="gc-node-section-title"><FileText size={12}/> DESCRIPTION / SUMMARY</div>
|
| 477 |
+
<div className="gc-node-summary">
|
| 478 |
+
{activeNode.description}
|
| 479 |
+
</div>
|
| 480 |
+
</>
|
| 481 |
+
)}
|
| 482 |
+
</div>
|
| 483 |
+
</div>
|
| 484 |
+
)}
|
| 485 |
+
|
| 486 |
+
<style>{`
|
| 487 |
+
.gc-node-modal {
|
| 488 |
+
position: absolute;
|
| 489 |
+
top: 20px;
|
| 490 |
+
right: 20px;
|
| 491 |
+
width: 320px;
|
| 492 |
+
max-height: calc(100% - 40px);
|
| 493 |
+
background: #fff;
|
| 494 |
+
border: 3px solid #000;
|
| 495 |
+
box-shadow: 6px 6px 0 rgba(0,0,0,0.1);
|
| 496 |
+
display: flex;
|
| 497 |
+
flex-direction: column;
|
| 498 |
+
z-index: 1000;
|
| 499 |
+
animation: slideInR 0.15s ease-out;
|
| 500 |
+
}
|
| 501 |
+
@keyframes slideInR { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
| 502 |
+
|
| 503 |
+
.gc-node-modal-header {
|
| 504 |
+
padding: 0.75rem 1rem;
|
| 505 |
+
border-bottom: 3px solid #000;
|
| 506 |
+
background: #fafafa;
|
| 507 |
+
display: flex;
|
| 508 |
+
align-items: center;
|
| 509 |
+
justify-content: space-between;
|
| 510 |
+
}
|
| 511 |
+
.gc-node-close {
|
| 512 |
+
background: none; border: none; cursor: pointer; padding: 2px; display: flex; align-items: center; opacity: 0.5; transition: 0.12s;
|
| 513 |
+
}
|
| 514 |
+
.gc-node-close:hover { opacity: 1; color: #ef4444; }
|
| 515 |
+
|
| 516 |
+
.gc-node-badge {
|
| 517 |
+
color: #fff;
|
| 518 |
+
font-size: 0.6rem;
|
| 519 |
+
font-family: var(--font-mono);
|
| 520 |
+
font-weight: 700;
|
| 521 |
+
padding: 2px 6px;
|
| 522 |
+
border-radius: 20px;
|
| 523 |
+
text-transform: uppercase;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.gc-node-modal-body {
|
| 527 |
+
padding: 1rem;
|
| 528 |
+
overflow-y: auto;
|
| 529 |
+
flex: 1;
|
| 530 |
+
display: flex;
|
| 531 |
+
flex-direction: column;
|
| 532 |
+
gap: 0.6rem;
|
| 533 |
+
background: #fff;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.gc-node-row {
|
| 537 |
+
display: flex;
|
| 538 |
+
align-items: flex-start;
|
| 539 |
+
gap: 0.75rem;
|
| 540 |
+
font-family: var(--font-mono);
|
| 541 |
+
font-size: 0.8rem;
|
| 542 |
+
}
|
| 543 |
+
.gc-node-key { width: 60px; flex-shrink: 0; color: #777; display: flex; align-items: center; gap: 4px; }
|
| 544 |
+
.gc-node-val { flex: 1; color: #111; }
|
| 545 |
+
|
| 546 |
+
.gc-node-divider { height: 1px; border-bottom: 1px dashed #ccc; margin: 0.4rem 0; }
|
| 547 |
+
.gc-node-section-title { font-family: var(--font-mono); font-size: 0.7rem; font-weight: 700; color: #444; letter-spacing: 1px; margin-bottom: 0.2rem; display: flex; align-items: center; gap: 6px; }
|
| 548 |
+
|
| 549 |
+
.gc-node-props { display: flex; flex-direction: column; gap: 4px; background: #f8f8f8; border: 1px solid #ddd; padding: 0.5rem; }
|
| 550 |
+
.gc-node-prop-item { font-family: var(--font-mono); font-size: 0.7rem; display: flex; gap: 6px; align-items: flex-start; }
|
| 551 |
+
.gc-node-prop-k { color: #555; }
|
| 552 |
+
.gc-node-prop-v { color: #111; word-break: break-all; }
|
| 553 |
+
|
| 554 |
+
.gc-node-summary {
|
| 555 |
+
font-family: var(--font-mono);
|
| 556 |
+
font-size: 0.75rem;
|
| 557 |
+
line-height: 1.5;
|
| 558 |
+
color: #333;
|
| 559 |
+
background: #fff9c4;
|
| 560 |
+
border-left: 3px solid #fbc02d;
|
| 561 |
+
padding: 0.5rem 0.75rem;
|
| 562 |
+
}
|
| 563 |
+
`}</style>
|
| 564 |
+
</div>
|
| 565 |
+
);
|
| 566 |
+
}
|
| 567 |
+
);
|
| 568 |
+
|
| 569 |
+
GraphCanvas.displayName = 'GraphCanvas';
|
| 570 |
+
export default GraphCanvas;
|
frontend-react/src/context/AuthContext.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useContext, useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
// This acts as our authentication context for the React application
|
| 4 |
+
|
| 5 |
+
interface User {
|
| 6 |
+
username: string;
|
| 7 |
+
email?: string;
|
| 8 |
+
full_name?: string;
|
| 9 |
+
scopes: string[];
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface AuthContextType {
|
| 13 |
+
token: string | null;
|
| 14 |
+
user: User | null;
|
| 15 |
+
login: (token: string, user: User) => void;
|
| 16 |
+
logout: () => void;
|
| 17 |
+
isAuthenticated: boolean;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
| 21 |
+
|
| 22 |
+
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
| 23 |
+
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
| 24 |
+
const [user, setUser] = useState<User | null>(null);
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
const storedUser = localStorage.getItem('user');
|
| 28 |
+
if (storedUser) {
|
| 29 |
+
try {
|
| 30 |
+
setUser(JSON.parse(storedUser));
|
| 31 |
+
} catch (e) {
|
| 32 |
+
console.error('Failed to parse stored user', e);
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
const login = (newToken: string, newUser: User) => {
|
| 38 |
+
localStorage.setItem('token', newToken);
|
| 39 |
+
localStorage.setItem('user', JSON.stringify(newUser));
|
| 40 |
+
setToken(newToken);
|
| 41 |
+
setUser(newUser);
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const logout = () => {
|
| 45 |
+
localStorage.removeItem('token');
|
| 46 |
+
localStorage.removeItem('user');
|
| 47 |
+
setToken(null);
|
| 48 |
+
setUser(null);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<AuthContext.Provider value={{ token, user, login, logout, isAuthenticated: !!token }}>
|
| 53 |
+
{children}
|
| 54 |
+
</AuthContext.Provider>
|
| 55 |
+
);
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
export const useAuth = () => {
|
| 59 |
+
const context = useContext(AuthContext);
|
| 60 |
+
if (context === undefined) {
|
| 61 |
+
throw new Error('useAuth must be used within an AuthProvider');
|
| 62 |
+
}
|
| 63 |
+
return context;
|
| 64 |
+
};
|
frontend-react/src/index.css
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
| 2 |
+
@import "tailwindcss";
|
| 3 |
+
|
| 4 |
+
/* ── Design Tokens ─────────────────────────────────────────────────────── */
|
| 5 |
+
:root {
|
| 6 |
+
--bg-color: #ffffff;
|
| 7 |
+
--text-color: #000000;
|
| 8 |
+
--border-color: #000000;
|
| 9 |
+
--surface-color: #f5f5f5; /* subtle off-white for secondary surfaces */
|
| 10 |
+
--hover-bg: #f0f0f0;
|
| 11 |
+
--muted-color: #666666;
|
| 12 |
+
--success-color: #16a34a;
|
| 13 |
+
--error-color: #dc2626;
|
| 14 |
+
--warning-color: #d97706;
|
| 15 |
+
|
| 16 |
+
--font-sans: 'Inter', 'Noto Sans SC', sans-serif;
|
| 17 |
+
--font-display: 'Space Grotesk', 'Inter', sans-serif;
|
| 18 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 19 |
+
|
| 20 |
+
--transition-speed: 0.18s;
|
| 21 |
+
--nav-height: 60px;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/* ── Reset ─────────────────────────────────────────────────────────────── */
|
| 25 |
+
*, *::before, *::after {
|
| 26 |
+
box-sizing: border-box;
|
| 27 |
+
margin: 0;
|
| 28 |
+
padding: 0;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
body {
|
| 32 |
+
font-family: var(--font-sans);
|
| 33 |
+
background-color: var(--bg-color);
|
| 34 |
+
color: var(--text-color);
|
| 35 |
+
line-height: 1.6;
|
| 36 |
+
-webkit-font-smoothing: antialiased;
|
| 37 |
+
-moz-osx-font-smoothing: grayscale;
|
| 38 |
+
min-height: 100vh;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
h1, h2, h3, h4, h5, h6 {
|
| 42 |
+
font-family: var(--font-display);
|
| 43 |
+
font-weight: 600;
|
| 44 |
+
margin-bottom: 0.75rem;
|
| 45 |
+
line-height: 1.2;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
code, pre, .technical { font-family: var(--font-mono); }
|
| 49 |
+
|
| 50 |
+
/* ── Links ─────────────────────────────────────────────────────────────── */
|
| 51 |
+
a {
|
| 52 |
+
color: var(--text-color);
|
| 53 |
+
text-decoration: underline;
|
| 54 |
+
text-underline-offset: 3px;
|
| 55 |
+
transition: background var(--transition-speed), color var(--transition-speed);
|
| 56 |
+
}
|
| 57 |
+
a:hover {
|
| 58 |
+
background-color: var(--text-color);
|
| 59 |
+
color: var(--bg-color);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* ── Buttons ───────────────────────────────────────────────────────────── */
|
| 63 |
+
button, .app-btn {
|
| 64 |
+
font-family: var(--font-display);
|
| 65 |
+
background-color: var(--bg-color);
|
| 66 |
+
color: var(--text-color);
|
| 67 |
+
border: 2px solid var(--border-color);
|
| 68 |
+
padding: 0.5rem 1rem;
|
| 69 |
+
font-weight: 600;
|
| 70 |
+
cursor: pointer;
|
| 71 |
+
transition: background var(--transition-speed) ease, color var(--transition-speed) ease;
|
| 72 |
+
font-size: 0.9rem;
|
| 73 |
+
text-transform: uppercase;
|
| 74 |
+
letter-spacing: 0.8px;
|
| 75 |
+
display: inline-flex;
|
| 76 |
+
align-items: center;
|
| 77 |
+
gap: 0.4rem;
|
| 78 |
+
}
|
| 79 |
+
button:hover, .app-btn:hover {
|
| 80 |
+
background-color: var(--text-color);
|
| 81 |
+
color: var(--bg-color);
|
| 82 |
+
}
|
| 83 |
+
button:disabled, .app-btn:disabled {
|
| 84 |
+
opacity: 0.45;
|
| 85 |
+
cursor: not-allowed;
|
| 86 |
+
pointer-events: none;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* Text-only button (no border, no background) */
|
| 90 |
+
.text-btn {
|
| 91 |
+
background: none !important;
|
| 92 |
+
border: none !important;
|
| 93 |
+
color: var(--text-color);
|
| 94 |
+
text-decoration: underline;
|
| 95 |
+
text-underline-offset: 3px;
|
| 96 |
+
cursor: pointer;
|
| 97 |
+
font-family: var(--font-mono);
|
| 98 |
+
font-size: 0.8rem;
|
| 99 |
+
font-weight: 600;
|
| 100 |
+
transition: background var(--transition-speed), color var(--transition-speed);
|
| 101 |
+
padding: 0.2rem 0.4rem;
|
| 102 |
+
}
|
| 103 |
+
.text-btn:hover {
|
| 104 |
+
background: var(--text-color) !important;
|
| 105 |
+
color: var(--bg-color) !important;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* ── Toast dismiss button ──────────────────────────────────────────────── */
|
| 109 |
+
/* Prevents global button:hover from flipping the close button inside toasts */
|
| 110 |
+
.toast-dismiss-btn {
|
| 111 |
+
background: transparent !important;
|
| 112 |
+
border: none !important;
|
| 113 |
+
color: inherit !important;
|
| 114 |
+
cursor: pointer !important;
|
| 115 |
+
margin-left: auto !important;
|
| 116 |
+
padding: 0 0.3rem !important;
|
| 117 |
+
font-size: 1.2rem !important;
|
| 118 |
+
line-height: 1 !important;
|
| 119 |
+
opacity: 0.7;
|
| 120 |
+
flex-shrink: 0;
|
| 121 |
+
transition: opacity 0.12s ease !important;
|
| 122 |
+
}
|
| 123 |
+
.toast-dismiss-btn:hover {
|
| 124 |
+
background: transparent !important;
|
| 125 |
+
color: inherit !important;
|
| 126 |
+
opacity: 1 !important;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* ── Status toast ──────────────────────────────────────────────────────── */
|
| 130 |
+
.status-toast {
|
| 131 |
+
position: fixed;
|
| 132 |
+
bottom: 2rem;
|
| 133 |
+
right: 2rem;
|
| 134 |
+
background: var(--text-color);
|
| 135 |
+
color: var(--bg-color);
|
| 136 |
+
padding: 0.9rem 1.2rem;
|
| 137 |
+
border-left: 5px solid #000;
|
| 138 |
+
box-shadow: 4px 4px 0 rgba(0,0,0,0.3);
|
| 139 |
+
display: flex;
|
| 140 |
+
align-items: center;
|
| 141 |
+
gap: 0.75rem;
|
| 142 |
+
z-index: 9999;
|
| 143 |
+
font-family: var(--font-mono);
|
| 144 |
+
font-weight: 700;
|
| 145 |
+
font-size: 0.85rem;
|
| 146 |
+
max-width: 420px;
|
| 147 |
+
animation: slideUpToast 0.28s ease-out both;
|
| 148 |
+
will-change: transform;
|
| 149 |
+
}
|
| 150 |
+
.status-toast.error { border-left-color: var(--error-color); }
|
| 151 |
+
.status-toast.success { border-left-color: var(--success-color); }
|
| 152 |
+
|
| 153 |
+
/* ── Form Controls ─────────────────────────────────────────────────────── */
|
| 154 |
+
input, textarea, select {
|
| 155 |
+
font-family: var(--font-sans);
|
| 156 |
+
background-color: var(--bg-color);
|
| 157 |
+
color: var(--text-color);
|
| 158 |
+
border: 2px solid var(--border-color);
|
| 159 |
+
padding: 0.5rem 0.75rem;
|
| 160 |
+
font-size: 0.95rem;
|
| 161 |
+
transition: box-shadow var(--transition-speed) ease;
|
| 162 |
+
}
|
| 163 |
+
input:focus, textarea:focus, select:focus {
|
| 164 |
+
outline: none;
|
| 165 |
+
box-shadow: 3px 3px 0 var(--border-color);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/* ── Scrollbars ────────────────────────────────────────────────────────── */
|
| 169 |
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
| 170 |
+
::-webkit-scrollbar-track { background: #f9f9f9; border-left: 1px solid #e5e5e5; }
|
| 171 |
+
::-webkit-scrollbar-thumb { background: #ccc; }
|
| 172 |
+
::-webkit-scrollbar-thumb:hover { background: #999; }
|
| 173 |
+
|
| 174 |
+
/* ── Layout Utilities ──────────────────────────────────────────────────── */
|
| 175 |
+
.container {
|
| 176 |
+
max-width: 1280px;
|
| 177 |
+
margin: 0 auto;
|
| 178 |
+
padding: 2rem;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.card {
|
| 182 |
+
border: 2px solid var(--border-color);
|
| 183 |
+
padding: 1.5rem;
|
| 184 |
+
background-color: var(--bg-color);
|
| 185 |
+
transition: transform var(--transition-speed) ease, box-shadow var(--transition-speed) ease;
|
| 186 |
+
}
|
| 187 |
+
.card:hover {
|
| 188 |
+
transform: translateY(-2px);
|
| 189 |
+
box-shadow: 5px 5px 0 var(--border-color);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.mono-text { font-family: var(--font-mono); font-size: 0.88em; }
|
| 193 |
+
|
| 194 |
+
.flex-center {
|
| 195 |
+
display: flex;
|
| 196 |
+
align-items: center;
|
| 197 |
+
justify-content: center;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.flex-between {
|
| 201 |
+
display: flex;
|
| 202 |
+
align-items: center;
|
| 203 |
+
justify-content: space-between;
|
| 204 |
+
flex-wrap: wrap;
|
| 205 |
+
gap: 1rem;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.full-width { width: 100%; }
|
| 209 |
+
|
| 210 |
+
.page-header {
|
| 211 |
+
border-bottom: 3px solid var(--border-color);
|
| 212 |
+
padding-bottom: 1rem;
|
| 213 |
+
margin-bottom: 2rem;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/* ── Keyframes ─────────────────────────────────────────────────────────── */
|
| 217 |
+
@keyframes fadeIn {
|
| 218 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 219 |
+
to { opacity: 1; transform: translateY(0); }
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
@keyframes slideUpToast {
|
| 223 |
+
from { transform: translateY(80px); opacity: 0; }
|
| 224 |
+
to { transform: translateY(0); opacity: 1; }
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
@keyframes slideUp {
|
| 228 |
+
from { transform: translateY(20px); opacity: 0; }
|
| 229 |
+
to { transform: translateY(0); opacity: 1; }
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
@keyframes spin {
|
| 233 |
+
100% { transform: rotate(360deg); }
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.fade-in { animation: fadeIn 0.4s ease both; }
|
| 237 |
+
|
| 238 |
+
/* ── Status indicators ─────────────────────────────────────────────────── */
|
| 239 |
+
.status-dot {
|
| 240 |
+
display: inline-block;
|
| 241 |
+
width: 8px;
|
| 242 |
+
height: 8px;
|
| 243 |
+
border-radius: 50%;
|
| 244 |
+
flex-shrink: 0;
|
| 245 |
+
}
|
| 246 |
+
.status-dot.online { background: var(--success-color); }
|
| 247 |
+
.status-dot.offline { background: var(--error-color); }
|
| 248 |
+
|
| 249 |
+
/* ── Responsive ────────────────────────────────────────────────────────── */
|
| 250 |
+
@media (max-width: 768px) {
|
| 251 |
+
.container { padding: 1rem; }
|
| 252 |
+
.card { padding: 1rem; }
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/* ── Admin / shared aliases ─────────────────────────────────────────────── */
|
| 256 |
+
/* These classes are referenced in AdminDashboard and other views. */
|
| 257 |
+
/* They map onto the existing design system so everything is uniform. */
|
| 258 |
+
|
| 259 |
+
/* Base btn — same as global button but without uppercase transform */
|
| 260 |
+
.btn {
|
| 261 |
+
font-family: var(--font-display);
|
| 262 |
+
background-color: var(--bg-color);
|
| 263 |
+
color: var(--text-color);
|
| 264 |
+
border: 2px solid var(--border-color);
|
| 265 |
+
padding: 0.5rem 1rem;
|
| 266 |
+
font-weight: 600;
|
| 267 |
+
cursor: pointer;
|
| 268 |
+
font-size: 0.85rem;
|
| 269 |
+
letter-spacing: 0.5px;
|
| 270 |
+
display: inline-flex;
|
| 271 |
+
align-items: center;
|
| 272 |
+
gap: 0.4rem;
|
| 273 |
+
transition: background var(--transition-speed) ease, color var(--transition-speed) ease;
|
| 274 |
+
}
|
| 275 |
+
.btn:hover:not(:disabled) {
|
| 276 |
+
background-color: var(--text-color);
|
| 277 |
+
color: var(--bg-color);
|
| 278 |
+
}
|
| 279 |
+
.btn:disabled {
|
| 280 |
+
opacity: 0.45;
|
| 281 |
+
cursor: not-allowed;
|
| 282 |
+
pointer-events: none;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
/* Primary button — filled black */
|
| 286 |
+
.btn-primary {
|
| 287 |
+
background-color: var(--text-color);
|
| 288 |
+
color: var(--bg-color);
|
| 289 |
+
border: 2px solid var(--text-color);
|
| 290 |
+
}
|
| 291 |
+
.btn-primary:hover:not(:disabled) {
|
| 292 |
+
background-color: #333;
|
| 293 |
+
border-color: #333;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/* Outline / ghost button */
|
| 297 |
+
.btn-outline {
|
| 298 |
+
background: transparent;
|
| 299 |
+
color: var(--text-color);
|
| 300 |
+
border: 2px solid var(--border-color);
|
| 301 |
+
}
|
| 302 |
+
.btn-outline:hover:not(:disabled) {
|
| 303 |
+
background: var(--text-color);
|
| 304 |
+
color: var(--bg-color);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/* Danger button */
|
| 308 |
+
.btn-danger {
|
| 309 |
+
color: var(--error-color);
|
| 310 |
+
border-color: var(--error-color);
|
| 311 |
+
}
|
| 312 |
+
.btn-danger:hover:not(:disabled) {
|
| 313 |
+
background: var(--error-color);
|
| 314 |
+
color: #fff;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
/* Uniform search / select input (matches global input but explicit class) */
|
| 318 |
+
.search-input {
|
| 319 |
+
font-family: var(--font-sans);
|
| 320 |
+
background-color: var(--bg-color);
|
| 321 |
+
color: var(--text-color);
|
| 322 |
+
border: 2px solid var(--border-color);
|
| 323 |
+
padding: 0.5rem 0.75rem;
|
| 324 |
+
font-size: 0.9rem;
|
| 325 |
+
transition: box-shadow var(--transition-speed) ease;
|
| 326 |
+
width: 100%;
|
| 327 |
+
}
|
| 328 |
+
.search-input:focus {
|
| 329 |
+
outline: none;
|
| 330 |
+
box-shadow: 3px 3px 0 var(--border-color);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* Status / metric cards */
|
| 334 |
+
.status-card {
|
| 335 |
+
border: 2px solid var(--border-color);
|
| 336 |
+
padding: 1.25rem 1.5rem;
|
| 337 |
+
background: var(--bg-color);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.metric-value {
|
| 341 |
+
font-family: var(--font-mono);
|
| 342 |
+
font-size: 2rem;
|
| 343 |
+
font-weight: 900;
|
| 344 |
+
line-height: 1;
|
| 345 |
+
margin-top: 0.25rem;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.status-label {
|
| 349 |
+
font-family: var(--font-mono);
|
| 350 |
+
font-size: 0.68rem;
|
| 351 |
+
font-weight: 700;
|
| 352 |
+
color: var(--muted-color);
|
| 353 |
+
letter-spacing: 1px;
|
| 354 |
+
text-transform: uppercase;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/* Online / offline indicator dots */
|
| 358 |
+
.indicator {
|
| 359 |
+
width: 10px;
|
| 360 |
+
height: 10px;
|
| 361 |
+
border-radius: 50%;
|
| 362 |
+
flex-shrink: 0;
|
| 363 |
+
}
|
| 364 |
+
.indicator.online { background: var(--success-color); }
|
| 365 |
+
.indicator.offline { background: var(--error-color); }
|
| 366 |
+
.indicator.pending { background: var(--warning-color); }
|
| 367 |
+
|
| 368 |
+
/* Typography */
|
| 369 |
+
.title-lg {
|
| 370 |
+
font-family: var(--font-display);
|
| 371 |
+
font-size: 1.8rem;
|
| 372 |
+
font-weight: 700;
|
| 373 |
+
line-height: 1.1;
|
| 374 |
+
}
|
| 375 |
+
.title-md {
|
| 376 |
+
font-family: var(--font-display);
|
| 377 |
+
font-size: 1.15rem;
|
| 378 |
+
font-weight: 700;
|
| 379 |
+
}
|
| 380 |
+
.title-sm {
|
| 381 |
+
font-family: var(--font-mono);
|
| 382 |
+
font-size: 0.72rem;
|
| 383 |
+
font-weight: 700;
|
| 384 |
+
letter-spacing: 1px;
|
| 385 |
+
text-transform: uppercase;
|
| 386 |
+
color: var(--muted-color);
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
/* Data table */
|
| 390 |
+
.data-table {
|
| 391 |
+
width: 100%;
|
| 392 |
+
border-collapse: collapse;
|
| 393 |
+
font-size: 0.9rem;
|
| 394 |
+
}
|
| 395 |
+
.data-table thead tr {
|
| 396 |
+
background: var(--surface-color);
|
| 397 |
+
border-bottom: 2px solid var(--border-color);
|
| 398 |
+
}
|
| 399 |
+
.data-table th,
|
| 400 |
+
.data-table td {
|
| 401 |
+
padding: 0.75rem 1rem;
|
| 402 |
+
text-align: left;
|
| 403 |
+
border-bottom: 1px solid #eaeaea;
|
| 404 |
+
}
|
| 405 |
+
.data-table th {
|
| 406 |
+
font-family: var(--font-mono);
|
| 407 |
+
font-size: 0.72rem;
|
| 408 |
+
font-weight: 700;
|
| 409 |
+
letter-spacing: 0.8px;
|
| 410 |
+
text-transform: uppercase;
|
| 411 |
+
color: var(--muted-color);
|
| 412 |
+
}
|
| 413 |
+
.data-table tbody tr:hover {
|
| 414 |
+
background: var(--surface-color);
|
| 415 |
+
}
|
| 416 |
+
.data-table tbody tr:last-child td {
|
| 417 |
+
border-bottom: none;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
/* Tag / badge chips */
|
| 421 |
+
.chip {
|
| 422 |
+
display: inline-flex;
|
| 423 |
+
align-items: center;
|
| 424 |
+
gap: 4px;
|
| 425 |
+
border: 1.5px solid var(--border-color);
|
| 426 |
+
padding: 2px 8px;
|
| 427 |
+
font-family: var(--font-mono);
|
| 428 |
+
font-size: 0.72rem;
|
| 429 |
+
font-weight: 700;
|
| 430 |
+
}
|
| 431 |
+
.chip.success { border-color: var(--success-color); color: var(--success-color); }
|
| 432 |
+
.chip.error { border-color: var(--error-color); color: var(--error-color); }
|
| 433 |
+
.chip.warning { border-color: var(--warning-color); color: var(--warning-color); }
|
| 434 |
+
.chip.filled { background: var(--text-color); color: var(--bg-color); }
|
| 435 |
+
|
| 436 |
+
/* Empty state */
|
| 437 |
+
.empty-state {
|
| 438 |
+
display: flex;
|
| 439 |
+
flex-direction: column;
|
| 440 |
+
align-items: center;
|
| 441 |
+
justify-content: center;
|
| 442 |
+
padding: 4rem 2rem;
|
| 443 |
+
color: var(--muted-color);
|
| 444 |
+
gap: 0.75rem;
|
| 445 |
+
border: 2px dashed var(--border-color);
|
| 446 |
+
font-family: var(--font-mono);
|
| 447 |
+
font-size: 0.85rem;
|
| 448 |
+
letter-spacing: 0.5px;
|
| 449 |
+
text-align: center;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
/* Help tooltip */
|
| 453 |
+
.help-tooltip {
|
| 454 |
+
position: relative;
|
| 455 |
+
display: inline-flex;
|
| 456 |
+
align-items: center;
|
| 457 |
+
}
|
| 458 |
+
.help-tooltip [data-tip] {
|
| 459 |
+
cursor: help;
|
| 460 |
+
color: var(--muted-color);
|
| 461 |
+
}
|
| 462 |
+
.help-tooltip [data-tip]:hover::after {
|
| 463 |
+
content: attr(data-tip);
|
| 464 |
+
position: absolute;
|
| 465 |
+
bottom: 125%;
|
| 466 |
+
left: 50%;
|
| 467 |
+
transform: translateX(-50%);
|
| 468 |
+
background: var(--text-color);
|
| 469 |
+
color: var(--bg-color);
|
| 470 |
+
padding: 0.4rem 0.75rem;
|
| 471 |
+
font-family: var(--font-mono);
|
| 472 |
+
font-size: 0.75rem;
|
| 473 |
+
white-space: nowrap;
|
| 474 |
+
z-index: 9999;
|
| 475 |
+
pointer-events: none;
|
| 476 |
+
max-width: 260px;
|
| 477 |
+
white-space: normal;
|
| 478 |
+
text-align: center;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
/* Page info bar */
|
| 482 |
+
.page-info-bar {
|
| 483 |
+
background: var(--surface-color);
|
| 484 |
+
border-left: 4px solid var(--border-color);
|
| 485 |
+
padding: 0.75rem 1rem;
|
| 486 |
+
font-size: 0.82rem;
|
| 487 |
+
line-height: 1.6;
|
| 488 |
+
margin-bottom: 1.5rem;
|
| 489 |
+
display: flex;
|
| 490 |
+
align-items: flex-start;
|
| 491 |
+
gap: 0.75rem;
|
| 492 |
+
color: var(--muted-color);
|
| 493 |
+
}
|
| 494 |
+
.page-info-bar strong { color: var(--text-color); font-family: var(--font-mono); }
|
frontend-react/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.tsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend-react/src/types/api.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface LoginRequest {
|
| 2 |
+
username: string;
|
| 3 |
+
password: string;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
export interface RegisterRequest {
|
| 7 |
+
username: string;
|
| 8 |
+
password: string;
|
| 9 |
+
email?: string;
|
| 10 |
+
full_name?: string;
|
| 11 |
+
scopes?: string[];
|
| 12 |
+
tenant_id?: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export interface TokenResponse {
|
| 16 |
+
access_token: string;
|
| 17 |
+
token_type: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export interface DocumentUploadResponse {
|
| 21 |
+
document_id: string;
|
| 22 |
+
filename: string;
|
| 23 |
+
size_bytes: number;
|
| 24 |
+
task_id?: string;
|
| 25 |
+
message: string;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export interface ScrapeRequest {
|
| 29 |
+
url: string;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export interface CrawlRequest {
|
| 33 |
+
url: string;
|
| 34 |
+
max_depth?: number;
|
| 35 |
+
max_pages?: number;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export interface IngestionStatusResponse {
|
| 39 |
+
task_id: string;
|
| 40 |
+
status: string;
|
| 41 |
+
progress?: Record<string, any>;
|
| 42 |
+
result?: Record<string, any>;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export interface DocumentInfo {
|
| 46 |
+
id: string;
|
| 47 |
+
filename: string;
|
| 48 |
+
file_type: string;
|
| 49 |
+
size_bytes: number;
|
| 50 |
+
upload_date: string;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export interface DocumentListResponse {
|
| 54 |
+
documents: DocumentInfo[];
|
| 55 |
+
total: number;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export interface QueryRequest {
|
| 59 |
+
query: string;
|
| 60 |
+
top_k?: number;
|
| 61 |
+
streaming?: boolean;
|
| 62 |
+
document_id?: string;
|
| 63 |
+
conversation_id?: string;
|
| 64 |
+
use_got?: boolean;
|
| 65 |
+
at_time?: string;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export interface ConfidenceJudgmentResponse {
|
| 69 |
+
score: number;
|
| 70 |
+
reasoning: string;
|
| 71 |
+
grounded_claims: number;
|
| 72 |
+
ungrounded_claims: number;
|
| 73 |
+
hallucination_risk: 'low' | 'medium' | 'high';
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export interface QueryResponse {
|
| 77 |
+
answer: string;
|
| 78 |
+
sources: Array<Record<string, any>>;
|
| 79 |
+
reasoning_chain: string[];
|
| 80 |
+
confidence: number;
|
| 81 |
+
confidence_judgment?: ConfidenceJudgmentResponse;
|
| 82 |
+
retrieval_method: string;
|
| 83 |
+
processing_time_seconds: number;
|
| 84 |
+
conversation_id?: string;
|
| 85 |
+
drift_expanded?: boolean;
|
| 86 |
+
total_sub_queries?: number;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export interface EvalResultData {
|
| 90 |
+
overall_score?: number;
|
| 91 |
+
faithfulness: number;
|
| 92 |
+
answer_relevancy?: number;
|
| 93 |
+
relevancy?: number;
|
| 94 |
+
context_precision?: number;
|
| 95 |
+
precision?: number;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export interface Message {
|
| 99 |
+
id: string;
|
| 100 |
+
role: string;
|
| 101 |
+
content: string;
|
| 102 |
+
reasoning?: string[];
|
| 103 |
+
sources?: Array<Record<string, any>>;
|
| 104 |
+
confidence?: number;
|
| 105 |
+
hallucination_risk?: string;
|
| 106 |
+
confidence_reasoning?: string;
|
| 107 |
+
created_at: string;
|
| 108 |
+
eval_result?: EvalResultData;
|
| 109 |
+
evaluating?: boolean;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export interface Conversation {
|
| 113 |
+
id: string;
|
| 114 |
+
title: string;
|
| 115 |
+
created_at: string;
|
| 116 |
+
updated_at: string;
|
| 117 |
+
messages?: Message[];
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
export interface ConversationListResponse {
|
| 121 |
+
conversations: Conversation[];
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
export interface OntologyResponse {
|
| 125 |
+
version: string;
|
| 126 |
+
entity_types: string[];
|
| 127 |
+
relationship_types: string[];
|
| 128 |
+
properties: Record<string, string[]>;
|
| 129 |
+
created_at: string;
|
| 130 |
+
approved: boolean;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export interface OntologyUpdateRequest {
|
| 134 |
+
entity_types?: string[];
|
| 135 |
+
relationship_types?: string[];
|
| 136 |
+
properties?: Record<string, string[]>;
|
| 137 |
+
approved?: boolean;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
export interface GraphNode {
|
| 141 |
+
id: string;
|
| 142 |
+
label: string;
|
| 143 |
+
type: string;
|
| 144 |
+
description?: string;
|
| 145 |
+
properties: Record<string, any>;
|
| 146 |
+
community_id?: number;
|
| 147 |
+
valid_from?: string;
|
| 148 |
+
valid_until?: string;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
export interface GraphEdge {
|
| 152 |
+
source: string;
|
| 153 |
+
target: string;
|
| 154 |
+
type: string;
|
| 155 |
+
properties: Record<string, any>;
|
| 156 |
+
valid_from?: string;
|
| 157 |
+
confidence?: number;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
export interface GraphVisualizationResponse {
|
| 161 |
+
nodes: GraphNode[];
|
| 162 |
+
edges: GraphEdge[];
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
export interface SystemHealthResponse {
|
| 166 |
+
status: string;
|
| 167 |
+
version: string;
|
| 168 |
+
neo4j_connected: boolean;
|
| 169 |
+
redis_connected: boolean;
|
| 170 |
+
workers_active: number;
|
| 171 |
+
timestamp: string;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export interface SystemStatsResponse {
|
| 175 |
+
documents_count: number;
|
| 176 |
+
entities_count: number;
|
| 177 |
+
relationships_count: number;
|
| 178 |
+
chunks_count: number;
|
| 179 |
+
ontology_version: string;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
export interface DriftReport { id: string; detected_at: string; new_entity_types: string[]; new_relationship_types: string[]; removed_entity_types: string[]; removed_relationship_types: string[]; sample_size: number; drift_score: number; status: string; approved_by?: string; approved_at?: string; }
|
frontend-react/src/views/AdminDashboard.tsx
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import {
|
| 4 |
+
BarChart2, Cpu, Users, Database, Settings,
|
| 5 |
+
Trash2, Check, X, Play, RefreshCw, Shield,
|
| 6 |
+
AlertTriangle, Zap, GitBranch, Info
|
| 7 |
+
} from 'lucide-react';
|
| 8 |
+
|
| 9 |
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
|
| 10 |
+
|
| 11 |
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
| 12 |
+
const Spinner = () => (
|
| 13 |
+
<span className="inline-block w-[14px] h-[14px] border-2 border-[#ccc] border-t-black rounded-full animate-spin"/>
|
| 14 |
+
);
|
| 15 |
+
|
| 16 |
+
// ─── Tab Components ──────────────────────────────────────────────────────────
|
| 17 |
+
|
| 18 |
+
const OverviewTab = ({ stats, health, onRefresh }: { stats: Partial<import('../types/api').SystemStatsResponse & {graph: any, total_nodes: number, total_relationships: number, documents: any, total_documents: number, costs: any}>; health: import('../types/api').SystemHealthResponse | any; onRefresh: () => void }) => (
|
| 19 |
+
<div>
|
| 20 |
+
{/* KPI Grid */}
|
| 21 |
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4 mb-8">
|
| 22 |
+
{[
|
| 23 |
+
{ label:'Graph Nodes', value: stats?.graph?.nodes ?? stats?.total_nodes ?? '—', icon:<Database size={16}/> },
|
| 24 |
+
{ label:'Relationships', value: stats?.graph?.relationships ?? stats?.total_relationships ?? '—', icon:<GitBranch size={16}/> },
|
| 25 |
+
{ label:'Documents', value: stats?.documents?.total ?? stats?.total_documents ?? '—', icon:<Database size={16}/> },
|
| 26 |
+
{ label:'Est. LLM Cost', value: `$${(stats?.costs?.total_estimated_usd ?? 0).toFixed(4)}`, icon:<BarChart2 size={16}/> },
|
| 27 |
+
].map(c => (
|
| 28 |
+
<div key={c.label} className="status-card">
|
| 29 |
+
<div className="flex justify-between items-center mb-2">
|
| 30 |
+
<div className="status-label">{c.label}</div>
|
| 31 |
+
{c.icon}
|
| 32 |
+
</div>
|
| 33 |
+
<div className="metric-value">{c.value}</div>
|
| 34 |
+
</div>
|
| 35 |
+
))}
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
{/* System health */}
|
| 39 |
+
<div className="card mb-6">
|
| 40 |
+
<div className="flex justify-between items-center mb-4">
|
| 41 |
+
<h2 className="title-md">System Health</h2>
|
| 42 |
+
<button className="btn btn-outline py-1 px-3 text-xs" onClick={onRefresh}>
|
| 43 |
+
<RefreshCw size={13}/> Refresh
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
{health ? (
|
| 47 |
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
| 48 |
+
{Object.entries(health).map(([k, v]: [string, any]) => {
|
| 49 |
+
const isOk = v === true || v === 'ok' || v === 'connected' || v === 'healthy';
|
| 50 |
+
const isErr = v === false || v === 'error' || v === 'disconnected';
|
| 51 |
+
return (
|
| 52 |
+
<div key={k} className="border-[1.5px] border-[#e5e5e5] py-2.5 px-3.5 flex items-center gap-2">
|
| 53 |
+
<span className={`indicator ${isOk ? 'online' : isErr ? 'offline' : 'pending'}`}/>
|
| 54 |
+
<div>
|
| 55 |
+
<div className="status-label">{k.toUpperCase()}</div>
|
| 56 |
+
<div className="font-mono text-[0.8rem] font-bold">
|
| 57 |
+
{typeof v === 'object' ? JSON.stringify(v) : String(v)}
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
);
|
| 62 |
+
})}
|
| 63 |
+
</div>
|
| 64 |
+
) : (
|
| 65 |
+
<div className="text-center p-6 text-[var(--muted-color)] font-mono text-[0.85rem]">
|
| 66 |
+
Loading health data…
|
| 67 |
+
</div>
|
| 68 |
+
)}
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{/* Provider info */}
|
| 72 |
+
{stats?.system && (
|
| 73 |
+
<div className="card">
|
| 74 |
+
<h2 className="title-md mb-4">LLM Provider</h2>
|
| 75 |
+
<div className="flex gap-4 flex-wrap">
|
| 76 |
+
{Object.entries(stats.system).map(([k, v]: any) => (
|
| 77 |
+
<div key={k} className="chip">{k}: <strong>{String(v)}</strong></div>
|
| 78 |
+
))}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
)}
|
| 82 |
+
</div>
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
const UsersTab = ({ token }: { token: string | null }) => {
|
| 86 |
+
const [users, setUsers] = useState<any[]>([]);
|
| 87 |
+
const [loading, setLoading] = useState(true);
|
| 88 |
+
const [msg, setMsg] = useState('');
|
| 89 |
+
|
| 90 |
+
const fetchUsers = useCallback(async () => {
|
| 91 |
+
setLoading(true);
|
| 92 |
+
try {
|
| 93 |
+
const res = await fetch(`${API_BASE}/admin/users`, { headers: { Authorization: `Bearer ${token}` } });
|
| 94 |
+
if (res.ok) {
|
| 95 |
+
const json = await res.json();
|
| 96 |
+
setUsers(json.users || []);
|
| 97 |
+
}
|
| 98 |
+
} finally { setLoading(false); }
|
| 99 |
+
}, [token]);
|
| 100 |
+
|
| 101 |
+
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
| 102 |
+
|
| 103 |
+
const updateRole = async (username: string, scopes: string) => {
|
| 104 |
+
const res = await fetch(`${API_BASE}/admin/users/${username}/role`, {
|
| 105 |
+
method: 'PUT',
|
| 106 |
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
| 107 |
+
body: JSON.stringify({ scopes: [scopes] })
|
| 108 |
+
});
|
| 109 |
+
if (res.ok) {
|
| 110 |
+
setUsers(u => u.map(usr => usr.username === username ? { ...usr, scopes: [scopes] } : usr));
|
| 111 |
+
setMsg(`Role updated for ${username}`);
|
| 112 |
+
setTimeout(() => setMsg(''), 3000);
|
| 113 |
+
} else {
|
| 114 |
+
setMsg(`Failed to update role for ${username}`);
|
| 115 |
+
setTimeout(() => setMsg(''), 3000);
|
| 116 |
+
}
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
return (
|
| 120 |
+
<div className="card">
|
| 121 |
+
<div className="flex justify-between items-center mb-4">
|
| 122 |
+
<h2 className="title-md">Registered Users</h2>
|
| 123 |
+
<button className="btn btn-outline py-1 px-3 text-xs" onClick={fetchUsers}>
|
| 124 |
+
<RefreshCw size={13}/> Refresh
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
{msg && <div className="bg-[#dcfce7] text-[#166534] py-2 px-3 mb-4 font-mono text-[0.8rem]">{msg}</div>}
|
| 128 |
+
{loading ? (
|
| 129 |
+
<div className="text-center p-8"><Spinner/></div>
|
| 130 |
+
) : (
|
| 131 |
+
<div className="overflow-x-auto">
|
| 132 |
+
<table className="data-table">
|
| 133 |
+
<thead><tr><th>Username</th><th>Scope</th><th>Change Role</th></tr></thead>
|
| 134 |
+
<tbody>
|
| 135 |
+
{users.map(u => {
|
| 136 |
+
const isAdminUser = u.username === 'admin';
|
| 137 |
+
const currentScope = u.scopes?.includes('admin') ? 'admin' : (u.scopes?.includes('write') ? 'write' : 'read');
|
| 138 |
+
return (
|
| 139 |
+
<tr key={u.username}>
|
| 140 |
+
<td className="font-mono font-semibold">
|
| 141 |
+
{u.username}
|
| 142 |
+
{isAdminUser && <span className="chip filled ml-1.5 text-[0.62rem]">PROTECTED</span>}
|
| 143 |
+
</td>
|
| 144 |
+
<td><span className="chip">{u.scopes?.join(', ') || 'none'}</span></td>
|
| 145 |
+
<td>
|
| 146 |
+
{isAdminUser ? (
|
| 147 |
+
<span className="font-mono text-xs text-[var(--muted-color)]">—</span>
|
| 148 |
+
) : (
|
| 149 |
+
<select
|
| 150 |
+
className="search-input w-auto py-1 px-2 text-[0.82rem]"
|
| 151 |
+
value={currentScope}
|
| 152 |
+
onChange={e => updateRole(u.username, e.target.value)}
|
| 153 |
+
>
|
| 154 |
+
<option value="read">Read Only</option>
|
| 155 |
+
<option value="write">Read / Write</option>
|
| 156 |
+
<option value="admin">Admin</option>
|
| 157 |
+
</select>
|
| 158 |
+
)}
|
| 159 |
+
</td>
|
| 160 |
+
</tr>
|
| 161 |
+
);
|
| 162 |
+
})}
|
| 163 |
+
{users.length === 0 && (
|
| 164 |
+
<tr><td colSpan={3} className="text-center p-8 text-[var(--muted-color)]">No users found.</td></tr>
|
| 165 |
+
)}
|
| 166 |
+
</tbody>
|
| 167 |
+
</table>
|
| 168 |
+
</div>
|
| 169 |
+
)}
|
| 170 |
+
</div>
|
| 171 |
+
);
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
const DocumentsTab = ({ token }: { token: string | null }) => {
|
| 175 |
+
const [docs, setDocs] = useState<any[]>([]);
|
| 176 |
+
const [loading, setLoading] = useState(true);
|
| 177 |
+
const [msg, setMsg] = useState('');
|
| 178 |
+
|
| 179 |
+
const fetchDocs = useCallback(async () => {
|
| 180 |
+
setLoading(true);
|
| 181 |
+
try {
|
| 182 |
+
const res = await fetch(`${API_BASE}/admin/documents`, { headers: { Authorization: `Bearer ${token}` } });
|
| 183 |
+
if (res.ok) {
|
| 184 |
+
const json = await res.json();
|
| 185 |
+
setDocs(json.documents || []);
|
| 186 |
+
}
|
| 187 |
+
} finally { setLoading(false); }
|
| 188 |
+
}, [token]);
|
| 189 |
+
|
| 190 |
+
useEffect(() => { fetchDocs(); }, [fetchDocs]);
|
| 191 |
+
|
| 192 |
+
const deleteDoc = async (id: string, filename: string) => {
|
| 193 |
+
if (!window.confirm(`Delete "${filename}" and all its graph data? This cannot be undone.`)) return;
|
| 194 |
+
const res = await fetch(`${API_BASE}/admin/documents/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
|
| 195 |
+
if (res.ok) {
|
| 196 |
+
setDocs(d => d.filter(doc => doc.id !== id));
|
| 197 |
+
setMsg('Document deleted.');
|
| 198 |
+
setTimeout(() => setMsg(''), 3000);
|
| 199 |
+
}
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
const reIngestDoc = async (id: string, filename: string) => {
|
| 203 |
+
setMsg(`Re-ingesting "${filename}"...`);
|
| 204 |
+
try {
|
| 205 |
+
const res = await fetch(`${API_BASE}/admin/documents/${id}/reingest`, {
|
| 206 |
+
method: 'POST',
|
| 207 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 208 |
+
});
|
| 209 |
+
if (res.ok) {
|
| 210 |
+
const data = await res.json();
|
| 211 |
+
setMsg(`Re-ingestion queued for "${filename}". Task: ${data.task_id?.slice(0,8)}…`);
|
| 212 |
+
setTimeout(() => { setMsg(''); fetchDocs(); }, 5000);
|
| 213 |
+
} else {
|
| 214 |
+
setMsg(`Failed to re-ingest "${filename}"`);
|
| 215 |
+
setTimeout(() => setMsg(''), 3000);
|
| 216 |
+
}
|
| 217 |
+
} catch {
|
| 218 |
+
setMsg('Network error during re-ingest.');
|
| 219 |
+
setTimeout(() => setMsg(''), 3000);
|
| 220 |
+
}
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
return (
|
| 224 |
+
<div className="card">
|
| 225 |
+
<div className="flex justify-between items-center mb-4">
|
| 226 |
+
<h2 className="title-md">Document Vault</h2>
|
| 227 |
+
<span className="font-mono text-xs text-[var(--muted-color)]">{docs.length} documents</span>
|
| 228 |
+
</div>
|
| 229 |
+
{msg && <div className={`py-2 px-3 mb-4 font-mono text-[0.8rem] ${(msg.includes('Failed') || msg.includes('error')) ? 'bg-[#fef2f2] text-[#dc2626]' : 'bg-[#dcfce7] text-[#166534]'}`}>{msg}</div>}
|
| 230 |
+
{loading ? (
|
| 231 |
+
<div className="text-center p-8"><Spinner/></div>
|
| 232 |
+
) : (
|
| 233 |
+
<div className="overflow-x-auto">
|
| 234 |
+
<table className="data-table">
|
| 235 |
+
<thead><tr><th>ID</th><th>Filename</th><th>Status</th><th>Actions</th></tr></thead>
|
| 236 |
+
<tbody>
|
| 237 |
+
{docs.map(d => (
|
| 238 |
+
<tr key={d.id}>
|
| 239 |
+
<td className="font-mono text-[0.78rem] text-[var(--muted-color)]">{d.id?.substring(0,12)}…</td>
|
| 240 |
+
<td className="font-mono font-semibold">{d.filename}</td>
|
| 241 |
+
<td>
|
| 242 |
+
<span className={`chip ${d.status === 'completed' ? 'success' : d.status === 'failed' ? 'error' : 'warning'}`}>
|
| 243 |
+
{d.status || 'unknown'}
|
| 244 |
+
</span>
|
| 245 |
+
</td>
|
| 246 |
+
<td className="flex gap-1.5 flex-wrap">
|
| 247 |
+
{(d.status === 'failed' || d.status === 'pending') && (
|
| 248 |
+
<button className="btn text-[0.72rem] py-1 px-2 border-[#2563eb] text-[#2563eb] bg-[#eff6ff]"
|
| 249 |
+
onClick={() => reIngestDoc(d.id, d.filename)}>
|
| 250 |
+
<Play size={11}/> Re-Ingest
|
| 251 |
+
</button>
|
| 252 |
+
)}
|
| 253 |
+
<button className="btn btn-danger text-xs py-1 px-2.5" onClick={() => deleteDoc(d.id, d.filename)}>
|
| 254 |
+
<Trash2 size={12}/> Delete
|
| 255 |
+
</button>
|
| 256 |
+
</td>
|
| 257 |
+
</tr>
|
| 258 |
+
))}
|
| 259 |
+
{docs.length === 0 && (
|
| 260 |
+
<tr><td colSpan={4} className="text-center p-8 text-[var(--muted-color)]">No documents uploaded yet.</td></tr>
|
| 261 |
+
)}
|
| 262 |
+
</tbody>
|
| 263 |
+
</table>
|
| 264 |
+
</div>
|
| 265 |
+
)}
|
| 266 |
+
</div>
|
| 267 |
+
);
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
+
const GraphCRUDTab = ({ token }: { token: string | null }) => {
|
| 271 |
+
const [nodes, setNodes] = useState<any[]>([]);
|
| 272 |
+
const [query, setQuery] = useState('');
|
| 273 |
+
const [loading, setLoading] = useState(false);
|
| 274 |
+
const [msg, setMsg] = useState('');
|
| 275 |
+
|
| 276 |
+
const search = async (e?: React.FormEvent) => {
|
| 277 |
+
e?.preventDefault();
|
| 278 |
+
setLoading(true);
|
| 279 |
+
try {
|
| 280 |
+
const res = await fetch(`${API_BASE}/admin/graph/nodes?query=${encodeURIComponent(query)}&limit=100`, {
|
| 281 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 282 |
+
});
|
| 283 |
+
if (res.ok) setNodes((await res.json()).nodes || []);
|
| 284 |
+
} finally { setLoading(false); }
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
const deleteNode = async (id: string) => {
|
| 288 |
+
if (!window.confirm('Detach and delete this node?')) return;
|
| 289 |
+
const res = await fetch(`${API_BASE}/admin/graph/nodes/${id}`, {
|
| 290 |
+
method: 'DELETE', headers: { Authorization: `Bearer ${token}` }
|
| 291 |
+
});
|
| 292 |
+
if (res.ok) {
|
| 293 |
+
setNodes(n => n.filter(nd => nd.id !== id));
|
| 294 |
+
setMsg('Node deleted.');
|
| 295 |
+
setTimeout(() => setMsg(''), 3000);
|
| 296 |
+
}
|
| 297 |
+
};
|
| 298 |
+
|
| 299 |
+
return (
|
| 300 |
+
<div className="card">
|
| 301 |
+
<h2 className="title-md mb-4">Graph Node Browser</h2>
|
| 302 |
+
<div className="page-info-bar">
|
| 303 |
+
<Info size={14}/>
|
| 304 |
+
<span>Search and inspect nodes directly in Neo4j. Use label names or property values. <strong>DELETE</strong> detaches all relationships before removing the node.</span>
|
| 305 |
+
</div>
|
| 306 |
+
{msg && <div className="bg-[#dcfce7] text-[#166534] py-2 px-3 mb-4 font-mono text-[0.8rem]">{msg}</div>}
|
| 307 |
+
<form onSubmit={search} className="flex gap-3 mb-6">
|
| 308 |
+
<input type="text" value={query} onChange={e => setQuery(e.target.value)}
|
| 309 |
+
placeholder="Search node labels or properties…" className="search-input flex-1"/>
|
| 310 |
+
<button type="submit" className="btn btn-primary" disabled={loading}>
|
| 311 |
+
{loading ? <Spinner/> : null} Search
|
| 312 |
+
</button>
|
| 313 |
+
</form>
|
| 314 |
+
<div className="overflow-x-auto max-h-[420px] overflow-y-auto">
|
| 315 |
+
<table className="data-table">
|
| 316 |
+
<thead><tr><th>ID</th><th>Labels</th><th>Properties</th><th>Action</th></tr></thead>
|
| 317 |
+
<tbody>
|
| 318 |
+
{nodes.map((n, i) => (
|
| 319 |
+
<tr key={i}>
|
| 320 |
+
<td className="font-mono text-[0.78rem] text-[var(--muted-color)]">{n.id}</td>
|
| 321 |
+
<td className="font-mono text-[#2563eb]">{n.labels?.join(', ')}</td>
|
| 322 |
+
<td className="font-mono text-[0.78rem] text-[var(--muted-color)] max-w-[260px] whitespace-nowrap overflow-hidden text-ellipsis">
|
| 323 |
+
{JSON.stringify(n.properties)}
|
| 324 |
+
</td>
|
| 325 |
+
<td>
|
| 326 |
+
<button className="btn btn-danger text-xs py-1 px-2.5" onClick={() => deleteNode(n.id)}>
|
| 327 |
+
<Trash2 size={12}/> Delete
|
| 328 |
+
</button>
|
| 329 |
+
</td>
|
| 330 |
+
</tr>
|
| 331 |
+
))}
|
| 332 |
+
{nodes.length === 0 && (
|
| 333 |
+
<tr><td colSpan={4} className="text-center p-8 text-[var(--muted-color)]">
|
| 334 |
+
{loading ? 'Searching…' : 'Enter a search term above to browse nodes.'}
|
| 335 |
+
</td></tr>
|
| 336 |
+
)}
|
| 337 |
+
</tbody>
|
| 338 |
+
</table>
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
);
|
| 342 |
+
};
|
| 343 |
+
|
| 344 |
+
const OntologyGovernanceTab = ({ token }: { token: string | null }) => {
|
| 345 |
+
const [proposals, setProposals] = useState<any[]>([]);
|
| 346 |
+
const [driftReports, setDriftReports] = useState<any[]>([]);
|
| 347 |
+
const [loading, setLoading] = useState(true);
|
| 348 |
+
const [detectLoading, setDetectLoading] = useState(false);
|
| 349 |
+
const [msg, setMsg] = useState('');
|
| 350 |
+
|
| 351 |
+
const fetchData = useCallback(async () => {
|
| 352 |
+
setLoading(true);
|
| 353 |
+
try {
|
| 354 |
+
const [propRes, driftRes] = await Promise.all([
|
| 355 |
+
fetch(`${API_BASE}/admin/ontology/pending`, { headers: { Authorization: `Bearer ${token}` } }),
|
| 356 |
+
fetch(`${API_BASE}/ontology/drift`, { headers: { Authorization: `Bearer ${token}` } }),
|
| 357 |
+
]);
|
| 358 |
+
if (propRes.ok) setProposals((await propRes.json()).proposals || []);
|
| 359 |
+
if (driftRes.ok) setDriftReports((await driftRes.json()).reports || []);
|
| 360 |
+
} finally { setLoading(false); }
|
| 361 |
+
}, [token]);
|
| 362 |
+
|
| 363 |
+
useEffect(() => { fetchData(); }, [fetchData]);
|
| 364 |
+
|
| 365 |
+
const handleProposal = async (id: string, action: 'approve' | 'reject') => {
|
| 366 |
+
const res = await fetch(`${API_BASE}/admin/ontology/${action}/${id}`, {
|
| 367 |
+
method: 'POST', headers: { Authorization: `Bearer ${token}` }
|
| 368 |
+
});
|
| 369 |
+
if (res.ok) {
|
| 370 |
+
setProposals(p => p.filter(o => o.id !== id));
|
| 371 |
+
setMsg(`Proposal ${action}d.`);
|
| 372 |
+
setTimeout(() => setMsg(''), 3000);
|
| 373 |
+
}
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
const handleDrift = async (id: string, action: 'approve' | 'reject') => {
|
| 377 |
+
const res = await fetch(`${API_BASE}/ontology/drift/${id}/${action}`, {
|
| 378 |
+
method: 'POST', headers: { Authorization: `Bearer ${token}` }
|
| 379 |
+
});
|
| 380 |
+
if (res.ok) {
|
| 381 |
+
setDriftReports(d => d.filter(r => r.id !== id));
|
| 382 |
+
setMsg(`Drift report ${action}d.`);
|
| 383 |
+
setTimeout(() => setMsg(''), 3000);
|
| 384 |
+
}
|
| 385 |
+
};
|
| 386 |
+
|
| 387 |
+
const detectDrift = async () => {
|
| 388 |
+
setDetectLoading(true);
|
| 389 |
+
try {
|
| 390 |
+
const res = await fetch(`${API_BASE}/ontology/drift/detect`, {
|
| 391 |
+
method: 'POST', headers: { Authorization: `Bearer ${token}` }
|
| 392 |
+
});
|
| 393 |
+
if (res.ok) {
|
| 394 |
+
setMsg('Drift detection complete. Refreshing…');
|
| 395 |
+
await fetchData();
|
| 396 |
+
}
|
| 397 |
+
} finally {
|
| 398 |
+
setDetectLoading(false);
|
| 399 |
+
setTimeout(() => setMsg(''), 4000);
|
| 400 |
+
}
|
| 401 |
+
};
|
| 402 |
+
|
| 403 |
+
return (
|
| 404 |
+
<div>
|
| 405 |
+
{msg && <div className="bg-[#dcfce7] text-[#166534] py-2.5 px-3.5 mb-4 font-mono text-[0.8rem] border border-[#bbf7d0]">{msg}</div>}
|
| 406 |
+
|
| 407 |
+
{/* Drift detection */}
|
| 408 |
+
<div className="card mb-6">
|
| 409 |
+
<div className="flex justify-between items-center mb-3">
|
| 410 |
+
<h2 className="title-md">Ontology Drift Reports</h2>
|
| 411 |
+
<button className="btn btn-primary text-[0.8rem]" onClick={detectDrift} disabled={detectLoading}>
|
| 412 |
+
{detectLoading ? <Spinner/> : <Zap size={13}/>} Run Drift Detection
|
| 413 |
+
</button>
|
| 414 |
+
</div>
|
| 415 |
+
<p className="text-[var(--muted-color)] text-[0.85rem] mb-5 font-sans">
|
| 416 |
+
Drift detection samples recent data and suggests additions or changes to the graph schema.
|
| 417 |
+
Review and approve or reject proposals below.
|
| 418 |
+
</p>
|
| 419 |
+
{loading ? <div className="text-center p-6"><Spinner/></div> : (
|
| 420 |
+
<div className="grid gap-3">
|
| 421 |
+
{driftReports.map(r => (
|
| 422 |
+
<div key={r.id} className="border-2 border-black p-4 flex justify-between items-start gap-4">
|
| 423 |
+
<div className="flex-1">
|
| 424 |
+
<div className="flex gap-2 mb-1.5 flex-wrap">
|
| 425 |
+
<span className="chip">{r.status || 'pending'}</span>
|
| 426 |
+
<span className="chip">{r.new_entity_types?.length || 0} new types</span>
|
| 427 |
+
</div>
|
| 428 |
+
<p className="font-mono text-[0.78rem] text-[#555] m-0">
|
| 429 |
+
{r.summary || 'Drift report — review suggested schema changes.'}
|
| 430 |
+
</p>
|
| 431 |
+
</div>
|
| 432 |
+
<div className="flex gap-2 shrink-0">
|
| 433 |
+
<button className="btn bg-[#f0fdf4] text-[#16a34a] border-[#16a34a] py-1 px-3 text-[0.78rem]"
|
| 434 |
+
onClick={() => handleDrift(r.id, 'approve')}><Check size={13}/> Apply</button>
|
| 435 |
+
<button className="btn bg-[#fef2f2] text-[#dc2626] border-[#dc2626] py-1 px-3 text-[0.78rem]"
|
| 436 |
+
onClick={() => handleDrift(r.id, 'reject')}><X size={13}/> Reject</button>
|
| 437 |
+
</div>
|
| 438 |
+
</div>
|
| 439 |
+
))}
|
| 440 |
+
{driftReports.length === 0 && (
|
| 441 |
+
<div className="empty-state p-8">No pending drift reports. Run drift detection above.</div>
|
| 442 |
+
)}
|
| 443 |
+
</div>
|
| 444 |
+
)}
|
| 445 |
+
</div>
|
| 446 |
+
|
| 447 |
+
{/* Manual proposals */}
|
| 448 |
+
<div className="card">
|
| 449 |
+
<h2 className="title-md mb-3">Manual Schema Proposals</h2>
|
| 450 |
+
<div className="grid gap-3">
|
| 451 |
+
{proposals.map(o => (
|
| 452 |
+
<div key={o.id} className="border-[1.5px] border-[#e5e5e5] p-3.5 flex justify-between items-center gap-4">
|
| 453 |
+
<div className="flex items-center gap-3">
|
| 454 |
+
<span className="chip">{o.type}</span>
|
| 455 |
+
<span className="font-mono text-[0.85rem]">{o.name}</span>
|
| 456 |
+
</div>
|
| 457 |
+
<div className="flex gap-2">
|
| 458 |
+
<button className="btn bg-[#f0fdf4] text-[#16a34a] border-[#16a34a] py-1 px-3 text-[0.78rem]"
|
| 459 |
+
onClick={() => handleProposal(o.id, 'approve')}><Check size={13}/> Approve</button>
|
| 460 |
+
<button className="btn bg-[#fef2f2] text-[#dc2626] border-[#dc2626] py-1 px-3 text-[0.78rem]"
|
| 461 |
+
onClick={() => handleProposal(o.id, 'reject')}><X size={13}/> Reject</button>
|
| 462 |
+
</div>
|
| 463 |
+
</div>
|
| 464 |
+
))}
|
| 465 |
+
{proposals.length === 0 && (
|
| 466 |
+
<div className="empty-state p-6">No pending manual proposals.</div>
|
| 467 |
+
)}
|
| 468 |
+
</div>
|
| 469 |
+
</div>
|
| 470 |
+
</div>
|
| 471 |
+
);
|
| 472 |
+
};
|
| 473 |
+
|
| 474 |
+
const WorkersTab = ({ token }: { token: string | null }) => {
|
| 475 |
+
const [tasks, setTasks] = useState<any>(null);
|
| 476 |
+
const [health, setHealth] = useState<any>(null);
|
| 477 |
+
const [loading, setLoading] = useState(true);
|
| 478 |
+
|
| 479 |
+
const fetchAll = useCallback(async () => {
|
| 480 |
+
setLoading(true);
|
| 481 |
+
try {
|
| 482 |
+
const [taskRes, healthRes] = await Promise.all([
|
| 483 |
+
fetch(`${API_BASE}/admin/tasks`, { headers: { Authorization: `Bearer ${token}` } }),
|
| 484 |
+
fetch(`${API_BASE}/system/health`, { headers: { Authorization: `Bearer ${token}` } }),
|
| 485 |
+
]);
|
| 486 |
+
if (taskRes.ok) setTasks(await taskRes.json());
|
| 487 |
+
if (healthRes.ok) setHealth(await healthRes.json());
|
| 488 |
+
} finally { setLoading(false); }
|
| 489 |
+
}, [token]);
|
| 490 |
+
|
| 491 |
+
useEffect(() => { fetchAll(); }, [fetchAll]);
|
| 492 |
+
|
| 493 |
+
return (
|
| 494 |
+
<div>
|
| 495 |
+
<div className="card mb-5">
|
| 496 |
+
<div className="flex justify-between items-center mb-4">
|
| 497 |
+
<h2 className="title-md">Celery Worker Status</h2>
|
| 498 |
+
<button className="btn btn-outline text-xs py-1 px-3" onClick={fetchAll}>
|
| 499 |
+
<RefreshCw size={13}/> Refresh
|
| 500 |
+
</button>
|
| 501 |
+
</div>
|
| 502 |
+
{loading ? <div className="text-center p-6"><Spinner/></div> : (
|
| 503 |
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
|
| 504 |
+
{[
|
| 505 |
+
{ label:'Active Tasks', value: tasks?.active_tasks ?? tasks?.active ?? 0 },
|
| 506 |
+
{ label:'Queued Tasks', value: tasks?.queued_tasks ?? tasks?.reserved ?? 0 },
|
| 507 |
+
{ label:'Completed', value: tasks?.completed_tasks ?? tasks?.total ?? 0 },
|
| 508 |
+
{ label:'Failed', value: tasks?.failed_tasks ?? 0 },
|
| 509 |
+
].map(m => (
|
| 510 |
+
<div key={m.label} className="status-card">
|
| 511 |
+
<div className="status-label">{m.label}</div>
|
| 512 |
+
<div className="metric-value text-2xl">{m.value}</div>
|
| 513 |
+
</div>
|
| 514 |
+
))}
|
| 515 |
+
</div>
|
| 516 |
+
)}
|
| 517 |
+
</div>
|
| 518 |
+
{health && (
|
| 519 |
+
<div className="card">
|
| 520 |
+
<h2 className="title-md mb-3">Service Health</h2>
|
| 521 |
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-2.5">
|
| 522 |
+
{Object.entries(health).map(([k, v]: any) => {
|
| 523 |
+
const ok = v === true || v === 'ok' || v === 'connected' || v === 'healthy';
|
| 524 |
+
return (
|
| 525 |
+
<div key={k} className="border-[1.5px] border-[#e5e5e5] py-2 px-3 flex items-center gap-2">
|
| 526 |
+
<span className={`indicator ${ok ? 'online' : 'offline'}`}/>
|
| 527 |
+
<div>
|
| 528 |
+
<div className="status-label">{k}</div>
|
| 529 |
+
<div className="font-mono text-[0.78rem] font-bold">{String(v)}</div>
|
| 530 |
+
</div>
|
| 531 |
+
</div>
|
| 532 |
+
);
|
| 533 |
+
})}
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
)}
|
| 537 |
+
</div>
|
| 538 |
+
);
|
| 539 |
+
};
|
| 540 |
+
|
| 541 |
+
const EnrichmentTab = ({ token }: { token: string | null }) => {
|
| 542 |
+
const [loading, setLoading] = useState(false);
|
| 543 |
+
const [result, setResult] = useState<any>(null);
|
| 544 |
+
const [batchSize, setBatchSize] = useState(20);
|
| 545 |
+
const [minConnections, setMinConnections] = useState(1);
|
| 546 |
+
const [driftLoading, setDriftLoading] = useState(false);
|
| 547 |
+
const [driftResult, setDriftResult] = useState<any>(null);
|
| 548 |
+
|
| 549 |
+
const runEnrichment = async () => {
|
| 550 |
+
setLoading(true); setResult(null);
|
| 551 |
+
try {
|
| 552 |
+
const res = await fetch(`${API_BASE}/entities/enrich`, {
|
| 553 |
+
method:'POST',
|
| 554 |
+
headers:{ Authorization:`Bearer ${token}`, 'Content-Type':'application/json' },
|
| 555 |
+
body: JSON.stringify({ batch_size: batchSize, min_connections: minConnections })
|
| 556 |
+
});
|
| 557 |
+
if (res.ok) setResult(await res.json());
|
| 558 |
+
} finally { setLoading(false); }
|
| 559 |
+
};
|
| 560 |
+
|
| 561 |
+
const runDrift = async () => {
|
| 562 |
+
setDriftLoading(true); setDriftResult(null);
|
| 563 |
+
try {
|
| 564 |
+
const res = await fetch(`${API_BASE}/ontology/drift/detect`, {
|
| 565 |
+
method:'POST', headers:{ Authorization:`Bearer ${token}` }
|
| 566 |
+
});
|
| 567 |
+
if (res.ok) setDriftResult(await res.json());
|
| 568 |
+
} finally { setDriftLoading(false); }
|
| 569 |
+
};
|
| 570 |
+
|
| 571 |
+
return (
|
| 572 |
+
<div>
|
| 573 |
+
{/* Entity Enrichment */}
|
| 574 |
+
<div className="card mb-6">
|
| 575 |
+
<h2 className="title-md mb-2">Entity Enrichment</h2>
|
| 576 |
+
<p className="text-[var(--muted-color)] text-[0.85rem] mb-5">
|
| 577 |
+
Synthesize rich LLM-generated profiles for all eligible entities by scanning their neighborhood context in the graph.
|
| 578 |
+
</p>
|
| 579 |
+
<div className="grid grid-cols-2 gap-4 mb-4 max-w-[360px]">
|
| 580 |
+
<div>
|
| 581 |
+
<label className="block font-mono text-[0.7rem] font-bold text-[#888] mb-1">BATCH SIZE</label>
|
| 582 |
+
<input type="number" min={1} max={100} value={batchSize} onChange={e => setBatchSize(Number(e.target.value))}
|
| 583 |
+
className="search-input w-full"/>
|
| 584 |
+
</div>
|
| 585 |
+
<div>
|
| 586 |
+
<label className="block font-mono text-[0.7rem] font-bold text-[#888] mb-1">MIN CONNECTIONS</label>
|
| 587 |
+
<input type="number" min={0} max={20} value={minConnections} onChange={e => setMinConnections(Number(e.target.value))}
|
| 588 |
+
className="search-input w-full"/>
|
| 589 |
+
</div>
|
| 590 |
+
</div>
|
| 591 |
+
<button className="btn btn-primary flex gap-2 items-center" onClick={runEnrichment} disabled={loading}>
|
| 592 |
+
{loading ? <Spinner/> : <Zap size={14}/>}
|
| 593 |
+
{loading ? 'Enriching…' : 'Run Entity Enrichment'}
|
| 594 |
+
</button>
|
| 595 |
+
{result && (
|
| 596 |
+
<div className="mt-4 bg-[#f0fdf4] border border-[#bbf7d0] p-3 font-mono text-[0.82rem] text-[#166534]">
|
| 597 |
+
✓ {result.message || `Enriched ${result.enriched_count ?? '?'} entities`}
|
| 598 |
+
</div>
|
| 599 |
+
)}
|
| 600 |
+
</div>
|
| 601 |
+
|
| 602 |
+
{/* Drift Detection */}
|
| 603 |
+
<div className="card">
|
| 604 |
+
<h2 className="title-md mb-2">Ontology Drift Detection</h2>
|
| 605 |
+
<p className="text-[var(--muted-color)] text-[0.85rem] mb-5">
|
| 606 |
+
Analyse recent data samples to detect schema evolution and generate a drift report for admin review.
|
| 607 |
+
</p>
|
| 608 |
+
<button className="btn btn-primary flex gap-2 items-center" onClick={runDrift} disabled={driftLoading}>
|
| 609 |
+
{driftLoading ? <Spinner/> : <GitBranch size={14}/>}
|
| 610 |
+
{driftLoading ? 'Detecting…' : 'Run Drift Detection'}
|
| 611 |
+
</button>
|
| 612 |
+
{driftResult && (
|
| 613 |
+
<div className="mt-4 bg-[#eff6ff] border border-[#bfdbfe] p-3 font-mono text-[0.82rem] text-[#1d4ed8]">
|
| 614 |
+
✓ Drift report created. ID: {driftResult.report_id || driftResult.id || '—'} → Review in Ontology Governance tab.
|
| 615 |
+
</div>
|
| 616 |
+
)}
|
| 617 |
+
</div>
|
| 618 |
+
</div>
|
| 619 |
+
);
|
| 620 |
+
};
|
| 621 |
+
|
| 622 |
+
const SandboxTab = ({ token }: { token: string | null }) => {
|
| 623 |
+
const [loading, setLoading] = useState(false);
|
| 624 |
+
const [msg, setMsg] = useState('');
|
| 625 |
+
|
| 626 |
+
const trigger = async (endpoint: string, label: string) => {
|
| 627 |
+
setLoading(true); setMsg('');
|
| 628 |
+
try {
|
| 629 |
+
const res = await fetch(`${API_BASE}${endpoint}`, {
|
| 630 |
+
method:'POST', headers:{ Authorization:`Bearer ${token}` }
|
| 631 |
+
});
|
| 632 |
+
setMsg(res.ok ? `✓ ${label} dispatched to Celery worker.` : `✗ Failed to trigger ${label}.`);
|
| 633 |
+
} catch {
|
| 634 |
+
setMsg(`✗ Network error.`);
|
| 635 |
+
} finally { setLoading(false); }
|
| 636 |
+
};
|
| 637 |
+
|
| 638 |
+
return (
|
| 639 |
+
<div className="card">
|
| 640 |
+
<h2 className="title-md mb-2">MiroFish God-Mode Sandbox</h2>
|
| 641 |
+
<p className="text-[var(--muted-color)] text-[0.85rem] mb-6">
|
| 642 |
+
Control the simulation loops that connect Knowledge Graph entities into living agents.
|
| 643 |
+
</p>
|
| 644 |
+
{msg && (
|
| 645 |
+
<div className={`py-2.5 px-3.5 mb-4 font-mono text-[0.82rem] border ${msg.startsWith('✓') ? 'bg-[#dcfce7] text-[#166534] border-[#bbf7d0]' : 'bg-[#fef2f2] text-[#dc2626] border-[#fecaca]'}`}>
|
| 646 |
+
{msg}
|
| 647 |
+
</div>
|
| 648 |
+
)}
|
| 649 |
+
<div className="flex flex-col gap-4 max-w-[420px]">
|
| 650 |
+
{[
|
| 651 |
+
{ endpoint:'/v1/simulation/generate_personas', label:'Generate Agent Personas', icon:<Users size={14}/>,
|
| 652 |
+
desc:'Converts raw graph nodes into living psychological profiles for agent simulation.' },
|
| 653 |
+
{ endpoint:'/v1/simulation/tick', label:'Force Simulation Tick', icon:<Play size={14}/>,
|
| 654 |
+
desc:'Forces agents to read their local graph memory and output a new interaction edge.' },
|
| 655 |
+
].map(item => (
|
| 656 |
+
<div key={item.endpoint} className="border-2 border-black p-4">
|
| 657 |
+
<button className="btn btn-primary flex gap-2 items-center w-full justify-center mb-2" onClick={() => trigger(item.endpoint, item.label)} disabled={loading}>
|
| 658 |
+
{loading ? <Spinner/> : item.icon} {item.label}
|
| 659 |
+
</button>
|
| 660 |
+
<p className="m-0 text-[0.78rem] text-[var(--muted-color)] font-sans">{item.desc}</p>
|
| 661 |
+
</div>
|
| 662 |
+
))}
|
| 663 |
+
</div>
|
| 664 |
+
</div>
|
| 665 |
+
);
|
| 666 |
+
};
|
| 667 |
+
|
| 668 |
+
// ─── Main Dashboard ───────────────────────────────────────────────────────────
|
| 669 |
+
export default function AdminDashboard() {
|
| 670 |
+
const { token, user } = useAuth();
|
| 671 |
+
const [activeTab, setActiveTab] = useState('overview');
|
| 672 |
+
const [stats, setStats] = useState<any>(null);
|
| 673 |
+
const [health, setHealth] = useState<any>(null);
|
| 674 |
+
const [error, setError] = useState<string | null>(null);
|
| 675 |
+
|
| 676 |
+
const fetchOverview = useCallback(async () => {
|
| 677 |
+
if (!token) return;
|
| 678 |
+
try {
|
| 679 |
+
const [statsRes, healthRes] = await Promise.all([
|
| 680 |
+
fetch(`${API_BASE}/admin/stats`, { headers: { Authorization: `Bearer ${token}` } }),
|
| 681 |
+
fetch(`${API_BASE}/system/health`, { headers: { Authorization: `Bearer ${token}` } }),
|
| 682 |
+
]);
|
| 683 |
+
if (statsRes.ok) setStats(await statsRes.json());
|
| 684 |
+
if (healthRes.ok) setHealth(await healthRes.json());
|
| 685 |
+
} catch (err: any) {
|
| 686 |
+
setError(err.message);
|
| 687 |
+
}
|
| 688 |
+
}, [token]);
|
| 689 |
+
|
| 690 |
+
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
| 691 |
+
|
| 692 |
+
if (user && user.username !== 'admin' && !user.scopes?.includes('admin')) {
|
| 693 |
+
return (
|
| 694 |
+
<div className="container flex-center min-h-[60vh] flex-col gap-4">
|
| 695 |
+
<Shield size={48} className="opacity-30"/>
|
| 696 |
+
<h2 className="title-md">Access Denied</h2>
|
| 697 |
+
<p className="text-[var(--muted-color)]">You need administrative privileges to view this page.</p>
|
| 698 |
+
</div>
|
| 699 |
+
);
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
const TABS = [
|
| 703 |
+
{ id:'overview', label:'Overview', icon:<BarChart2 size={14}/> },
|
| 704 |
+
{ id:'users', label:'Users', icon:<Users size={14}/> },
|
| 705 |
+
{ id:'documents', label:'Documents', icon:<Database size={14}/> },
|
| 706 |
+
{ id:'graph', label:'Graph CRUD', icon:<GitBranch size={14}/> },
|
| 707 |
+
{ id:'ontology', label:'Ontology', icon:<Settings size={14}/> },
|
| 708 |
+
{ id:'workers', label:'Workers', icon:<Cpu size={14}/> },
|
| 709 |
+
{ id:'enrichment', label:'Enrichment / Drift', icon:<Zap size={14}/> },
|
| 710 |
+
{ id:'sandbox', label:'God-Mode Sandbox', icon:<Play size={14}/> },
|
| 711 |
+
];
|
| 712 |
+
|
| 713 |
+
return (
|
| 714 |
+
<div className="container fade-in py-8 px-10 max-w-[1400px]">
|
| 715 |
+
{/* Header */}
|
| 716 |
+
<div className="flex justify-between items-start mb-6">
|
| 717 |
+
<div>
|
| 718 |
+
<h1 className="text-3xl font-bold font-display tracking-tight">Admin Control Center</h1>
|
| 719 |
+
<p className="text-[#666] mt-2 text-[0.95rem]">Manage graph data, workers, users, and platform configuration</p>
|
| 720 |
+
</div>
|
| 721 |
+
{error && (
|
| 722 |
+
<div className="bg-[#fef2f2] text-[#dc2626] py-2 px-4 border border-[#fecaca] font-mono text-[0.85rem] flex items-center gap-2 rounded shadow-sm">
|
| 723 |
+
<AlertTriangle size={15}/> {error}
|
| 724 |
+
</div>
|
| 725 |
+
)}
|
| 726 |
+
</div>
|
| 727 |
+
|
| 728 |
+
<div className="grid grid-cols-[240px_1fr] gap-8 mt-2">
|
| 729 |
+
{/* Sidebar nav */}
|
| 730 |
+
<div className="bg-white border-2 border-black p-3">
|
| 731 |
+
<nav className="flex flex-col gap-1.5">
|
| 732 |
+
{TABS.map(tab => (
|
| 733 |
+
<button
|
| 734 |
+
key={tab.id}
|
| 735 |
+
className={`flex items-center w-full justify-start gap-2.5 py-2 px-3 text-[0.85rem] font-medium border-[1.5px] transition-colors ${activeTab === tab.id ? 'bg-black text-white border-black' : 'bg-transparent text-black border-transparent hover:bg-gray-100'}`}
|
| 736 |
+
onClick={() => setActiveTab(tab.id)}
|
| 737 |
+
>
|
| 738 |
+
{tab.icon} {tab.label}
|
| 739 |
+
</button>
|
| 740 |
+
))}
|
| 741 |
+
</nav>
|
| 742 |
+
</div>
|
| 743 |
+
|
| 744 |
+
{/* Main content */}
|
| 745 |
+
<div>
|
| 746 |
+
{activeTab === 'overview' && <OverviewTab stats={stats} health={health} onRefresh={fetchOverview}/>}
|
| 747 |
+
{activeTab === 'users' && <UsersTab token={token}/>}
|
| 748 |
+
{activeTab === 'documents' && <DocumentsTab token={token}/>}
|
| 749 |
+
{activeTab === 'graph' && <GraphCRUDTab token={token}/>}
|
| 750 |
+
{activeTab === 'ontology' && <OntologyGovernanceTab token={token}/>}
|
| 751 |
+
{activeTab === 'workers' && <WorkersTab token={token}/>}
|
| 752 |
+
{activeTab === 'enrichment' && <EnrichmentTab token={token}/>}
|
| 753 |
+
{activeTab === 'sandbox' && <SandboxTab token={token}/>}
|
| 754 |
+
</div>
|
| 755 |
+
</div>
|
| 756 |
+
|
| 757 |
+
<style>{`
|
| 758 |
+
@keyframes spin { 100% { transform: rotate(360deg); } }
|
| 759 |
+
`}</style>
|
| 760 |
+
</div>
|
| 761 |
+
);
|
| 762 |
+
}
|
frontend-react/src/views/Home.tsx
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useRef } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import { Network, Server, Cpu, Database, Activity, ArrowRight, Zap, GitBranch, MessageSquare, TrendingUp, RefreshCw } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
|
| 6 |
+
|
| 7 |
+
// Animated counter hook
|
| 8 |
+
function useCountUp(target: number, duration = 1200) {
|
| 9 |
+
const [val, setVal] = useState(0);
|
| 10 |
+
const prev = useRef(0);
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
if (target === 0) { setVal(0); return; }
|
| 13 |
+
const start = prev.current;
|
| 14 |
+
const diff = target - start;
|
| 15 |
+
const startTime = performance.now();
|
| 16 |
+
const tick = (now: number) => {
|
| 17 |
+
const t = Math.min((now - startTime) / duration, 1);
|
| 18 |
+
const ease = 1 - Math.pow(1 - t, 3);
|
| 19 |
+
setVal(Math.round(start + diff * ease));
|
| 20 |
+
if (t < 1) requestAnimationFrame(tick);
|
| 21 |
+
else prev.current = target;
|
| 22 |
+
};
|
| 23 |
+
requestAnimationFrame(tick);
|
| 24 |
+
}, [target]);
|
| 25 |
+
return val;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const StatCounter: React.FC<{ value: number | string; label: string; suffix?: string }> = ({ value, label, suffix = '' }) => {
|
| 29 |
+
const numVal = typeof value === 'number' ? value : parseInt(String(value)) || 0;
|
| 30 |
+
const animated = useCountUp(numVal);
|
| 31 |
+
return (
|
| 32 |
+
<div className="hm-stat-block">
|
| 33 |
+
<div className="hm-stat-value">{typeof value === 'number' ? animated : value}{suffix}</div>
|
| 34 |
+
<div className="hm-stat-key">{label}</div>
|
| 35 |
+
</div>
|
| 36 |
+
);
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const Home: React.FC = () => {
|
| 40 |
+
const { token, logout, user } = useAuth();
|
| 41 |
+
const [health, setHealth] = useState<any>(null);
|
| 42 |
+
const [stats, setStats] = useState<any>(null);
|
| 43 |
+
const [myStats, setMyStats] = useState<any>(null);
|
| 44 |
+
const [loading, setLoading] = useState(true);
|
| 45 |
+
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
| 46 |
+
|
| 47 |
+
const fetchData = async () => {
|
| 48 |
+
setLoading(true);
|
| 49 |
+
try {
|
| 50 |
+
const [healthRes, statsRes, myStatsRes] = await Promise.all([
|
| 51 |
+
fetch(`${API_BASE}/system/health`),
|
| 52 |
+
fetch(`${API_BASE}/system/stats`, { headers: { Authorization: `Bearer ${token}` } }),
|
| 53 |
+
fetch(`${API_BASE}/system/my-stats`, { headers: { Authorization: `Bearer ${token}` } }).catch(() => null),
|
| 54 |
+
]);
|
| 55 |
+
if (statsRes.status === 401) { logout(); return; }
|
| 56 |
+
if (healthRes.ok) setHealth(await healthRes.json());
|
| 57 |
+
if (statsRes.ok) setStats(await statsRes.json());
|
| 58 |
+
if (myStatsRes?.ok) setMyStats(await myStatsRes.json());
|
| 59 |
+
} catch (err) {
|
| 60 |
+
console.error('Failed to fetch system data', err);
|
| 61 |
+
} finally {
|
| 62 |
+
setLoading(false);
|
| 63 |
+
setLastRefresh(new Date());
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
useEffect(() => {
|
| 68 |
+
fetchData();
|
| 69 |
+
const interval = setInterval(fetchData, 30000);
|
| 70 |
+
return () => clearInterval(interval);
|
| 71 |
+
}, [token]);
|
| 72 |
+
|
| 73 |
+
const isOnline = (v: boolean | undefined) => v === true;
|
| 74 |
+
|
| 75 |
+
const systemOk = health ? (isOnline(health.neo4j_connected) && isOnline(health.redis_connected)) : false;
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div className="container fade-in max-w-[1300px] pb-12">
|
| 79 |
+
|
| 80 |
+
{/* ── Hero ─────────────────────────────────────── */}
|
| 81 |
+
<div className="hm-hero">
|
| 82 |
+
<div className="hm-hero-left">
|
| 83 |
+
<div className="hm-hero-label">CORTEX PLATFORM</div>
|
| 84 |
+
<h1 className="hm-hero-title">
|
| 85 |
+
Agentic Knowledge<br />
|
| 86 |
+
<span className="hm-hero-accent">Intelligence</span>
|
| 87 |
+
</h1>
|
| 88 |
+
<p className="hm-hero-sub">
|
| 89 |
+
Production-grade knowledge graph · Real-time extraction · Multi-hop reasoning
|
| 90 |
+
</p>
|
| 91 |
+
<div className="hm-hero-ctas">
|
| 92 |
+
<a href="/process" className="hm-cta-primary">
|
| 93 |
+
<Database size={16} /> Ingest Documents <ArrowRight size={14} />
|
| 94 |
+
</a>
|
| 95 |
+
<a href="/interact" className="hm-cta-secondary">
|
| 96 |
+
<Cpu size={16} /> Query Graph <ArrowRight size={14} />
|
| 97 |
+
</a>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div className="hm-hero-right">
|
| 102 |
+
<div className={`hm-system-badge ${systemOk ? 'ok' : 'warn'}`}>
|
| 103 |
+
<span className={`hm-pulse-dot ${systemOk ? 'green' : 'yellow'}`} />
|
| 104 |
+
<span className="hm-badge-label">
|
| 105 |
+
{loading ? 'CHECKING...' : systemOk ? 'SYSTEM OPERATIONAL' : 'SYSTEM DEGRADED'}
|
| 106 |
+
</span>
|
| 107 |
+
<button className="hm-refresh-btn" onClick={fetchData} title="Refresh">
|
| 108 |
+
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<div className="hm-infra-stack">
|
| 113 |
+
{[
|
| 114 |
+
{ label: 'NEO4J', ok: isOnline(health?.neo4j_connected) },
|
| 115 |
+
{ label: 'REDIS', ok: isOnline(health?.redis_connected) },
|
| 116 |
+
{ label: `${health?.workers_active ?? 0} WORKERS`, ok: true, neutral: true },
|
| 117 |
+
{ label: 'API', ok: true },
|
| 118 |
+
].map(s => (
|
| 119 |
+
<div key={s.label} className="hm-infra-row">
|
| 120 |
+
<span className={`hm-dot ${s.neutral ? 'neutral' : s.ok ? 'online' : 'offline'}`} />
|
| 121 |
+
<span className="hm-infra-name">{s.label}</span>
|
| 122 |
+
<span className={`hm-infra-badge ${s.neutral ? 'neutral' : s.ok ? 'ok' : 'fail'}`}>
|
| 123 |
+
{s.neutral ? 'ACTIVE' : s.ok ? 'CONNECTED' : 'OFFLINE'}
|
| 124 |
+
</span>
|
| 125 |
+
</div>
|
| 126 |
+
))}
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
{/* ── Platform Metrics ─────────────────────────── */}
|
| 132 |
+
<div className="hm-section">
|
| 133 |
+
<div className="hm-section-label">PLATFORM METRICS</div>
|
| 134 |
+
<div className="hm-metrics-row">
|
| 135 |
+
<StatCounter value={stats?.documents_count ?? 0} label="DOCUMENTS" />
|
| 136 |
+
<div className="hm-metric-divider" />
|
| 137 |
+
<StatCounter value={stats?.entities_count ?? 0} label="ENTITIES" />
|
| 138 |
+
<div className="hm-metric-divider" />
|
| 139 |
+
<StatCounter value={stats?.relationships_count ?? 0} label="RELATIONSHIPS" />
|
| 140 |
+
<div className="hm-metric-divider" />
|
| 141 |
+
<StatCounter value={stats?.chunks_count ?? 0} label="CHUNKS" />
|
| 142 |
+
<div className="hm-metric-divider" />
|
| 143 |
+
<div className="hm-stat-block">
|
| 144 |
+
<div className="hm-stat-value text-2xl">
|
| 145 |
+
{stats?.ontology_version ?? '—'}
|
| 146 |
+
</div>
|
| 147 |
+
<div className="hm-stat-key">ONTOLOGY VER</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
{/* ── Main Grid ────────────────────────────────── */}
|
| 153 |
+
<div className="hm-main-grid">
|
| 154 |
+
|
| 155 |
+
{/* Quick Actions */}
|
| 156 |
+
<div className="hm-card hm-actions-card">
|
| 157 |
+
<div className="hm-card-head">
|
| 158 |
+
<Activity size={16} /> QUICK ACTIONS
|
| 159 |
+
</div>
|
| 160 |
+
<div className="hm-action-list">
|
| 161 |
+
{[
|
| 162 |
+
{ href: '/process', icon: <Database size={18}/>, label: 'INGEST DOCUMENTS', desc: 'Upload PDFs, text, or crawl URLs' },
|
| 163 |
+
{ href: '/interact', icon: <Cpu size={18}/>, label: 'QUERY KNOWLEDGE', desc: 'Ask questions across the graph' },
|
| 164 |
+
{ href: '/simulate', icon: <Network size={18}/>, label: 'EXPLORE NODES', desc: 'Interactive D3 force visualization' },
|
| 165 |
+
{ href: '/ontology', icon: <Server size={18}/>, label: 'MANAGE ONTOLOGY', desc: 'Edit schema & run AI refinement' },
|
| 166 |
+
{ href: '/insights', icon: <TrendingUp size={18}/>,label: 'INSIGHTS', desc: 'Quality metrics & AI reports' },
|
| 167 |
+
].map(a => (
|
| 168 |
+
<a key={a.href} href={a.href} className="hm-action-item">
|
| 169 |
+
<span className="hm-action-icon">{a.icon}</span>
|
| 170 |
+
<div className="hm-action-text">
|
| 171 |
+
<div className="hm-action-label">{a.label}</div>
|
| 172 |
+
<div className="hm-action-desc">{a.desc}</div>
|
| 173 |
+
</div>
|
| 174 |
+
<ArrowRight size={14} className="hm-action-arrow" />
|
| 175 |
+
</a>
|
| 176 |
+
))}
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
{/* Right column: My Activity + Feature cards */}
|
| 181 |
+
<div className="flex flex-col gap-6">
|
| 182 |
+
|
| 183 |
+
{/* User Activity */}
|
| 184 |
+
<div className="hm-card">
|
| 185 |
+
<div className="hm-card-head">
|
| 186 |
+
<MessageSquare size={16} /> MY ACTIVITY
|
| 187 |
+
{user && <span className="ml-auto font-mono text-[0.7rem] text-[#666666]">@{user.username}</span>}
|
| 188 |
+
</div>
|
| 189 |
+
<div className="hm-activity-grid">
|
| 190 |
+
<div className="hm-activity-chip">
|
| 191 |
+
<div className="hm-activity-val">{myStats?.conversation_count ?? '—'}</div>
|
| 192 |
+
<div className="hm-activity-key">CONVERSATIONS</div>
|
| 193 |
+
</div>
|
| 194 |
+
<div className="hm-activity-chip">
|
| 195 |
+
<div className="hm-activity-val">{myStats?.message_count ?? '—'}</div>
|
| 196 |
+
<div className="hm-activity-key">QUERIES SENT</div>
|
| 197 |
+
</div>
|
| 198 |
+
<div className="hm-activity-chip">
|
| 199 |
+
<div className="hm-activity-val">{myStats?.last_active ? new Date(myStats.last_active).toLocaleDateString() : '—'}</div>
|
| 200 |
+
<div className="hm-activity-key">LAST ACTIVE</div>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
{!myStats && (
|
| 204 |
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.78rem', color: 'var(--muted-color)', marginTop: '0.75rem' }}>
|
| 205 |
+
Start querying the graph to build your activity history.
|
| 206 |
+
</div>
|
| 207 |
+
)}
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
{/* Graph intelligence card */}
|
| 211 |
+
<div className="hm-card hm-graph-card">
|
| 212 |
+
<div className="hm-card-head">
|
| 213 |
+
<GitBranch size={16} /> KNOWLEDGE GRAPH
|
| 214 |
+
</div>
|
| 215 |
+
<div style={{ fontSize: '0.85rem', lineHeight: 1.7, color: 'var(--muted-color)', marginBottom: '1rem' }}>
|
| 216 |
+
Neo4j-powered semantic knowledge graph. Multi-hop reasoning, entity enrichment, and community detection built in.
|
| 217 |
+
</div>
|
| 218 |
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
| 219 |
+
{['Entities', 'Relationships', 'Communities', 'Graph Export'].map(tag => (
|
| 220 |
+
<span key={tag} className="hm-tag">{tag}</span>
|
| 221 |
+
))}
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
{/* ── Feature Showcase ─────────────────────────── */}
|
| 228 |
+
<div className="hm-features-section">
|
| 229 |
+
<div className="hm-section-label" style={{ marginBottom: '1.5rem' }}>PLATFORM CAPABILITIES</div>
|
| 230 |
+
<div className="hm-features-grid">
|
| 231 |
+
{[
|
| 232 |
+
{
|
| 233 |
+
icon: <Database size={24}/>,
|
| 234 |
+
title: 'DOCUMENT INGESTION',
|
| 235 |
+
desc: 'Ingest PDFs, text files, Markdown, and web URLs. Celery workers extract entities and relationships into the knowledge graph automatically via LLM pipelines.',
|
| 236 |
+
color: '#2563eb',
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
icon: <Network size={24}/>,
|
| 240 |
+
title: 'GRAPH INTELLIGENCE',
|
| 241 |
+
desc: 'Neo4j-powered knowledge graph with rich entity relationships. Query across documents globally or per-source with full ontology control.',
|
| 242 |
+
color: '#7c3aed',
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
icon: <Cpu size={24}/>,
|
| 246 |
+
title: 'AGENTIC LOGIC',
|
| 247 |
+
desc: 'Multi-step ReACT reasoning agent that searches the graph, retrieves relevant chunks, and streams answers with confidence scoring in real time.',
|
| 248 |
+
color: '#059669',
|
| 249 |
+
},
|
| 250 |
+
{
|
| 251 |
+
icon: <Zap size={24}/>,
|
| 252 |
+
title: 'LLM-AS-JUDGE',
|
| 253 |
+
desc: 'Inline faithfulness evaluation using heuristic scoring. Detects hallucination risk, context precision, and answer quality on every response.',
|
| 254 |
+
color: '#d97706',
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
icon: <Activity size={24}/>,
|
| 258 |
+
title: 'LIVE SIMULATION',
|
| 259 |
+
desc: 'Interactive D3 force graph with color-coded entity types, physics controls, fullscreen mode, PNG export, and node detail modals.',
|
| 260 |
+
color: '#dc2626',
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
icon: <TrendingUp size={24}/>,
|
| 264 |
+
title: 'ONTOLOGY DRIFT',
|
| 265 |
+
desc: 'Automated schema drift detection that spots when new data no longer fits the current ontology. Propose and approve schema expansions.',
|
| 266 |
+
color: '#0891b2',
|
| 267 |
+
},
|
| 268 |
+
].map(f => (
|
| 269 |
+
<div key={f.title} className="hm-feature-card" style={{ '--feature-color': f.color } as any}>
|
| 270 |
+
<div className="hm-feature-icon" style={{ color: f.color }}>{f.icon}</div>
|
| 271 |
+
<div className="hm-feature-title">{f.title}</div>
|
| 272 |
+
<div className="hm-feature-desc">{f.desc}</div>
|
| 273 |
+
</div>
|
| 274 |
+
))}
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
{/* Footer bar */}
|
| 279 |
+
<div className="hm-footer-bar">
|
| 280 |
+
<span className="hm-footer-brand">CORTEX_PLATFORM</span>
|
| 281 |
+
<span style={{ color: 'var(--muted-color)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}>
|
| 282 |
+
Last refreshed: {lastRefresh.toLocaleTimeString()}
|
| 283 |
+
</span>
|
| 284 |
+
<span style={{ color: 'var(--muted-color)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}>
|
| 285 |
+
v{health ? '1.0' : '—'} · Neo4j + Redis + Celery
|
| 286 |
+
</span>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<style>{`
|
| 290 |
+
/* ── Hero ── */
|
| 291 |
+
.hm-hero {
|
| 292 |
+
display: grid;
|
| 293 |
+
grid-template-columns: 1fr 320px;
|
| 294 |
+
gap: 2rem;
|
| 295 |
+
padding: 2.5rem 0 2rem;
|
| 296 |
+
border-bottom: 3px solid #000;
|
| 297 |
+
margin-bottom: 2.5rem;
|
| 298 |
+
align-items: center;
|
| 299 |
+
}
|
| 300 |
+
.hm-hero-label {
|
| 301 |
+
font-family: var(--font-mono);
|
| 302 |
+
font-size: 0.72rem;
|
| 303 |
+
font-weight: 700;
|
| 304 |
+
letter-spacing: 3px;
|
| 305 |
+
color: var(--muted-color);
|
| 306 |
+
margin-bottom: 0.75rem;
|
| 307 |
+
}
|
| 308 |
+
.hm-hero-title {
|
| 309 |
+
font-family: var(--font-display);
|
| 310 |
+
font-size: clamp(2rem, 4vw, 3rem);
|
| 311 |
+
font-weight: 800;
|
| 312 |
+
line-height: 1.1;
|
| 313 |
+
margin-bottom: 0.75rem;
|
| 314 |
+
letter-spacing: -0.5px;
|
| 315 |
+
}
|
| 316 |
+
.hm-hero-accent {
|
| 317 |
+
position: relative;
|
| 318 |
+
display: inline-block;
|
| 319 |
+
}
|
| 320 |
+
.hm-hero-accent::after {
|
| 321 |
+
content: '';
|
| 322 |
+
position: absolute;
|
| 323 |
+
left: 0; bottom: 2px;
|
| 324 |
+
width: 100%; height: 4px;
|
| 325 |
+
background: #000;
|
| 326 |
+
}
|
| 327 |
+
.hm-hero-sub {
|
| 328 |
+
color: var(--muted-color);
|
| 329 |
+
font-size: 0.95rem;
|
| 330 |
+
line-height: 1.6;
|
| 331 |
+
margin-bottom: 1.5rem;
|
| 332 |
+
max-width: 480px;
|
| 333 |
+
}
|
| 334 |
+
.hm-hero-ctas {
|
| 335 |
+
display: flex;
|
| 336 |
+
gap: 0.75rem;
|
| 337 |
+
flex-wrap: wrap;
|
| 338 |
+
}
|
| 339 |
+
.hm-cta-primary {
|
| 340 |
+
display: inline-flex;
|
| 341 |
+
align-items: center;
|
| 342 |
+
gap: 0.5rem;
|
| 343 |
+
background: #000;
|
| 344 |
+
color: #fff;
|
| 345 |
+
padding: 0.65rem 1.25rem;
|
| 346 |
+
font-family: var(--font-mono);
|
| 347 |
+
font-size: 0.8rem;
|
| 348 |
+
font-weight: 700;
|
| 349 |
+
letter-spacing: 0.5px;
|
| 350 |
+
text-decoration: none;
|
| 351 |
+
transition: background 0.15s, transform 0.15s;
|
| 352 |
+
border: 2px solid #000;
|
| 353 |
+
}
|
| 354 |
+
.hm-cta-primary:hover {
|
| 355 |
+
background: #333;
|
| 356 |
+
color: #fff;
|
| 357 |
+
transform: translateY(-1px);
|
| 358 |
+
box-shadow: 3px 3px 0 rgba(0,0,0,0.2);
|
| 359 |
+
}
|
| 360 |
+
.hm-cta-secondary {
|
| 361 |
+
display: inline-flex;
|
| 362 |
+
align-items: center;
|
| 363 |
+
gap: 0.5rem;
|
| 364 |
+
background: transparent;
|
| 365 |
+
color: #000;
|
| 366 |
+
padding: 0.65rem 1.25rem;
|
| 367 |
+
font-family: var(--font-mono);
|
| 368 |
+
font-size: 0.8rem;
|
| 369 |
+
font-weight: 700;
|
| 370 |
+
letter-spacing: 0.5px;
|
| 371 |
+
text-decoration: none;
|
| 372 |
+
border: 2px solid #000;
|
| 373 |
+
transition: background 0.15s, transform 0.15s;
|
| 374 |
+
}
|
| 375 |
+
.hm-cta-secondary:hover {
|
| 376 |
+
background: #000;
|
| 377 |
+
color: #fff;
|
| 378 |
+
transform: translateY(-1px);
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
/* System badge */
|
| 382 |
+
.hm-system-badge {
|
| 383 |
+
display: flex;
|
| 384 |
+
align-items: center;
|
| 385 |
+
gap: 0.6rem;
|
| 386 |
+
border: 2px solid #000;
|
| 387 |
+
padding: 0.5rem 0.85rem;
|
| 388 |
+
margin-bottom: 1rem;
|
| 389 |
+
font-family: var(--font-mono);
|
| 390 |
+
font-size: 0.72rem;
|
| 391 |
+
font-weight: 700;
|
| 392 |
+
letter-spacing: 1px;
|
| 393 |
+
}
|
| 394 |
+
.hm-system-badge.warn { border-color: #d97706; color: #d97706; }
|
| 395 |
+
.hm-pulse-dot {
|
| 396 |
+
width: 8px; height: 8px;
|
| 397 |
+
border-radius: 50%;
|
| 398 |
+
flex-shrink: 0;
|
| 399 |
+
animation: pulseGlow 2s ease-in-out infinite;
|
| 400 |
+
}
|
| 401 |
+
.hm-pulse-dot.green { background: #16a34a; box-shadow: 0 0 0 0 rgba(22,163,74,0.4); }
|
| 402 |
+
.hm-pulse-dot.yellow { background: #d97706; box-shadow: 0 0 0 0 rgba(217,119,6,0.4); }
|
| 403 |
+
@keyframes pulseGlow {
|
| 404 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(22,163,74,0.4); }
|
| 405 |
+
50% { box-shadow: 0 0 0 5px rgba(22,163,74,0); }
|
| 406 |
+
}
|
| 407 |
+
.hm-badge-label { flex: 1; }
|
| 408 |
+
.hm-refresh-btn {
|
| 409 |
+
background: none;
|
| 410 |
+
border: none;
|
| 411 |
+
cursor: pointer;
|
| 412 |
+
padding: 0;
|
| 413 |
+
color: var(--muted-color);
|
| 414 |
+
display: flex;
|
| 415 |
+
align-items: center;
|
| 416 |
+
transition: color 0.15s;
|
| 417 |
+
}
|
| 418 |
+
.hm-refresh-btn:hover { color: #000; background: none; }
|
| 419 |
+
|
| 420 |
+
/* Infra stack */
|
| 421 |
+
.hm-infra-stack { display: flex; flex-direction: column; gap: 0.5rem; }
|
| 422 |
+
.hm-infra-row {
|
| 423 |
+
display: flex;
|
| 424 |
+
align-items: center;
|
| 425 |
+
gap: 0.5rem;
|
| 426 |
+
padding: 0.4rem 0.6rem;
|
| 427 |
+
border: 1.5px solid #e5e5e5;
|
| 428 |
+
font-family: var(--font-mono);
|
| 429 |
+
font-size: 0.78rem;
|
| 430 |
+
}
|
| 431 |
+
.hm-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
| 432 |
+
.hm-dot.online { background: #16a34a; }
|
| 433 |
+
.hm-dot.offline { background: #dc2626; }
|
| 434 |
+
.hm-dot.neutral { background: #9ca3af; }
|
| 435 |
+
.hm-infra-name { flex: 1; font-weight: 600; color: var(--muted-color); }
|
| 436 |
+
.hm-infra-badge {
|
| 437 |
+
font-size: 0.65rem;
|
| 438 |
+
font-weight: 700;
|
| 439 |
+
padding: 1px 6px;
|
| 440 |
+
letter-spacing: 0.5px;
|
| 441 |
+
}
|
| 442 |
+
.hm-infra-badge.ok { background: #dcfce7; color: #16a34a; }
|
| 443 |
+
.hm-infra-badge.fail { background: #fee2e2; color: #dc2626; }
|
| 444 |
+
.hm-infra-badge.neutral { background: #f3f4f6; color: #6b7280; }
|
| 445 |
+
|
| 446 |
+
/* ── Section label ── */
|
| 447 |
+
.hm-section { margin-bottom: 2.5rem; }
|
| 448 |
+
.hm-section-label {
|
| 449 |
+
font-family: var(--font-mono);
|
| 450 |
+
font-size: 0.68rem;
|
| 451 |
+
font-weight: 700;
|
| 452 |
+
letter-spacing: 3px;
|
| 453 |
+
color: var(--muted-color);
|
| 454 |
+
margin-bottom: 1rem;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
/* ── Metrics row ── */
|
| 458 |
+
.hm-metrics-row {
|
| 459 |
+
display: flex;
|
| 460 |
+
align-items: center;
|
| 461 |
+
border: 3px solid #000;
|
| 462 |
+
overflow: hidden;
|
| 463 |
+
}
|
| 464 |
+
.hm-stat-block {
|
| 465 |
+
flex: 1;
|
| 466 |
+
min-width: 0;
|
| 467 |
+
padding: 1.25rem 1.5rem;
|
| 468 |
+
text-align: center;
|
| 469 |
+
}
|
| 470 |
+
.hm-stat-value {
|
| 471 |
+
font-family: var(--font-mono);
|
| 472 |
+
font-size: 2.2rem;
|
| 473 |
+
font-weight: 900;
|
| 474 |
+
line-height: 1;
|
| 475 |
+
margin-bottom: 0.3rem;
|
| 476 |
+
}
|
| 477 |
+
.hm-stat-key {
|
| 478 |
+
font-family: var(--font-mono);
|
| 479 |
+
font-size: 0.6rem;
|
| 480 |
+
color: var(--muted-color);
|
| 481 |
+
letter-spacing: 1.5px;
|
| 482 |
+
font-weight: 700;
|
| 483 |
+
}
|
| 484 |
+
.hm-metric-divider {
|
| 485 |
+
width: 1px;
|
| 486 |
+
height: 60px;
|
| 487 |
+
background: #000;
|
| 488 |
+
flex-shrink: 0;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
/* ── Main grid ── */
|
| 492 |
+
.hm-main-grid {
|
| 493 |
+
display: grid;
|
| 494 |
+
grid-template-columns: 1fr 360px;
|
| 495 |
+
gap: 1.5rem;
|
| 496 |
+
margin-bottom: 3rem;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
/* ── Card base ── */
|
| 500 |
+
.hm-card {
|
| 501 |
+
border: 2px solid #000;
|
| 502 |
+
padding: 1.5rem;
|
| 503 |
+
background: #fff;
|
| 504 |
+
transition: transform 0.15s, box-shadow 0.15s;
|
| 505 |
+
}
|
| 506 |
+
.hm-card:hover {
|
| 507 |
+
transform: translateY(-2px);
|
| 508 |
+
box-shadow: 4px 4px 0 #000;
|
| 509 |
+
}
|
| 510 |
+
.hm-card-head {
|
| 511 |
+
display: flex;
|
| 512 |
+
align-items: center;
|
| 513 |
+
gap: 0.5rem;
|
| 514 |
+
font-family: var(--font-mono);
|
| 515 |
+
font-size: 0.72rem;
|
| 516 |
+
font-weight: 700;
|
| 517 |
+
letter-spacing: 1.5px;
|
| 518 |
+
margin-bottom: 1.25rem;
|
| 519 |
+
padding-bottom: 0.75rem;
|
| 520 |
+
border-bottom: 2px solid #000;
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
/* ── Actions ── */
|
| 524 |
+
.hm-actions-card {
|
| 525 |
+
background: #fafafa;
|
| 526 |
+
}
|
| 527 |
+
.hm-action-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
| 528 |
+
.hm-action-item {
|
| 529 |
+
display: flex;
|
| 530 |
+
align-items: center;
|
| 531 |
+
gap: 0.75rem;
|
| 532 |
+
padding: 0.7rem 0.75rem;
|
| 533 |
+
border: 1.5px solid #e5e5e5;
|
| 534 |
+
text-decoration: none;
|
| 535 |
+
color: #000;
|
| 536 |
+
transition: all 0.15s;
|
| 537 |
+
background: #fff;
|
| 538 |
+
}
|
| 539 |
+
.hm-action-item:hover {
|
| 540 |
+
background: #000;
|
| 541 |
+
color: #fff;
|
| 542 |
+
border-color: #000;
|
| 543 |
+
transform: translateX(3px);
|
| 544 |
+
box-shadow: -3px 3px 0 rgba(0,0,0,0.1);
|
| 545 |
+
}
|
| 546 |
+
.hm-action-icon {
|
| 547 |
+
width: 36px; height: 36px;
|
| 548 |
+
display: flex; align-items: center; justify-content: center;
|
| 549 |
+
background: #000;
|
| 550 |
+
color: #fff;
|
| 551 |
+
flex-shrink: 0;
|
| 552 |
+
transition: background 0.15s;
|
| 553 |
+
}
|
| 554 |
+
.hm-action-item:hover .hm-action-icon { background: #fff; color: #000; }
|
| 555 |
+
.hm-action-text { flex: 1; min-width: 0; }
|
| 556 |
+
.hm-action-label {
|
| 557 |
+
font-family: var(--font-mono);
|
| 558 |
+
font-size: 0.8rem;
|
| 559 |
+
font-weight: 700;
|
| 560 |
+
letter-spacing: 0.5px;
|
| 561 |
+
}
|
| 562 |
+
.hm-action-desc {
|
| 563 |
+
font-size: 0.75rem;
|
| 564 |
+
color: var(--muted-color);
|
| 565 |
+
margin-top: 0.1rem;
|
| 566 |
+
}
|
| 567 |
+
.hm-action-item:hover .hm-action-desc { color: rgba(255,255,255,0.7); }
|
| 568 |
+
.hm-action-arrow { flex-shrink: 0; transition: transform 0.15s; }
|
| 569 |
+
.hm-action-item:hover .hm-action-arrow { transform: translateX(3px); }
|
| 570 |
+
|
| 571 |
+
/* ── Activity ── */
|
| 572 |
+
.hm-activity-grid {
|
| 573 |
+
display: grid;
|
| 574 |
+
grid-template-columns: repeat(3, 1fr);
|
| 575 |
+
gap: 0.75rem;
|
| 576 |
+
}
|
| 577 |
+
.hm-activity-chip {
|
| 578 |
+
border: 1.5px solid #e5e5e5;
|
| 579 |
+
padding: 0.75rem 0.5rem;
|
| 580 |
+
text-align: center;
|
| 581 |
+
}
|
| 582 |
+
.hm-activity-val {
|
| 583 |
+
font-family: var(--font-mono);
|
| 584 |
+
font-size: 1.3rem;
|
| 585 |
+
font-weight: 900;
|
| 586 |
+
line-height: 1;
|
| 587 |
+
margin-bottom: 0.25rem;
|
| 588 |
+
}
|
| 589 |
+
.hm-activity-key {
|
| 590 |
+
font-family: var(--font-mono);
|
| 591 |
+
font-size: 0.58rem;
|
| 592 |
+
color: var(--muted-color);
|
| 593 |
+
letter-spacing: 1px;
|
| 594 |
+
font-weight: 700;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
/* ── Graph card ── */
|
| 598 |
+
.hm-graph-card {}
|
| 599 |
+
.hm-tag {
|
| 600 |
+
display: inline-block;
|
| 601 |
+
background: #f3f4f6;
|
| 602 |
+
border: 1.5px solid #e5e5e5;
|
| 603 |
+
font-family: var(--font-mono);
|
| 604 |
+
font-size: 0.68rem;
|
| 605 |
+
font-weight: 700;
|
| 606 |
+
padding: 2px 8px;
|
| 607 |
+
letter-spacing: 0.5px;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
/* ── Features ── */
|
| 611 |
+
.hm-features-section {
|
| 612 |
+
border-top: 3px solid #000;
|
| 613 |
+
padding-top: 2.5rem;
|
| 614 |
+
margin-bottom: 2rem;
|
| 615 |
+
}
|
| 616 |
+
.hm-features-grid {
|
| 617 |
+
display: grid;
|
| 618 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 619 |
+
gap: 1rem;
|
| 620 |
+
}
|
| 621 |
+
.hm-feature-card {
|
| 622 |
+
border: 2px solid #e5e5e5;
|
| 623 |
+
padding: 1.5rem;
|
| 624 |
+
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
| 625 |
+
position: relative;
|
| 626 |
+
overflow: hidden;
|
| 627 |
+
}
|
| 628 |
+
.hm-feature-card::before {
|
| 629 |
+
content: '';
|
| 630 |
+
position: absolute;
|
| 631 |
+
left: 0; top: 0; bottom: 0;
|
| 632 |
+
width: 3px;
|
| 633 |
+
background: var(--feature-color, #000);
|
| 634 |
+
opacity: 0;
|
| 635 |
+
transition: opacity 0.2s;
|
| 636 |
+
}
|
| 637 |
+
.hm-feature-card:hover {
|
| 638 |
+
border-color: #000;
|
| 639 |
+
box-shadow: 4px 4px 0 #000;
|
| 640 |
+
transform: translateY(-2px);
|
| 641 |
+
}
|
| 642 |
+
.hm-feature-card:hover::before { opacity: 1; }
|
| 643 |
+
.hm-feature-icon {
|
| 644 |
+
margin-bottom: 0.85rem;
|
| 645 |
+
transition: transform 0.2s;
|
| 646 |
+
}
|
| 647 |
+
.hm-feature-card:hover .hm-feature-icon { transform: scale(1.1); }
|
| 648 |
+
.hm-feature-title {
|
| 649 |
+
font-family: var(--font-mono);
|
| 650 |
+
font-size: 0.72rem;
|
| 651 |
+
font-weight: 700;
|
| 652 |
+
letter-spacing: 1px;
|
| 653 |
+
margin-bottom: 0.5rem;
|
| 654 |
+
}
|
| 655 |
+
.hm-feature-desc {
|
| 656 |
+
font-size: 0.82rem;
|
| 657 |
+
color: var(--muted-color);
|
| 658 |
+
line-height: 1.65;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
/* ── Footer ── */
|
| 662 |
+
.hm-footer-bar {
|
| 663 |
+
display: flex;
|
| 664 |
+
align-items: center;
|
| 665 |
+
gap: 1.5rem;
|
| 666 |
+
border-top: 1px solid #e5e5e5;
|
| 667 |
+
padding-top: 1.25rem;
|
| 668 |
+
margin-top: 1rem;
|
| 669 |
+
}
|
| 670 |
+
.hm-footer-brand {
|
| 671 |
+
font-family: var(--font-mono);
|
| 672 |
+
font-size: 0.72rem;
|
| 673 |
+
font-weight: 700;
|
| 674 |
+
letter-spacing: 2px;
|
| 675 |
+
margin-right: auto;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
/* ── Responsive ── */
|
| 679 |
+
@media (max-width: 900px) {
|
| 680 |
+
.hm-hero { grid-template-columns: 1fr; }
|
| 681 |
+
.hm-hero-right { order: -1; }
|
| 682 |
+
.hm-main-grid { grid-template-columns: 1fr; }
|
| 683 |
+
.hm-metrics-row { flex-wrap: wrap; }
|
| 684 |
+
.hm-metric-divider { display: none; }
|
| 685 |
+
.hm-stat-block { flex: 0 0 50%; border-bottom: 1px solid #000; }
|
| 686 |
+
}
|
| 687 |
+
`}</style>
|
| 688 |
+
</div>
|
| 689 |
+
);
|
| 690 |
+
};
|
| 691 |
+
|
| 692 |
+
export default Home;
|
frontend-react/src/views/InsightsView.tsx
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import { BarChart2, TrendingUp, AlertTriangle, CheckCircle, RefreshCw, Zap, FileText, GitCommit, MessageSquare } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
|
| 6 |
+
|
| 7 |
+
interface EvalDashboard {
|
| 8 |
+
total_evaluations: number;
|
| 9 |
+
avg_overall_score: number;
|
| 10 |
+
avg_faithfulness: number;
|
| 11 |
+
avg_relevancy: number;
|
| 12 |
+
hallucination_rate: number;
|
| 13 |
+
trend_data: TrendPoint[];
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface TrendPoint {
|
| 17 |
+
timestamp: string;
|
| 18 |
+
overall_score: number;
|
| 19 |
+
faithfulness: number;
|
| 20 |
+
answer_relevancy: number;
|
| 21 |
+
hallucination_detected: boolean;
|
| 22 |
+
document_id?: string;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
interface EvalForm {
|
| 26 |
+
question: string;
|
| 27 |
+
answer: string;
|
| 28 |
+
contexts: string;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// Helper: build markdown from report result
|
| 32 |
+
function buildReportMarkdown(result: any, topic: string): string {
|
| 33 |
+
const lines: string[] = [];
|
| 34 |
+
lines.push(`# ${result.topic || topic}`);
|
| 35 |
+
lines.push('');
|
| 36 |
+
if (result.confidence !== undefined) {
|
| 37 |
+
lines.push(`**Confidence:** ${(result.confidence * 100).toFixed(1)}%`);
|
| 38 |
+
}
|
| 39 |
+
if (result.tool_calls_made !== undefined) {
|
| 40 |
+
lines.push(`**Tool calls:** ${result.tool_calls_made}`);
|
| 41 |
+
}
|
| 42 |
+
lines.push(`**Generated:** ${new Date().toLocaleString()}`);
|
| 43 |
+
lines.push('');
|
| 44 |
+
|
| 45 |
+
if (result.executive_summary) {
|
| 46 |
+
lines.push('## Executive Summary');
|
| 47 |
+
lines.push('');
|
| 48 |
+
lines.push(result.executive_summary);
|
| 49 |
+
lines.push('');
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (result.sections && typeof result.sections === 'object' && !Array.isArray(result.sections)) {
|
| 53 |
+
Object.entries(result.sections).forEach(([title, content], i) => {
|
| 54 |
+
lines.push(`## ${i + 1}. ${title}`);
|
| 55 |
+
lines.push('');
|
| 56 |
+
lines.push(String(content));
|
| 57 |
+
lines.push('');
|
| 58 |
+
});
|
| 59 |
+
} else if (Array.isArray(result.sections)) {
|
| 60 |
+
result.sections.forEach((s: any, i: number) => {
|
| 61 |
+
lines.push(`## ${i + 1}. ${s.title || ''}`);
|
| 62 |
+
lines.push('');
|
| 63 |
+
lines.push(s.content || '');
|
| 64 |
+
lines.push('');
|
| 65 |
+
});
|
| 66 |
+
} else if (!result.sections) {
|
| 67 |
+
lines.push(result.report || result.content || result.markdown || JSON.stringify(result, null, 2));
|
| 68 |
+
lines.push('');
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
if (result.key_entities && result.key_entities.length > 0) {
|
| 72 |
+
lines.push('## Key Entities');
|
| 73 |
+
lines.push('');
|
| 74 |
+
lines.push(result.key_entities.join(', '));
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
return lines.join('\n');
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const InsightsView: React.FC = () => {
|
| 81 |
+
const { token } = useAuth();
|
| 82 |
+
const [dashboard, setDashboard] = useState<EvalDashboard | null>(null);
|
| 83 |
+
const [loading, setLoading] = useState(false);
|
| 84 |
+
const [evalForm, setEvalForm] = useState<EvalForm>({ question: '', answer: '', contexts: '' });
|
| 85 |
+
const [evalResult, setEvalResult] = useState<any>(null);
|
| 86 |
+
const [evalLoading, setEvalLoading] = useState(false);
|
| 87 |
+
const [communities, setCommunities] = useState<any[]>([]);
|
| 88 |
+
const [communityLoading, setCommunityLoading] = useState(false);
|
| 89 |
+
const [activeTab, setActiveTab] = useState<'metrics' | 'evaluate' | 'communities' | 'export' | 'report' | 'graph-update' | 'entity-chat'>('metrics');
|
| 90 |
+
|
| 91 |
+
// Report Agent state
|
| 92 |
+
const [reportTopic, setReportTopic] = useState('');
|
| 93 |
+
const [reportDepth, setReportDepth] = useState(3);
|
| 94 |
+
const [reportResult, setReportResult] = useState<any>(null);
|
| 95 |
+
const [reportLoading, setReportLoading] = useState(false);
|
| 96 |
+
const [reportTimestamp, setReportTimestamp] = useState<Date | null>(null);
|
| 97 |
+
const [copyDone, setCopyDone] = useState(false);
|
| 98 |
+
|
| 99 |
+
// Graph Update state
|
| 100 |
+
const [updateText, setUpdateText] = useState('');
|
| 101 |
+
const [updateResult, setUpdateResult] = useState<any>(null);
|
| 102 |
+
const [updateLoading, setUpdateLoading] = useState(false);
|
| 103 |
+
|
| 104 |
+
// Entity Chat state
|
| 105 |
+
const [entities, setEntities] = useState<any[]>([]);
|
| 106 |
+
const [selectedEntity, setSelectedEntity] = useState('');
|
| 107 |
+
const [entityContext, setEntityContext] = useState('');
|
| 108 |
+
const [chatMsg, setChatMsg] = useState('');
|
| 109 |
+
const [chatHistory, setChatHistory] = useState<{role: string; content: string}[]>([]);
|
| 110 |
+
const [chatLoading, setChatLoading] = useState(false);
|
| 111 |
+
|
| 112 |
+
const fetchDashboard = useCallback(async () => {
|
| 113 |
+
setLoading(true);
|
| 114 |
+
try {
|
| 115 |
+
const res = await fetch(`${API_BASE}/eval/dashboard?limit=200`, {
|
| 116 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 117 |
+
});
|
| 118 |
+
if (res.ok) setDashboard(await res.json());
|
| 119 |
+
} catch (e) { console.error(e); }
|
| 120 |
+
setLoading(false);
|
| 121 |
+
}, [token]);
|
| 122 |
+
|
| 123 |
+
const fetchCommunities = useCallback(async () => {
|
| 124 |
+
setCommunityLoading(true);
|
| 125 |
+
try {
|
| 126 |
+
const res = await fetch(`${API_BASE}/graph/communities?limit=30`, {
|
| 127 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 128 |
+
});
|
| 129 |
+
if (res.ok) {
|
| 130 |
+
const data = await res.json();
|
| 131 |
+
setCommunities(data.communities || []);
|
| 132 |
+
}
|
| 133 |
+
} catch (e) { console.error(e); }
|
| 134 |
+
setCommunityLoading(false);
|
| 135 |
+
}, [token]);
|
| 136 |
+
|
| 137 |
+
const fetchEntities = useCallback(async () => {
|
| 138 |
+
try {
|
| 139 |
+
const res = await fetch(`${API_BASE}/admin/graph/nodes?query=&limit=200`, {
|
| 140 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 141 |
+
});
|
| 142 |
+
if (res.ok) {
|
| 143 |
+
const data = await res.json();
|
| 144 |
+
setEntities((data.nodes || []).slice(0, 100));
|
| 145 |
+
}
|
| 146 |
+
} catch {}
|
| 147 |
+
}, [token]);
|
| 148 |
+
|
| 149 |
+
useEffect(() => {
|
| 150 |
+
fetchDashboard();
|
| 151 |
+
fetchCommunities();
|
| 152 |
+
fetchEntities();
|
| 153 |
+
}, [fetchDashboard, fetchCommunities, fetchEntities]);
|
| 154 |
+
|
| 155 |
+
const runEval = async () => {
|
| 156 |
+
if (!evalForm.question || !evalForm.answer || !evalForm.contexts) return;
|
| 157 |
+
setEvalLoading(true);
|
| 158 |
+
setEvalResult(null);
|
| 159 |
+
try {
|
| 160 |
+
const res = await fetch(`${API_BASE}/eval/score`, {
|
| 161 |
+
method: 'POST',
|
| 162 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 163 |
+
body: JSON.stringify({
|
| 164 |
+
question: evalForm.question,
|
| 165 |
+
answer: evalForm.answer,
|
| 166 |
+
contexts: evalForm.contexts.split('\n---\n').map(c => c.trim()).filter(Boolean)
|
| 167 |
+
})
|
| 168 |
+
});
|
| 169 |
+
if (res.ok) {
|
| 170 |
+
setEvalResult(await res.json());
|
| 171 |
+
fetchDashboard(); // Refresh metrics
|
| 172 |
+
}
|
| 173 |
+
} catch (e) { console.error(e); }
|
| 174 |
+
setEvalLoading(false);
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
const runReport = async () => {
|
| 178 |
+
if (!reportTopic.trim()) return;
|
| 179 |
+
setReportLoading(true); setReportResult(null);
|
| 180 |
+
try {
|
| 181 |
+
const res = await fetch(`${API_BASE}/report`, {
|
| 182 |
+
method: 'POST',
|
| 183 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 184 |
+
body: JSON.stringify({ topic: reportTopic, depth: reportDepth })
|
| 185 |
+
});
|
| 186 |
+
if (res.ok) {
|
| 187 |
+
setReportResult(await res.json());
|
| 188 |
+
setReportTimestamp(new Date());
|
| 189 |
+
}
|
| 190 |
+
} catch (e) { console.error(e); }
|
| 191 |
+
setReportLoading(false);
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
const runGraphUpdate = async () => {
|
| 195 |
+
if (!updateText.trim()) return;
|
| 196 |
+
setUpdateLoading(true); setUpdateResult(null);
|
| 197 |
+
try {
|
| 198 |
+
const res = await fetch(`${API_BASE}/graph/update`, {
|
| 199 |
+
method: 'POST',
|
| 200 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 201 |
+
body: JSON.stringify({ text: updateText })
|
| 202 |
+
});
|
| 203 |
+
if (res.ok) setUpdateResult(await res.json());
|
| 204 |
+
} catch (e) { console.error(e); }
|
| 205 |
+
setUpdateLoading(false);
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
const sendEntityChat = async () => {
|
| 209 |
+
if (!selectedEntity || !chatMsg.trim()) return;
|
| 210 |
+
const userMsg = { role: 'user', content: chatMsg };
|
| 211 |
+
setChatHistory(h => [...h, userMsg]);
|
| 212 |
+
setChatMsg('');
|
| 213 |
+
setChatLoading(true);
|
| 214 |
+
try {
|
| 215 |
+
const res = await fetch(`${API_BASE}/entities/${encodeURIComponent(selectedEntity)}/chat`, {
|
| 216 |
+
method: 'POST',
|
| 217 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 218 |
+
body: JSON.stringify({ message: userMsg.content })
|
| 219 |
+
});
|
| 220 |
+
if (res.ok) {
|
| 221 |
+
const data = await res.json();
|
| 222 |
+
setChatHistory(h => [...h, { role: 'assistant', content: data.response || data.answer || JSON.stringify(data) }]);
|
| 223 |
+
}
|
| 224 |
+
} catch (e) { console.error(e); }
|
| 225 |
+
setChatLoading(false);
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
const assignCommunities = async () => {
|
| 229 |
+
setCommunityLoading(true);
|
| 230 |
+
try {
|
| 231 |
+
const res = await fetch(`${API_BASE}/graph/communities/assign`, {
|
| 232 |
+
method: 'POST',
|
| 233 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 234 |
+
});
|
| 235 |
+
if (res.ok) {
|
| 236 |
+
const data = await res.json();
|
| 237 |
+
alert(`✓ ${data.message}`);
|
| 238 |
+
fetchCommunities();
|
| 239 |
+
}
|
| 240 |
+
} catch (e) { console.error(e); }
|
| 241 |
+
setCommunityLoading(false);
|
| 242 |
+
};
|
| 243 |
+
|
| 244 |
+
const exportGraph = async (fmt: string) => {
|
| 245 |
+
const res = await fetch(`${API_BASE}/graph/export?format=${fmt}`, {
|
| 246 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 247 |
+
});
|
| 248 |
+
if (!res.ok) return;
|
| 249 |
+
const blob = await res.blob();
|
| 250 |
+
const ext = fmt === 'json' ? 'json' : fmt === 'cypher' ? 'cypher' : 'graphml';
|
| 251 |
+
const url = URL.createObjectURL(blob);
|
| 252 |
+
const a = document.createElement('a');
|
| 253 |
+
a.href = url; a.download = `graph_export.${ext}`; a.click();
|
| 254 |
+
URL.revokeObjectURL(url);
|
| 255 |
+
};
|
| 256 |
+
|
| 257 |
+
const handleCopyReport = () => {
|
| 258 |
+
if (!reportResult) return;
|
| 259 |
+
const text = buildReportMarkdown(reportResult, reportTopic);
|
| 260 |
+
navigator.clipboard.writeText(text).then(() => {
|
| 261 |
+
setCopyDone(true);
|
| 262 |
+
setTimeout(() => setCopyDone(false), 2000);
|
| 263 |
+
});
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
const handleDownloadReport = () => {
|
| 267 |
+
if (!reportResult) return;
|
| 268 |
+
const text = buildReportMarkdown(reportResult, reportTopic);
|
| 269 |
+
const blob = new Blob([text], { type: 'text/markdown' });
|
| 270 |
+
const url = URL.createObjectURL(blob);
|
| 271 |
+
const a = document.createElement('a');
|
| 272 |
+
a.href = url;
|
| 273 |
+
a.download = `report_${reportTopic.slice(0, 30).replace(/\s+/g, '_')}.md`;
|
| 274 |
+
a.click();
|
| 275 |
+
URL.revokeObjectURL(url);
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
const score2color = (s: number) => {
|
| 279 |
+
if (s >= 0.8) return '#16a34a';
|
| 280 |
+
if (s >= 0.6) return '#d97706';
|
| 281 |
+
return '#dc2626';
|
| 282 |
+
};
|
| 283 |
+
const score2label = (s: number) => s >= 0.8 ? 'HIGH' : s >= 0.6 ? 'MEDIUM' : 'LOW';
|
| 284 |
+
|
| 285 |
+
const ScoreBar: React.FC<{ label: string; value: number }> = ({ label, value }) => (
|
| 286 |
+
<div style={{ marginBottom: '1rem' }}>
|
| 287 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
|
| 288 |
+
<span>{label}</span>
|
| 289 |
+
<span style={{ color: score2color(value), fontWeight: 700 }}>{(value * 100).toFixed(1)}%</span>
|
| 290 |
+
</div>
|
| 291 |
+
<div style={{ height: '8px', background: '#e5e7eb', position: 'relative' }}>
|
| 292 |
+
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: `${value * 100}%`, background: score2color(value), transition: 'width 0.6s ease' }} />
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
);
|
| 296 |
+
|
| 297 |
+
const confidenceBadgeColor = (c: number) =>
|
| 298 |
+
c >= 0.8 ? '#16a34a' : c >= 0.6 ? '#d97706' : '#dc2626';
|
| 299 |
+
|
| 300 |
+
return (
|
| 301 |
+
<div className="container" style={{ maxWidth: '1100px', paddingBottom: '3rem' }}>
|
| 302 |
+
<div className="page-header flex-between" style={{ marginBottom: '2rem' }}>
|
| 303 |
+
<div>
|
| 304 |
+
<h1>INSIGHTS HQ</h1>
|
| 305 |
+
<p className="mono-text">QUALITY METRICS // EVAL DASHBOARD // GRAPH INTELLIGENCE</p>
|
| 306 |
+
</div>
|
| 307 |
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
| 308 |
+
<button className="app-btn mono-text" onClick={fetchDashboard} disabled={loading} style={{ padding: '0.5rem 1rem', fontSize: '0.8rem' }}>
|
| 309 |
+
<RefreshCw size={14} style={{ display: 'inline', marginRight: '0.4rem' }} />
|
| 310 |
+
REFRESH
|
| 311 |
+
</button>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
{/* Tab Nav */}
|
| 316 |
+
<div style={{ display: 'flex', borderBottom: '3px solid #000', marginBottom: '2rem', gap: 0, flexWrap: 'wrap' }}>
|
| 317 |
+
{([
|
| 318 |
+
{ id: 'metrics', label: 'METRICS' },
|
| 319 |
+
{ id: 'evaluate', label: 'EVALUATE' },
|
| 320 |
+
{ id: 'communities', label: 'COMMUNITIES' },
|
| 321 |
+
{ id: 'export', label: 'EXPORT' },
|
| 322 |
+
{ id: 'report', label: '⚡ REPORT' },
|
| 323 |
+
{ id: 'graph-update', label: '↑ LIVE UPDATE' },
|
| 324 |
+
{ id: 'entity-chat', label: '💬 ENTITY CHAT' },
|
| 325 |
+
] as const).map(t => (
|
| 326 |
+
<button
|
| 327 |
+
key={t.id}
|
| 328 |
+
onClick={() => setActiveTab(t.id as any)}
|
| 329 |
+
className="mono-text"
|
| 330 |
+
style={{
|
| 331 |
+
padding: '0.65rem 1.2rem', fontWeight: 700, fontSize: '0.78rem', letterSpacing: '0.8px',
|
| 332 |
+
border: 'none', borderBottom: activeTab === t.id ? '3px solid #000' : 'none',
|
| 333 |
+
background: activeTab === t.id ? '#000' : 'transparent',
|
| 334 |
+
color: activeTab === t.id ? '#fff' : '#000', cursor: 'pointer',
|
| 335 |
+
marginBottom: '-3px', textTransform: 'uppercase', whiteSpace: 'nowrap'
|
| 336 |
+
}}
|
| 337 |
+
>
|
| 338 |
+
{t.label}
|
| 339 |
+
</button>
|
| 340 |
+
))}
|
| 341 |
+
</div>
|
| 342 |
+
|
| 343 |
+
{/* ── METRICS TAB ── */}
|
| 344 |
+
{activeTab === 'metrics' && (
|
| 345 |
+
<div>
|
| 346 |
+
{loading ? (
|
| 347 |
+
<div className="mono-text" style={{ textAlign: 'center', padding: '3rem', color: '#aaa' }}>LOADING METRICS...</div>
|
| 348 |
+
) : !dashboard || dashboard.total_evaluations === 0 ? (
|
| 349 |
+
<div style={{ border: '3px solid #000', padding: '3rem', textAlign: 'center' }}>
|
| 350 |
+
<BarChart2 size={48} style={{ marginBottom: '1rem', opacity: 0.3 }} />
|
| 351 |
+
<p className="mono-text" style={{ color: '#777' }}>NO EVALUATION DATA YET</p>
|
| 352 |
+
<p className="mono-text" style={{ color: '#999', fontSize: '0.85rem', marginTop: '0.5rem' }}>
|
| 353 |
+
Use the EVALUATE tab to score Q&A pairs and build your quality history.
|
| 354 |
+
</p>
|
| 355 |
+
</div>
|
| 356 |
+
) : (
|
| 357 |
+
<>
|
| 358 |
+
{/* KPI Cards */}
|
| 359 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
|
| 360 |
+
{[
|
| 361 |
+
{ label: 'TOTAL EVALS', value: dashboard.total_evaluations, raw: true, icon: <BarChart2 size={20} /> },
|
| 362 |
+
{ label: 'AVG QUALITY', value: dashboard.avg_overall_score, icon: <TrendingUp size={20} /> },
|
| 363 |
+
{ label: 'FAITHFULNESS', value: dashboard.avg_faithfulness, icon: <CheckCircle size={20} /> },
|
| 364 |
+
{ label: 'HALLUCINATION RATE', value: dashboard.hallucination_rate, invert: true, icon: <AlertTriangle size={20} /> },
|
| 365 |
+
].map(card => (
|
| 366 |
+
<div key={card.label} style={{ border: '3px solid #000', padding: '1.5rem', background: (card.invert ? dashboard.hallucination_rate > 0.3 : false) ? '#fff5f5' : '#fff' }}>
|
| 367 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
|
| 368 |
+
<span className="mono-text" style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '1px', color: '#666' }}>{card.label}</span>
|
| 369 |
+
{card.icon}
|
| 370 |
+
</div>
|
| 371 |
+
<div className="mono-text" style={{
|
| 372 |
+
fontSize: '2rem', fontWeight: 900,
|
| 373 |
+
color: card.raw ? '#000' : score2color(card.invert ? 1 - (card.value as number) : (card.value as number))
|
| 374 |
+
}}>
|
| 375 |
+
{card.raw ? card.value : `${((card.value as number) * 100).toFixed(1)}%`}
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
))}
|
| 379 |
+
</div>
|
| 380 |
+
|
| 381 |
+
{/* Score Breakdown */}
|
| 382 |
+
<div style={{ border: '3px solid #000', padding: '1.5rem', marginBottom: '2rem' }}>
|
| 383 |
+
<h3 className="mono-text" style={{ marginBottom: '1.5rem', borderBottom: '1px dotted #000', paddingBottom: '0.5rem' }}>METRIC BREAKDOWN</h3>
|
| 384 |
+
<ScoreBar label="Overall Quality Score" value={dashboard.avg_overall_score} />
|
| 385 |
+
<ScoreBar label="Faithfulness (grounding)" value={dashboard.avg_faithfulness} />
|
| 386 |
+
<ScoreBar label="Answer Relevancy" value={dashboard.avg_relevancy} />
|
| 387 |
+
<ScoreBar label="Non-hallucination Rate" value={1 - dashboard.hallucination_rate} />
|
| 388 |
+
</div>
|
| 389 |
+
|
| 390 |
+
{/* Trend Table */}
|
| 391 |
+
{dashboard.trend_data.length > 0 && (
|
| 392 |
+
<div style={{ border: '3px solid #000', padding: '1.5rem' }}>
|
| 393 |
+
<h3 className="mono-text" style={{ marginBottom: '1.5rem', borderBottom: '1px dotted #000', paddingBottom: '0.5rem' }}>EVALUATION HISTORY (LATEST {Math.min(dashboard.trend_data.length, 20)})</h3>
|
| 394 |
+
<div style={{ overflowX: 'auto' }}>
|
| 395 |
+
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
|
| 396 |
+
<thead>
|
| 397 |
+
<tr style={{ borderBottom: '2px solid #000' }}>
|
| 398 |
+
{['TIMESTAMP', 'QUALITY', 'FAITHFULNESS', 'RELEVANCY', 'HALLUCINATION'].map(h => (
|
| 399 |
+
<th key={h} style={{ padding: '0.5rem', textAlign: 'left', fontWeight: 700 }}>{h}</th>
|
| 400 |
+
))}
|
| 401 |
+
</tr>
|
| 402 |
+
</thead>
|
| 403 |
+
<tbody>
|
| 404 |
+
{dashboard.trend_data.slice(0, 20).map((p, i) => (
|
| 405 |
+
<tr key={i} style={{ borderBottom: '1px dotted #ddd', background: i % 2 === 0 ? '#fafafa' : '#fff' }}>
|
| 406 |
+
<td style={{ padding: '0.5rem' }}>{p.timestamp}</td>
|
| 407 |
+
<td style={{ padding: '0.5rem', color: score2color(p.overall_score), fontWeight: 700 }}>{(p.overall_score * 100).toFixed(1)}%</td>
|
| 408 |
+
<td style={{ padding: '0.5rem', color: score2color(p.faithfulness) }}>{(p.faithfulness * 100).toFixed(1)}%</td>
|
| 409 |
+
<td style={{ padding: '0.5rem', color: score2color(p.answer_relevancy) }}>{(p.answer_relevancy * 100).toFixed(1)}%</td>
|
| 410 |
+
<td style={{ padding: '0.5rem' }}>
|
| 411 |
+
<span style={{ background: p.hallucination_detected ? '#dc2626' : '#16a34a', color: '#fff', padding: '0.1rem 0.5rem', fontSize: '0.7rem', fontWeight: 700 }}>
|
| 412 |
+
{p.hallucination_detected ? 'YES' : 'NO'}
|
| 413 |
+
</span>
|
| 414 |
+
</td>
|
| 415 |
+
</tr>
|
| 416 |
+
))}
|
| 417 |
+
</tbody>
|
| 418 |
+
</table>
|
| 419 |
+
</div>
|
| 420 |
+
</div>
|
| 421 |
+
)}
|
| 422 |
+
</>
|
| 423 |
+
)}
|
| 424 |
+
</div>
|
| 425 |
+
)}
|
| 426 |
+
|
| 427 |
+
{/* ── EVALUATE TAB ── */}
|
| 428 |
+
{activeTab === 'evaluate' && (
|
| 429 |
+
<div>
|
| 430 |
+
<div style={{ border: '3px solid #000', padding: '2rem', marginBottom: '2rem' }}>
|
| 431 |
+
<h3 className="mono-text" style={{ marginBottom: '1.5rem' }}>
|
| 432 |
+
<Zap size={16} style={{ display: 'inline', marginRight: '0.5rem' }} />
|
| 433 |
+
SCORE A Q&A PAIR
|
| 434 |
+
</h3>
|
| 435 |
+
<p className="mono-text" style={{ fontSize: '0.8rem', color: '#666', marginBottom: '1.5rem' }}>
|
| 436 |
+
Paste a question, its generated answer, and the retrieved context chunks (separate chunks with "---").
|
| 437 |
+
</p>
|
| 438 |
+
|
| 439 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
| 440 |
+
<div>
|
| 441 |
+
<label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>QUESTION</label>
|
| 442 |
+
<textarea value={evalForm.question} onChange={e => setEvalForm(f => ({ ...f, question: e.target.value }))}
|
| 443 |
+
rows={2} style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
|
| 444 |
+
</div>
|
| 445 |
+
<div>
|
| 446 |
+
<label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>GENERATED ANSWER</label>
|
| 447 |
+
<textarea value={evalForm.answer} onChange={e => setEvalForm(f => ({ ...f, answer: e.target.value }))}
|
| 448 |
+
rows={4} style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
|
| 449 |
+
</div>
|
| 450 |
+
<div>
|
| 451 |
+
<label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>CONTEXT CHUNKS (separate with "---" on its own line)</label>
|
| 452 |
+
<textarea value={evalForm.contexts} onChange={e => setEvalForm(f => ({ ...f, contexts: e.target.value }))}
|
| 453 |
+
rows={6} placeholder={'Context chunk 1 text...\n---\nContext chunk 2 text...'} style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.85rem', resize: 'vertical', boxSizing: 'border-box' }} />
|
| 454 |
+
</div>
|
| 455 |
+
<button className="app-btn mono-text" onClick={runEval} disabled={evalLoading || !evalForm.question || !evalForm.answer || !evalForm.contexts}
|
| 456 |
+
style={{ alignSelf: 'flex-start', padding: '0.75rem 2rem' }}>
|
| 457 |
+
{evalLoading ? 'EVALUATING...' : 'RUN EVALUATION'}
|
| 458 |
+
</button>
|
| 459 |
+
</div>
|
| 460 |
+
</div>
|
| 461 |
+
|
| 462 |
+
{evalResult && (
|
| 463 |
+
<div style={{ border: '3px solid #000', padding: '2rem', background: evalResult.hallucination_detected ? '#fff5f5' : '#f0fdf4' }}>
|
| 464 |
+
<h3 className="mono-text" style={{ marginBottom: '1.5rem' }}>
|
| 465 |
+
{evalResult.hallucination_detected
|
| 466 |
+
? <><AlertTriangle size={16} style={{ display: 'inline', marginRight: '0.5rem', color: '#dc2626' }} />HALLUCINATION RISK DETECTED</>
|
| 467 |
+
: <><CheckCircle size={16} style={{ display: 'inline', marginRight: '0.5rem', color: '#16a34a' }} />EVALUATION RESULTS</>
|
| 468 |
+
}
|
| 469 |
+
</h3>
|
| 470 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem', marginBottom: '1.5rem' }}>
|
| 471 |
+
{[
|
| 472 |
+
{ label: 'OVERALL', value: evalResult.overall_score },
|
| 473 |
+
{ label: 'FAITHFULNESS', value: evalResult.faithfulness },
|
| 474 |
+
{ label: 'RELEVANCY', value: evalResult.answer_relevancy },
|
| 475 |
+
{ label: 'PRECISION', value: evalResult.context_precision },
|
| 476 |
+
].map(m => (
|
| 477 |
+
<div key={m.label} style={{ border: `2px solid ${score2color(m.value)}`, padding: '1rem', textAlign: 'center' }}>
|
| 478 |
+
<div className="mono-text" style={{ fontSize: '0.7rem', fontWeight: 700, color: '#666', marginBottom: '0.5rem' }}>{m.label}</div>
|
| 479 |
+
<div className="mono-text" style={{ fontSize: '1.8rem', fontWeight: 900, color: score2color(m.value) }}>
|
| 480 |
+
{(m.value * 100).toFixed(0)}%
|
| 481 |
+
</div>
|
| 482 |
+
<div className="mono-text" style={{ fontSize: '0.65rem', color: score2color(m.value), marginTop: '0.25rem' }}>{score2label(m.value)}</div>
|
| 483 |
+
</div>
|
| 484 |
+
))}
|
| 485 |
+
</div>
|
| 486 |
+
<div className="mono-text" style={{ fontSize: '0.75rem', color: '#777', borderTop: '1px dotted #ccc', paddingTop: '0.75rem' }}>
|
| 487 |
+
Saved to Neo4j for trending. Eval ID: {evalResult.eval_id || 'N/A'}
|
| 488 |
+
</div>
|
| 489 |
+
</div>
|
| 490 |
+
)}
|
| 491 |
+
</div>
|
| 492 |
+
)}
|
| 493 |
+
|
| 494 |
+
{/* ── COMMUNITIES TAB ── */}
|
| 495 |
+
{activeTab === 'communities' && (
|
| 496 |
+
<div>
|
| 497 |
+
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', alignItems: 'center' }}>
|
| 498 |
+
<button className="app-btn mono-text" onClick={assignCommunities} disabled={communityLoading} style={{ padding: '0.75rem 1.5rem' }}>
|
| 499 |
+
{communityLoading ? 'DETECTING...' : '⚡ DETECT COMMUNITIES'}
|
| 500 |
+
</button>
|
| 501 |
+
<span className="mono-text" style={{ fontSize: '0.8rem', color: '#666' }}>
|
| 502 |
+
Run after ingesting documents to cluster entities into related groups (enables community search).
|
| 503 |
+
</span>
|
| 504 |
+
</div>
|
| 505 |
+
|
| 506 |
+
{communities.length === 0 ? (
|
| 507 |
+
<div style={{ border: '3px solid #000', padding: '3rem', textAlign: 'center' }}>
|
| 508 |
+
<p className="mono-text" style={{ color: '#777' }}>NO COMMUNITIES DETECTED YET</p>
|
| 509 |
+
<p className="mono-text" style={{ color: '#999', fontSize: '0.85rem', marginTop: '0.5rem' }}>Click "Detect Communities" above to cluster your knowledge graph entities.</p>
|
| 510 |
+
</div>
|
| 511 |
+
) : (
|
| 512 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1rem' }}>
|
| 513 |
+
{communities.map((c, i) => (
|
| 514 |
+
<div key={i} style={{ border: '2px solid #000', padding: '1.25rem' }}>
|
| 515 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
|
| 516 |
+
<span className="mono-text" style={{ fontWeight: 700, fontSize: '0.85rem' }}>CLUSTER #{c.community_id}</span>
|
| 517 |
+
<span className="mono-text" style={{ fontSize: '0.75rem', background: '#000', color: '#fff', padding: '0.15rem 0.5rem' }}>
|
| 518 |
+
{c.entity_count} entities
|
| 519 |
+
</span>
|
| 520 |
+
</div>
|
| 521 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
| 522 |
+
{(c.sample_entities || []).map((e: string, ei: number) => (
|
| 523 |
+
<span key={ei} style={{ background: '#f3f4f6', border: '1px solid #d1d5db', padding: '0.15rem 0.5rem', fontSize: '0.75rem', fontFamily: 'var(--font-mono)' }}>{e}</span>
|
| 524 |
+
))}
|
| 525 |
+
{c.entity_count > (c.sample_entities || []).length && (
|
| 526 |
+
<span style={{ color: '#666', fontSize: '0.75rem', fontFamily: 'var(--font-mono)', padding: '0.15rem 0' }}>
|
| 527 |
+
+{c.entity_count - (c.sample_entities || []).length} more
|
| 528 |
+
</span>
|
| 529 |
+
)}
|
| 530 |
+
</div>
|
| 531 |
+
</div>
|
| 532 |
+
))}
|
| 533 |
+
</div>
|
| 534 |
+
)}
|
| 535 |
+
</div>
|
| 536 |
+
)}
|
| 537 |
+
|
| 538 |
+
{/* ── EXPORT TAB ── */}
|
| 539 |
+
{activeTab === 'export' && (
|
| 540 |
+
<div>
|
| 541 |
+
<div style={{ border: '3px solid #000', padding: '2rem', marginBottom: '2rem' }}>
|
| 542 |
+
<h3 className="mono-text" style={{ marginBottom: '1.5rem' }}>EXPORT KNOWLEDGE GRAPH</h3>
|
| 543 |
+
<p className="mono-text" style={{ fontSize: '0.85rem', color: '#555', marginBottom: '2rem', lineHeight: '1.6' }}>
|
| 544 |
+
Export your knowledge graph for use in external tools, backups, or further analysis.
|
| 545 |
+
</p>
|
| 546 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1rem' }}>
|
| 547 |
+
{[
|
| 548 |
+
{ fmt: 'json', label: 'JSON', desc: 'Nodes & edges as JSON. For custom processing or visualization.' },
|
| 549 |
+
{ fmt: 'cypher', label: 'CYPHER', desc: 'Cypher CREATE statements. Re-import to any Neo4j instance.' },
|
| 550 |
+
{ fmt: 'graphml', label: 'GRAPHML', desc: 'GraphML XML. Compatible with Gephi, yEd, and most graph tools.' },
|
| 551 |
+
].map(e => (
|
| 552 |
+
<div key={e.fmt} style={{ border: '2px solid #000', padding: '1.5rem' }}>
|
| 553 |
+
<div className="mono-text" style={{ fontWeight: 900, fontSize: '1.1rem', marginBottom: '0.5rem' }}>.{e.label}</div>
|
| 554 |
+
<p style={{ fontSize: '0.82rem', color: '#555', marginBottom: '1.25rem', fontFamily: 'var(--font-sans)', lineHeight: '1.5' }}>{e.desc}</p>
|
| 555 |
+
<button className="app-btn mono-text" onClick={() => exportGraph(e.fmt)} style={{ width: '100%', textAlign: 'center' }}>
|
| 556 |
+
DOWNLOAD .{e.label}
|
| 557 |
+
</button>
|
| 558 |
+
</div>
|
| 559 |
+
))}
|
| 560 |
+
</div>
|
| 561 |
+
</div>
|
| 562 |
+
</div>
|
| 563 |
+
)}
|
| 564 |
+
|
| 565 |
+
{/* ── REPORT AGENT TAB ── */}
|
| 566 |
+
{activeTab === 'report' && (
|
| 567 |
+
<div>
|
| 568 |
+
<div className="page-info-bar">
|
| 569 |
+
<FileText size={14}/>
|
| 570 |
+
<span><strong>REPORT AGENT</strong> — Enter any topic or question. The ReACT agent autonomously queries the knowledge graph using multiple retrieval strategies and synthesizes a deep multi-section report. More <strong>depth</strong> = more reasoning steps.</span>
|
| 571 |
+
</div>
|
| 572 |
+
|
| 573 |
+
{/* Input form */}
|
| 574 |
+
<div style={{ border: '3px solid #000', padding: '2rem', marginBottom: '2rem' }}>
|
| 575 |
+
<h3 className="mono-text" style={{ marginBottom: '1.5rem' }}>
|
| 576 |
+
<Zap size={16} style={{ display: 'inline', marginRight: '0.5rem' }}/>GENERATE ANALYTICAL REPORT
|
| 577 |
+
</h3>
|
| 578 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
| 579 |
+
<div>
|
| 580 |
+
<label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>TOPIC OR QUESTION</label>
|
| 581 |
+
<textarea value={reportTopic} onChange={e => setReportTopic(e.target.value)}
|
| 582 |
+
rows={3} placeholder="e.g. 'Summarize all relationships between Company X and its investors'"
|
| 583 |
+
style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }}/>
|
| 584 |
+
</div>
|
| 585 |
+
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
| 586 |
+
<div>
|
| 587 |
+
<label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>DEPTH (REASONING STEPS)</label>
|
| 588 |
+
<select value={reportDepth} onChange={e => setReportDepth(Number(e.target.value))}
|
| 589 |
+
style={{ border: '2px solid #000', padding: '0.5rem 0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.9rem' }}>
|
| 590 |
+
{[1, 2, 3, 4, 5].map(n => <option key={n} value={n}>{n} {n === 1 ? '(fast)' : n === 5 ? '(deep)' : ''}</option>)}
|
| 591 |
+
</select>
|
| 592 |
+
</div>
|
| 593 |
+
<button className="app-btn mono-text" onClick={runReport} disabled={reportLoading || !reportTopic.trim()}
|
| 594 |
+
style={{ alignSelf: 'flex-end', padding: '0.75rem 2rem' }}>
|
| 595 |
+
{reportLoading ? 'GENERATING...' : 'GENERATE REPORT'}
|
| 596 |
+
</button>
|
| 597 |
+
</div>
|
| 598 |
+
</div>
|
| 599 |
+
</div>
|
| 600 |
+
|
| 601 |
+
{/* Empty state */}
|
| 602 |
+
{!reportResult && !reportLoading && (
|
| 603 |
+
<div style={{ border: '3px dashed #e5e5e5', padding: '4rem 2rem', textAlign: 'center' }}>
|
| 604 |
+
<FileText size={44} style={{ opacity: 0.18, marginBottom: '1rem' }} />
|
| 605 |
+
<p className="mono-text" style={{ color: '#aaa', fontSize: '0.85rem', letterSpacing: '1px' }}>NO REPORT GENERATED YET</p>
|
| 606 |
+
<p style={{ color: '#bbb', fontSize: '0.8rem', marginTop: '0.5rem', fontFamily: 'var(--font-sans)' }}>
|
| 607 |
+
Enter a topic above and click Generate to produce an AI-powered analytical report from the knowledge graph.
|
| 608 |
+
</p>
|
| 609 |
+
</div>
|
| 610 |
+
)}
|
| 611 |
+
|
| 612 |
+
{/* Loading state */}
|
| 613 |
+
{reportLoading && (
|
| 614 |
+
<div style={{ border: '3px solid #000', padding: '2.5rem', textAlign: 'center' }}>
|
| 615 |
+
<div style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: '0.9rem', marginBottom: '0.75rem' }}>
|
| 616 |
+
⚡ AGENT REASONING…
|
| 617 |
+
</div>
|
| 618 |
+
<div style={{ fontFamily: 'var(--font-sans)', fontSize: '0.82rem', color: 'var(--muted-color)' }}>
|
| 619 |
+
Querying knowledge graph with depth {reportDepth}. This may take 20–60 seconds.
|
| 620 |
+
</div>
|
| 621 |
+
</div>
|
| 622 |
+
)}
|
| 623 |
+
|
| 624 |
+
{/* Report output */}
|
| 625 |
+
{reportResult && (
|
| 626 |
+
<div style={{ border: '3px solid #000' }}>
|
| 627 |
+
{/* Report header bar */}
|
| 628 |
+
<div style={{ background: '#000', color: '#fff', padding: '0.85rem 1.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
| 629 |
+
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: '0.82rem', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
| 630 |
+
📄 {(reportResult.topic || reportTopic).toUpperCase()}
|
| 631 |
+
</span>
|
| 632 |
+
{/* Confidence badge */}
|
| 633 |
+
{reportResult.confidence !== undefined && (
|
| 634 |
+
<span style={{
|
| 635 |
+
background: confidenceBadgeColor(reportResult.confidence),
|
| 636 |
+
color: '#fff',
|
| 637 |
+
padding: '2px 10px',
|
| 638 |
+
fontFamily: 'var(--font-mono)',
|
| 639 |
+
fontSize: '0.72rem',
|
| 640 |
+
fontWeight: 700,
|
| 641 |
+
letterSpacing: '0.5px',
|
| 642 |
+
flexShrink: 0,
|
| 643 |
+
}}>
|
| 644 |
+
CONF: {(reportResult.confidence * 100).toFixed(0)}%
|
| 645 |
+
</span>
|
| 646 |
+
)}
|
| 647 |
+
{reportResult.tool_calls_made !== undefined && (
|
| 648 |
+
<span style={{ background: '#333', color: '#fff', padding: '2px 10px', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', fontWeight: 700, flexShrink: 0 }}>
|
| 649 |
+
{reportResult.tool_calls_made} CALLS
|
| 650 |
+
</span>
|
| 651 |
+
)}
|
| 652 |
+
{/* Actions */}
|
| 653 |
+
<button
|
| 654 |
+
onClick={handleCopyReport}
|
| 655 |
+
style={{ background: copyDone ? '#16a34a' : 'transparent', color: '#fff', border: '1px solid #555', padding: '3px 10px', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', cursor: 'pointer', flexShrink: 0 }}
|
| 656 |
+
>
|
| 657 |
+
{copyDone ? '✓ COPIED' : '📋 COPY'}
|
| 658 |
+
</button>
|
| 659 |
+
<button
|
| 660 |
+
onClick={handleDownloadReport}
|
| 661 |
+
style={{ background: 'transparent', color: '#fff', border: '1px solid #555', padding: '3px 10px', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', cursor: 'pointer', flexShrink: 0 }}
|
| 662 |
+
>
|
| 663 |
+
⬇ .MD
|
| 664 |
+
</button>
|
| 665 |
+
</div>
|
| 666 |
+
|
| 667 |
+
<div style={{ padding: '2rem' }}>
|
| 668 |
+
{/* Meta info row */}
|
| 669 |
+
<div style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', marginBottom: '2rem', paddingBottom: '1rem', borderBottom: '1px dotted #ddd', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: '#888' }}>
|
| 670 |
+
{reportTimestamp && <div>Generated: {reportTimestamp.toLocaleString()}</div>}
|
| 671 |
+
{reportResult.tool_calls_made !== undefined && (
|
| 672 |
+
<div>Depth: {reportDepth} · Tool calls: {reportResult.tool_calls_made}</div>
|
| 673 |
+
)}
|
| 674 |
+
{reportResult.confidence !== undefined && (
|
| 675 |
+
<div style={{ color: confidenceBadgeColor(reportResult.confidence), fontWeight: 700 }}>
|
| 676 |
+
Confidence: {(reportResult.confidence * 100).toFixed(1)}%
|
| 677 |
+
</div>
|
| 678 |
+
)}
|
| 679 |
+
</div>
|
| 680 |
+
|
| 681 |
+
{/* Executive summary */}
|
| 682 |
+
{reportResult.executive_summary && (
|
| 683 |
+
<div style={{ marginBottom: '2.5rem', borderLeft: '4px solid #f59e0b', paddingLeft: '1.25rem' }}>
|
| 684 |
+
<h4 className="mono-text" style={{ fontSize: '0.72rem', marginBottom: '0.75rem', color: '#d97706', letterSpacing: '1.5px' }}>
|
| 685 |
+
EXECUTIVE SUMMARY
|
| 686 |
+
</h4>
|
| 687 |
+
<p style={{ fontFamily: 'var(--font-sans)', lineHeight: 1.9, fontSize: '1rem', fontWeight: 500, whiteSpace: 'pre-wrap', color: '#111' }}>
|
| 688 |
+
{reportResult.executive_summary}
|
| 689 |
+
</p>
|
| 690 |
+
</div>
|
| 691 |
+
)}
|
| 692 |
+
|
| 693 |
+
{/* Sections */}
|
| 694 |
+
{reportResult.sections && typeof reportResult.sections === 'object' && !Array.isArray(reportResult.sections) ? (
|
| 695 |
+
Object.entries(reportResult.sections).map(([title, content], i) => (
|
| 696 |
+
<div key={i} style={{ marginBottom: '2rem', paddingBottom: '1.5rem', borderBottom: '1px dotted #e5e5e5' }}>
|
| 697 |
+
<h4 className="mono-text" style={{ fontSize: '0.8rem', marginBottom: '1rem', color: '#000', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 698 |
+
<span style={{ background: '#000', color: '#fff', padding: '2px 8px', fontSize: '0.65rem', flexShrink: 0 }}>{i + 1}</span>
|
| 699 |
+
{title.toUpperCase()}
|
| 700 |
+
</h4>
|
| 701 |
+
<p style={{ fontFamily: 'var(--font-sans)', lineHeight: 1.85, fontSize: '0.92rem', whiteSpace: 'pre-wrap', color: '#333' }}>
|
| 702 |
+
{String(content)}
|
| 703 |
+
</p>
|
| 704 |
+
</div>
|
| 705 |
+
))
|
| 706 |
+
) : (
|
| 707 |
+
reportResult.sections?.map?.((s: any, i: number) => (
|
| 708 |
+
<div key={i} style={{ marginBottom: '2rem', paddingBottom: '1.5rem', borderBottom: '1px dotted #e5e5e5' }}>
|
| 709 |
+
<h4 className="mono-text" style={{ fontSize: '0.8rem', marginBottom: '1rem', color: '#000', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 710 |
+
<span style={{ background: '#000', color: '#fff', padding: '2px 8px', fontSize: '0.65rem', flexShrink: 0 }}>{i + 1}</span>
|
| 711 |
+
{s.title?.toUpperCase()}
|
| 712 |
+
</h4>
|
| 713 |
+
<p style={{ fontFamily: 'var(--font-sans)', lineHeight: 1.85, fontSize: '0.92rem', whiteSpace: 'pre-wrap', color: '#333' }}>
|
| 714 |
+
{s.content}
|
| 715 |
+
</p>
|
| 716 |
+
</div>
|
| 717 |
+
))
|
| 718 |
+
)}
|
| 719 |
+
|
| 720 |
+
{!reportResult.sections && (
|
| 721 |
+
<p style={{ fontFamily: 'var(--font-sans)', lineHeight: 1.85, whiteSpace: 'pre-wrap', color: '#333' }}>
|
| 722 |
+
{reportResult.report || reportResult.content || reportResult.markdown || JSON.stringify(reportResult, null, 2)}
|
| 723 |
+
</p>
|
| 724 |
+
)}
|
| 725 |
+
|
| 726 |
+
{/* Key entities */}
|
| 727 |
+
{reportResult.key_entities && reportResult.key_entities.length > 0 && (
|
| 728 |
+
<div style={{ marginTop: '2rem', paddingTop: '1rem', borderTop: '1px dotted #ccc' }}>
|
| 729 |
+
<h4 className="mono-text" style={{ fontSize: '0.68rem', color: '#888', marginBottom: '0.5rem', letterSpacing: '1.5px' }}>
|
| 730 |
+
KEY ENTITIES REFERENCED
|
| 731 |
+
</h4>
|
| 732 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
| 733 |
+
{reportResult.key_entities.map((e: string, i: number) => (
|
| 734 |
+
<span key={i} style={{ background: '#f3f4f6', border: '1.5px solid #e5e5e5', padding: '2px 8px', fontSize: '0.75rem', fontFamily: 'var(--font-mono)', fontWeight: 600 }}>
|
| 735 |
+
{e}
|
| 736 |
+
</span>
|
| 737 |
+
))}
|
| 738 |
+
</div>
|
| 739 |
+
</div>
|
| 740 |
+
)}
|
| 741 |
+
</div>
|
| 742 |
+
</div>
|
| 743 |
+
)}
|
| 744 |
+
</div>
|
| 745 |
+
)}
|
| 746 |
+
|
| 747 |
+
{/* ── LIVE GRAPH UPDATE TAB ── */}
|
| 748 |
+
{activeTab === 'graph-update' && (
|
| 749 |
+
<div>
|
| 750 |
+
<div className="page-info-bar">
|
| 751 |
+
<GitCommit size={14}/>
|
| 752 |
+
<span><strong>LIVE GRAPH UPDATE</strong> — Paste any text (news article, note, policy). The system extracts entities and relationships and merges them into the live knowledge graph without re-running ingestion. Uses <strong>MERGE</strong> to prevent duplicates.</span>
|
| 753 |
+
</div>
|
| 754 |
+
<div style={{ border: '3px solid #000', padding: '2rem', marginBottom: '2rem' }}>
|
| 755 |
+
<h3 className="mono-text" style={{ marginBottom: '1.5rem' }}><GitCommit size={16} style={{ display: 'inline', marginRight: '0.5rem' }}/>INJECT KNOWLEDGE INTO GRAPH</h3>
|
| 756 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
| 757 |
+
<div>
|
| 758 |
+
<label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>TEXT TO INJECT</label>
|
| 759 |
+
<textarea value={updateText} onChange={e => setUpdateText(e.target.value)}
|
| 760 |
+
rows={8} placeholder="Paste any text — news, reports, notes, or raw facts..."
|
| 761 |
+
style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.88rem', resize: 'vertical', boxSizing: 'border-box' }}/>
|
| 762 |
+
</div>
|
| 763 |
+
<button className="app-btn mono-text" onClick={runGraphUpdate} disabled={updateLoading || !updateText.trim()}
|
| 764 |
+
style={{ alignSelf: 'flex-start', padding: '0.75rem 2rem' }}>
|
| 765 |
+
{updateLoading ? 'INJECTING...' : 'INJECT INTO GRAPH'}
|
| 766 |
+
</button>
|
| 767 |
+
</div>
|
| 768 |
+
</div>
|
| 769 |
+
{updateResult && (
|
| 770 |
+
<div style={{ border: '3px solid #000', padding: '1.5rem', background: '#f0fdf4' }}>
|
| 771 |
+
<h3 className="mono-text" style={{ marginBottom: '1rem', color: '#166534' }}>✓ GRAPH UPDATED</h3>
|
| 772 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px,1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
|
| 773 |
+
{[
|
| 774 |
+
['Entities Added', updateResult.entities_added ?? updateResult.nodes_created],
|
| 775 |
+
['Relationships Added', updateResult.relationships_added ?? updateResult.edges_created],
|
| 776 |
+
['Merged Existing', updateResult.entities_merged ?? '—'],
|
| 777 |
+
].map(([label, val]) => (
|
| 778 |
+
<div key={label as string} style={{ border: '2px solid #000', padding: '1rem', textAlign: 'center' }}>
|
| 779 |
+
<div className="mono-text" style={{ fontSize: '0.68rem', color: '#888', marginBottom: '0.35rem' }}>{label}</div>
|
| 780 |
+
<div className="mono-text" style={{ fontSize: '1.6rem', fontWeight: 900 }}>{val ?? '—'}</div>
|
| 781 |
+
</div>
|
| 782 |
+
))}
|
| 783 |
+
</div>
|
| 784 |
+
{updateResult.message && (
|
| 785 |
+
<p className="mono-text" style={{ fontSize: '0.82rem', color: '#555' }}>{updateResult.message}</p>
|
| 786 |
+
)}
|
| 787 |
+
</div>
|
| 788 |
+
)}
|
| 789 |
+
</div>
|
| 790 |
+
)}
|
| 791 |
+
|
| 792 |
+
{/* ── ENTITY CHAT TAB ── */}
|
| 793 |
+
{activeTab === 'entity-chat' && (
|
| 794 |
+
<div>
|
| 795 |
+
<div className="page-info-bar">
|
| 796 |
+
<MessageSquare size={14}/>
|
| 797 |
+
<span><strong>ENTITY CHAT</strong> — Select an entity from the graph and interview it. The agent synthesizes answers from its neighborhood context, relationships, and LLM-generated profile.</span>
|
| 798 |
+
</div>
|
| 799 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem' }}>
|
| 800 |
+
{/* Config panel */}
|
| 801 |
+
<div style={{ border: '3px solid #000', padding: '1.5rem' }}>
|
| 802 |
+
<h3 className="mono-text" style={{ marginBottom: '1.25rem', fontSize: '0.9rem' }}>SELECT ENTITY</h3>
|
| 803 |
+
<div style={{ marginBottom: '1rem' }}>
|
| 804 |
+
<label className="mono-text" style={{ fontSize: '0.75rem', fontWeight: 700, color: '#888', display: 'block', marginBottom: '0.4rem' }}>ENTITY NAME</label>
|
| 805 |
+
<select value={selectedEntity} onChange={e => { setSelectedEntity(e.target.value); setChatHistory([]); }}
|
| 806 |
+
style={{ width: '100%', border: '2px solid #000', padding: '0.5rem', fontFamily: 'var(--font-mono)', fontSize: '0.85rem' }}>
|
| 807 |
+
<option value="">— Select entity —</option>
|
| 808 |
+
{entities.map((n: any) => (
|
| 809 |
+
<option key={n.id} value={n.properties?.name || n.properties?.label || n.id}>
|
| 810 |
+
{n.labels?.[0]} · {n.properties?.name || n.properties?.label || n.id}
|
| 811 |
+
</option>
|
| 812 |
+
))}
|
| 813 |
+
</select>
|
| 814 |
+
</div>
|
| 815 |
+
<div style={{ marginBottom: '1rem' }}>
|
| 816 |
+
<label className="mono-text" style={{ fontSize: '0.75rem', fontWeight: 700, color: '#888', display: 'block', marginBottom: '0.4rem' }}>EXTRA CONTEXT (OPTIONAL)</label>
|
| 817 |
+
<textarea value={entityContext} onChange={e => setEntityContext(e.target.value)}
|
| 818 |
+
rows={3} placeholder="Add any additional context or constraints..."
|
| 819 |
+
style={{ width: '100%', border: '2px solid #000', padding: '0.5rem', fontFamily: 'var(--font-mono)', fontSize: '0.82rem', resize: 'vertical' }}/>
|
| 820 |
+
</div>
|
| 821 |
+
<button className="app-btn mono-text" style={{ width: '100%', justifyContent: 'center' }}
|
| 822 |
+
onClick={() => setChatHistory([])}>CLEAR CHAT</button>
|
| 823 |
+
</div>
|
| 824 |
+
{/* Chat window */}
|
| 825 |
+
<div style={{ border: '3px solid #000', display: 'flex', flexDirection: 'column', minHeight: 400 }}>
|
| 826 |
+
<div style={{ background: '#000', color: '#fff', padding: '0.5rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', fontWeight: 700 }}>
|
| 827 |
+
{selectedEntity ? `CHATTING WITH: ${selectedEntity.toUpperCase()}` : 'SELECT AN ENTITY TO BEGIN'}
|
| 828 |
+
</div>
|
| 829 |
+
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', minHeight: 280, maxHeight: 400 }}>
|
| 830 |
+
{chatHistory.length === 0 && (
|
| 831 |
+
<div style={{ textAlign: 'center', padding: '2rem', color: '#ccc', fontFamily: 'var(--font-mono)', fontSize: '0.82rem' }}>
|
| 832 |
+
{selectedEntity ? 'Ask this entity anything…' : 'Select an entity to start chatting'}
|
| 833 |
+
</div>
|
| 834 |
+
)}
|
| 835 |
+
{chatHistory.map((m, i) => (
|
| 836 |
+
<div key={i} style={{
|
| 837 |
+
alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
|
| 838 |
+
background: m.role === 'user' ? '#000' : '#f3f4f6',
|
| 839 |
+
color: m.role === 'user' ? '#fff' : '#000',
|
| 840 |
+
padding: '0.6rem 1rem',
|
| 841 |
+
maxWidth: '85%',
|
| 842 |
+
fontFamily: 'var(--font-sans)', fontSize: '0.88rem', lineHeight: 1.6
|
| 843 |
+
}}>
|
| 844 |
+
{m.content}
|
| 845 |
+
</div>
|
| 846 |
+
))}
|
| 847 |
+
{chatLoading && (
|
| 848 |
+
<div style={{ alignSelf: 'flex-start', background: '#f3f4f6', padding: '0.6rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.82rem', color: '#888' }}>
|
| 849 |
+
Thinking…
|
| 850 |
+
</div>
|
| 851 |
+
)}
|
| 852 |
+
</div>
|
| 853 |
+
<div style={{ borderTop: '2px solid #000', display: 'flex', gap: 0 }}>
|
| 854 |
+
<input type="text" value={chatMsg} onChange={e => setChatMsg(e.target.value)}
|
| 855 |
+
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendEntityChat()}
|
| 856 |
+
placeholder={selectedEntity ? 'Ask a question…' : 'Select an entity first'}
|
| 857 |
+
disabled={!selectedEntity || chatLoading}
|
| 858 |
+
style={{ flex: 1, border: 'none', padding: '0.75rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.88rem', outline: 'none' }}/>
|
| 859 |
+
<button className="app-btn" onClick={sendEntityChat} disabled={!selectedEntity || !chatMsg.trim() || chatLoading}
|
| 860 |
+
style={{ border: 'none', borderLeft: '2px solid #000', borderRadius: 0, minWidth: 80 }}>
|
| 861 |
+
SEND
|
| 862 |
+
</button>
|
| 863 |
+
</div>
|
| 864 |
+
</div>
|
| 865 |
+
</div>
|
| 866 |
+
</div>
|
| 867 |
+
)}
|
| 868 |
+
</div>
|
| 869 |
+
);
|
| 870 |
+
};
|
| 871 |
+
|
| 872 |
+
export default InsightsView;
|
frontend-react/src/views/InteractionView.tsx
ADDED
|
@@ -0,0 +1,1396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import { Message, Conversation, DocumentInfo, GraphNode } from '../types/api';
|
| 4 |
+
import {
|
| 5 |
+
MessageSquare, Send, Bot, User as UserIcon, Zap,
|
| 6 |
+
Menu, Info, X, ChevronDown, FileText, Plus
|
| 7 |
+
} from 'lucide-react';
|
| 8 |
+
import ReactMarkdown from 'react-markdown';
|
| 9 |
+
import remarkGfm from 'remark-gfm';
|
| 10 |
+
|
| 11 |
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
|
| 12 |
+
|
| 13 |
+
/* ── confidence colour helper ─────────────────────────────────────────────── */
|
| 14 |
+
const confColor = (c: number) => c >= 0.75 ? '#16a34a' : c >= 0.5 ? '#d97706' : '#dc2626';
|
| 15 |
+
const riskColor = (r: string) =>
|
| 16 |
+
r.toLowerCase() === 'high' ? '#dc2626' : r.toLowerCase() === 'medium' ? '#d97706' : '#16a34a';
|
| 17 |
+
|
| 18 |
+
/* ─────────────────────────────────────────────────────────────────────────── */
|
| 19 |
+
|
| 20 |
+
const InteractionView: React.FC = () => {
|
| 21 |
+
const { token, logout } = useAuth();
|
| 22 |
+
|
| 23 |
+
// ── Core chat state ──────────────────────────────────────────────────────
|
| 24 |
+
const [query, setQuery] = useState('');
|
| 25 |
+
const [conversation, setConversation] = useState<Message[]>([]);
|
| 26 |
+
const [loading, setLoading] = useState(false);
|
| 27 |
+
|
| 28 |
+
// ── Document / mode state ────────────────────────────────────────────────
|
| 29 |
+
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
|
| 30 |
+
const [selectedDocId, setSelectedDocId] = useState('');
|
| 31 |
+
const [useGot, setUseGot] = useState(false);
|
| 32 |
+
const [mode, setMode] = useState<'rag' | 'simulation'>('rag');
|
| 33 |
+
const [agentId, setAgentId] = useState('');
|
| 34 |
+
const [agentNodes, setAgentNodes] = useState<GraphNode[]>([]);
|
| 35 |
+
|
| 36 |
+
// ── Thread history ────────────────────────────────────────────────────────
|
| 37 |
+
const [pastConversations, setPastConversations] = useState<Conversation[]>([]);
|
| 38 |
+
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
| 39 |
+
|
| 40 |
+
// ── UI state ──────────────────────────────────────────────────────────────
|
| 41 |
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 42 |
+
const [drawerSource, setDrawerSource] = useState<any | null>(null);
|
| 43 |
+
|
| 44 |
+
const endOfChatRef = useRef<HTMLDivElement>(null);
|
| 45 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 46 |
+
|
| 47 |
+
/* ── auto-scroll ─────────────────────────────────────────────────────── */
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
endOfChatRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 50 |
+
}, [conversation]);
|
| 51 |
+
|
| 52 |
+
/* ── initial data fetch ──────────────────────────────────────────────── */
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
const fetchDocs = async () => {
|
| 55 |
+
try {
|
| 56 |
+
const res = await fetch(`${API_BASE}/documents`, {
|
| 57 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 58 |
+
});
|
| 59 |
+
if (res.ok) setDocuments((await res.json()).documents);
|
| 60 |
+
} catch {}
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const fetchConvs = async () => {
|
| 64 |
+
try {
|
| 65 |
+
const res = await fetch(`${API_BASE}/conversations`, {
|
| 66 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 67 |
+
});
|
| 68 |
+
if (res.ok) setPastConversations((await res.json()).conversations);
|
| 69 |
+
} catch {}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const fetchAgents = async () => {
|
| 73 |
+
try {
|
| 74 |
+
const res = await fetch(`${API_BASE}/graph/visualization?limit=500`, {
|
| 75 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 76 |
+
});
|
| 77 |
+
if (res.ok) setAgentNodes((await res.json()).nodes);
|
| 78 |
+
} catch {}
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
fetchDocs();
|
| 82 |
+
fetchConvs();
|
| 83 |
+
fetchAgents();
|
| 84 |
+
}, [token]);
|
| 85 |
+
|
| 86 |
+
/* ── load an archived thread ─────────────────────────────────────────── */
|
| 87 |
+
const loadConversation = useCallback(async (convId: string) => {
|
| 88 |
+
try {
|
| 89 |
+
const res = await fetch(`${API_BASE}/conversations/${convId}`, {
|
| 90 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 91 |
+
});
|
| 92 |
+
if (res.ok) {
|
| 93 |
+
const data = await res.json();
|
| 94 |
+
setCurrentConversationId(data.id);
|
| 95 |
+
setConversation(
|
| 96 |
+
data.messages.map((m: any) => ({
|
| 97 |
+
role: m.role,
|
| 98 |
+
content: m.content,
|
| 99 |
+
reasoning: m.reasoning || [],
|
| 100 |
+
sources: m.sources || []
|
| 101 |
+
}))
|
| 102 |
+
);
|
| 103 |
+
// On mobile: close sidebar after selecting
|
| 104 |
+
if (window.innerWidth < 768) setSidebarOpen(false);
|
| 105 |
+
}
|
| 106 |
+
} catch {}
|
| 107 |
+
}, [token]);
|
| 108 |
+
|
| 109 |
+
const startNewConversation = useCallback(() => {
|
| 110 |
+
setCurrentConversationId(null);
|
| 111 |
+
setConversation([]);
|
| 112 |
+
if (window.innerWidth < 768) setSidebarOpen(false);
|
| 113 |
+
setTimeout(() => inputRef.current?.focus(), 100);
|
| 114 |
+
}, []);
|
| 115 |
+
|
| 116 |
+
/* ── submit query ────────────────────────────────────────────────────── */
|
| 117 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 118 |
+
e.preventDefault();
|
| 119 |
+
if (!query.trim() || loading) return;
|
| 120 |
+
|
| 121 |
+
const userMessage = { role: 'user', content: query };
|
| 122 |
+
const assistantPlaceholder = {
|
| 123 |
+
role: 'assistant', content: '', sources: [], reasoning: [],
|
| 124 |
+
confidence: null, drift_expanded: false
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
setConversation(prev => [...prev, userMessage, assistantPlaceholder]);
|
| 128 |
+
setQuery('');
|
| 129 |
+
setLoading(true);
|
| 130 |
+
|
| 131 |
+
try {
|
| 132 |
+
/* ── Simulation mode ──────────────────────────────────────────── */
|
| 133 |
+
if (mode === 'simulation') {
|
| 134 |
+
const res = await fetch(`${API_BASE}/v1/simulation/interview`, {
|
| 135 |
+
method: 'POST',
|
| 136 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 137 |
+
body: JSON.stringify({ agent_id: agentId, user_query: userMessage.content })
|
| 138 |
+
});
|
| 139 |
+
if (!res.ok) throw new Error('Simulation endpoint failed.');
|
| 140 |
+
const data = await res.json();
|
| 141 |
+
setConversation(prev => {
|
| 142 |
+
const next = [...prev];
|
| 143 |
+
next[next.length - 1] = {
|
| 144 |
+
role: 'assistant',
|
| 145 |
+
content: data.response,
|
| 146 |
+
sources: [],
|
| 147 |
+
reasoning: [`Simulated persona response for agent: ${data.agent_name || agentId}`],
|
| 148 |
+
confidence: null
|
| 149 |
+
};
|
| 150 |
+
return next;
|
| 151 |
+
});
|
| 152 |
+
setLoading(false);
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* ── RAG streaming mode ─────────────────────────────────────────── */
|
| 157 |
+
const reqBody: any = {
|
| 158 |
+
query: userMessage.content,
|
| 159 |
+
streaming: true,
|
| 160 |
+
top_k: 5,
|
| 161 |
+
use_got: useGot
|
| 162 |
+
};
|
| 163 |
+
if (selectedDocId) reqBody.document_id = selectedDocId;
|
| 164 |
+
if (currentConversationId) reqBody.conversation_id = currentConversationId;
|
| 165 |
+
|
| 166 |
+
const res = await fetch(`${API_BASE}/query`, {
|
| 167 |
+
method: 'POST',
|
| 168 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 169 |
+
body: JSON.stringify(reqBody)
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
if (res.status === 401) { logout(); return; }
|
| 173 |
+
if (!res.body) throw new Error('ReadableStream not supported.');
|
| 174 |
+
|
| 175 |
+
const reader = res.body.getReader();
|
| 176 |
+
const decoder = new TextDecoder('utf-8');
|
| 177 |
+
|
| 178 |
+
while (true) {
|
| 179 |
+
const { done, value } = await reader.read();
|
| 180 |
+
if (done) break;
|
| 181 |
+
const raw = decoder.decode(value);
|
| 182 |
+
const chunks = raw.split('\n\n');
|
| 183 |
+
|
| 184 |
+
for (const chunk of chunks) {
|
| 185 |
+
if (chunk.trim() === 'data: [DONE]') { setLoading(false); break; }
|
| 186 |
+
if (!chunk.startsWith('data: ')) continue;
|
| 187 |
+
try {
|
| 188 |
+
const data = JSON.parse(chunk.replace('data: ', ''));
|
| 189 |
+
if (data.type === 'meta') {
|
| 190 |
+
setCurrentConversationId(data.conversation_id);
|
| 191 |
+
// Refresh thread list so it shows up in sidebar
|
| 192 |
+
const convRes = await fetch(`${API_BASE}/conversations`, {
|
| 193 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 194 |
+
});
|
| 195 |
+
if (convRes.ok) setPastConversations((await convRes.json()).conversations);
|
| 196 |
+
continue;
|
| 197 |
+
}
|
| 198 |
+
setConversation(prev => {
|
| 199 |
+
const next = [...prev];
|
| 200 |
+
const last = next.length - 1;
|
| 201 |
+
if (data.type === 'step') {
|
| 202 |
+
next[last] = { ...next[last], reasoning: [...(next[last].reasoning || []), data.content] };
|
| 203 |
+
} else if (data.type === 'answer') {
|
| 204 |
+
next[last] = {
|
| 205 |
+
...next[last],
|
| 206 |
+
content: data.answer,
|
| 207 |
+
sources: data.sources,
|
| 208 |
+
confidence: data.confidence,
|
| 209 |
+
drift_expanded: data.drift_expanded || false,
|
| 210 |
+
hallucination_risk: data.hallucination_risk,
|
| 211 |
+
confidence_reasoning: data.confidence_reasoning
|
| 212 |
+
};
|
| 213 |
+
}
|
| 214 |
+
return next;
|
| 215 |
+
});
|
| 216 |
+
} catch {}
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
} catch (err) {
|
| 220 |
+
console.error('Query error:', err);
|
| 221 |
+
// Show error in the placeholder message
|
| 222 |
+
setConversation(prev => {
|
| 223 |
+
const next = [...prev];
|
| 224 |
+
next[next.length - 1] = {
|
| 225 |
+
...next[next.length - 1],
|
| 226 |
+
content: '⚠ An error occurred. Please check your connection or try again.'
|
| 227 |
+
};
|
| 228 |
+
return next;
|
| 229 |
+
});
|
| 230 |
+
} finally {
|
| 231 |
+
setLoading(false);
|
| 232 |
+
}
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
/* ── inline eval ─────────────────────────────────────────────────────── */
|
| 236 |
+
const runInlineEval = async (msgIndex: number) => {
|
| 237 |
+
const astMsg = conversation[msgIndex];
|
| 238 |
+
if (astMsg.role !== 'assistant') return;
|
| 239 |
+
|
| 240 |
+
let question = 'Contextual Query';
|
| 241 |
+
for (let i = msgIndex - 1; i >= 0; i--) {
|
| 242 |
+
if (conversation[i].role === 'user') { question = conversation[i].content; break; }
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
setConversation(prev => {
|
| 246 |
+
const next = [...prev];
|
| 247 |
+
next[msgIndex] = { ...next[msgIndex], evaluating: true };
|
| 248 |
+
return next;
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
try {
|
| 252 |
+
const contexts = (astMsg.sources || []).map((s: any) => s.text || JSON.stringify(s));
|
| 253 |
+
const res = await fetch(`${API_BASE}/eval/score`, {
|
| 254 |
+
method: 'POST',
|
| 255 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 256 |
+
body: JSON.stringify({ question, answer: astMsg.content, contexts })
|
| 257 |
+
});
|
| 258 |
+
const evalData = res.ok ? await res.json() : null;
|
| 259 |
+
setConversation(prev => {
|
| 260 |
+
const next = [...prev];
|
| 261 |
+
next[msgIndex] = { ...next[msgIndex], evaluating: false, ...(evalData ? { eval_result: evalData } : {}) };
|
| 262 |
+
return next;
|
| 263 |
+
});
|
| 264 |
+
} catch {
|
| 265 |
+
setConversation(prev => {
|
| 266 |
+
const next = [...prev]; next[msgIndex] = { ...next[msgIndex], evaluating: false }; return next;
|
| 267 |
+
});
|
| 268 |
+
}
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
/* ── keyboard shortcut: Ctrl+/ or Cmd+/ to focus input ─────────────── */
|
| 272 |
+
useEffect(() => {
|
| 273 |
+
const handler = (e: KeyboardEvent) => {
|
| 274 |
+
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
| 275 |
+
e.preventDefault();
|
| 276 |
+
inputRef.current?.focus();
|
| 277 |
+
}
|
| 278 |
+
if (e.key === 'Escape' && drawerSource) setDrawerSource(null);
|
| 279 |
+
};
|
| 280 |
+
window.addEventListener('keydown', handler);
|
| 281 |
+
return () => window.removeEventListener('keydown', handler);
|
| 282 |
+
}, [drawerSource]);
|
| 283 |
+
|
| 284 |
+
/* ────────────────────────────────────────────────────────────────────── */
|
| 285 |
+
return (
|
| 286 |
+
<div className="iv-root">
|
| 287 |
+
|
| 288 |
+
{/* ── Top header bar ──────────────────────────────────────────────── */}
|
| 289 |
+
<div className="iv-header">
|
| 290 |
+
{/* left: title + breadcrumb */}
|
| 291 |
+
<div className="iv-header-left">
|
| 292 |
+
<button
|
| 293 |
+
className="iv-sidebar-toggle"
|
| 294 |
+
onClick={() => setSidebarOpen(o => !o)}
|
| 295 |
+
title={sidebarOpen ? 'Hide threads' : 'Show threads'}
|
| 296 |
+
>
|
| 297 |
+
<Menu size={18} />
|
| 298 |
+
</button>
|
| 299 |
+
<div>
|
| 300 |
+
<h1 className="iv-title">AGENTIC INTERACTION</h1>
|
| 301 |
+
<p className="mono-text iv-breadcrumb">TERMINAL // LOGIC QUERY INTERFACE</p>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
|
| 305 |
+
{/* right: controls */}
|
| 306 |
+
<div className="iv-header-right">
|
| 307 |
+
{/* Document filter */}
|
| 308 |
+
<div className="iv-ctrl-group">
|
| 309 |
+
<label className="iv-ctrl-label">SCOPE</label>
|
| 310 |
+
<div className="iv-select-wrap">
|
| 311 |
+
<select
|
| 312 |
+
className="iv-select"
|
| 313 |
+
value={selectedDocId}
|
| 314 |
+
onChange={e => setSelectedDocId(e.target.value)}
|
| 315 |
+
>
|
| 316 |
+
<option value="">🌐 ALL DOCUMENTS</option>
|
| 317 |
+
{documents.length === 0 ? (
|
| 318 |
+
<option disabled>No documents uploaded</option>
|
| 319 |
+
) : (
|
| 320 |
+
documents.map(doc => (
|
| 321 |
+
<option key={doc.id} value={doc.id}>
|
| 322 |
+
📄 {doc.filename.length > 32 ? doc.filename.substring(0, 30) + '…' : doc.filename}
|
| 323 |
+
</option>
|
| 324 |
+
))
|
| 325 |
+
)}
|
| 326 |
+
</select>
|
| 327 |
+
<ChevronDown size={13} className="iv-select-chevron" />
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
|
| 331 |
+
{/* GoT toggle */}
|
| 332 |
+
<button
|
| 333 |
+
className={`iv-got-btn ${useGot ? 'active' : ''}`}
|
| 334 |
+
onClick={() => setUseGot(g => !g)}
|
| 335 |
+
title="Graph-of-Thought: runs all retrieval strategies in parallel for higher quality answers"
|
| 336 |
+
>
|
| 337 |
+
<Zap size={13} />
|
| 338 |
+
GoT {useGot ? 'ON' : 'OFF'}
|
| 339 |
+
</button>
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
|
| 343 |
+
{/* ── Info bar ────────────────────────────────────────────────────── */}
|
| 344 |
+
<div className="iv-info-bar">
|
| 345 |
+
<Info size={13} />
|
| 346 |
+
<span>
|
| 347 |
+
<strong>Standard (Graph Logic)</strong>: multi-hop retrieval over the knowledge graph.
|
| 348 |
+
<strong>GoT</strong>: runs all search strategies in parallel, best for complex questions.
|
| 349 |
+
<strong>God-Mode</strong>: interviews a simulated AI persona by agent ID.
|
| 350 |
+
Press <kbd>Ctrl+/</kbd> to focus input.
|
| 351 |
+
</span>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
{/* ── Main layout ─────────────────────────────────────────────────── */}
|
| 355 |
+
<div className="iv-body">
|
| 356 |
+
|
| 357 |
+
{/* ── Sidebar ────────────────────────────────────────────────────── */}
|
| 358 |
+
<div className={`iv-sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
|
| 359 |
+
<button className="iv-new-thread" onClick={startNewConversation} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 360 |
+
<Plus size={16} /> NEW THREAD
|
| 361 |
+
</button>
|
| 362 |
+
|
| 363 |
+
<div className="iv-thread-list">
|
| 364 |
+
<div className="iv-thread-header">ARCHIVED THREADS</div>
|
| 365 |
+
{pastConversations.length === 0 ? (
|
| 366 |
+
<div className="iv-empty-threads">No prior sequences</div>
|
| 367 |
+
) : (
|
| 368 |
+
pastConversations.map(conv => (
|
| 369 |
+
<div
|
| 370 |
+
key={conv.id}
|
| 371 |
+
className={`iv-thread-item ${currentConversationId === conv.id ? 'active' : ''}`}
|
| 372 |
+
onClick={() => loadConversation(conv.id)}
|
| 373 |
+
>
|
| 374 |
+
<div className="iv-thread-title">
|
| 375 |
+
<MessageSquare size={12} style={{ display: 'inline', marginRight: '6px', verticalAlign: '-1px' }} />
|
| 376 |
+
{conv.title || 'Untitled thread'}
|
| 377 |
+
</div>
|
| 378 |
+
<div className="iv-thread-date">
|
| 379 |
+
{new Date(conv.created_at).toLocaleDateString()}
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
))
|
| 383 |
+
)}
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
|
| 387 |
+
{/* ── Chat panel ──────────────────────────────────────────────────── */}
|
| 388 |
+
<div className="iv-chat-panel">
|
| 389 |
+
|
| 390 |
+
{/* messages */}
|
| 391 |
+
<div className="iv-messages">
|
| 392 |
+
{conversation.length === 0 ? (
|
| 393 |
+
<div className="iv-empty-chat">
|
| 394 |
+
<Bot size={40} style={{ opacity: 0.15 }} />
|
| 395 |
+
<span>INITIALIZE QUERY SEQUENCE TO BEGIN GRAPH ANALYSIS…</span>
|
| 396 |
+
<div className="iv-empty-hints">
|
| 397 |
+
<span>Try: "Summarize the main entities in this document"</span>
|
| 398 |
+
<span>Try: "What relationships exist between X and Y?"</span>
|
| 399 |
+
<span>Try: "Find all mentions of [topic] and their context"</span>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
) : (
|
| 403 |
+
conversation.map((msg, idx) => (
|
| 404 |
+
<div key={idx} className={`iv-msg-row ${msg.role}`}>
|
| 405 |
+
<div className="iv-msg-avatar">
|
| 406 |
+
{msg.role === 'user' ? <UserIcon size={18} /> : <Bot size={18} />}
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
<div className="iv-msg-card">
|
| 410 |
+
{/* message header */}
|
| 411 |
+
<div className="iv-msg-header">
|
| 412 |
+
<span className="iv-msg-role">
|
| 413 |
+
{msg.role === 'user' ? 'YOU' : 'GRAPH REASONING SYSTEM'}
|
| 414 |
+
</span>
|
| 415 |
+
|
| 416 |
+
{msg.role === 'assistant' && msg.confidence != null && (
|
| 417 |
+
<div className="iv-msg-badges">
|
| 418 |
+
{msg.drift_expanded && (
|
| 419 |
+
<span className="iv-badge" style={{ background: '#3b82f6' }}>
|
| 420 |
+
DRIFT EXPANDED
|
| 421 |
+
</span>
|
| 422 |
+
)}
|
| 423 |
+
<span
|
| 424 |
+
className="iv-badge"
|
| 425 |
+
style={{ background: confColor(msg.confidence) }}
|
| 426 |
+
title={`Confidence: ${(msg.confidence * 100).toFixed(1)}%`}
|
| 427 |
+
>
|
| 428 |
+
{(msg.confidence * 100).toFixed(0)}% CONF
|
| 429 |
+
</span>
|
| 430 |
+
{msg.hallucination_risk && (
|
| 431 |
+
<span
|
| 432 |
+
className="iv-badge-outline"
|
| 433 |
+
style={{ color: riskColor(msg.hallucination_risk), borderColor: riskColor(msg.hallucination_risk) }}
|
| 434 |
+
title={msg.confidence_reasoning}
|
| 435 |
+
>
|
| 436 |
+
RISK: {msg.hallucination_risk.toUpperCase()}
|
| 437 |
+
</span>
|
| 438 |
+
)}
|
| 439 |
+
</div>
|
| 440 |
+
)}
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
{/* reasoning steps */}
|
| 444 |
+
{msg.role === 'assistant' && msg.reasoning?.length > 0 && (
|
| 445 |
+
<div className="iv-reasoning">
|
| 446 |
+
{msg.reasoning.map((step: string, si: number) => (
|
| 447 |
+
<div key={si} className="iv-reasoning-step">
|
| 448 |
+
<span className="iv-step-idx">{si + 1}</span>
|
| 449 |
+
<span>{step}</span>
|
| 450 |
+
</div>
|
| 451 |
+
))}
|
| 452 |
+
</div>
|
| 453 |
+
)}
|
| 454 |
+
|
| 455 |
+
{/* content */}
|
| 456 |
+
<div className="iv-msg-content">
|
| 457 |
+
{msg.role === 'assistant' && msg.content === '' && loading && idx === conversation.length - 1 ? (
|
| 458 |
+
<span className="iv-cursor">██</span>
|
| 459 |
+
) : (
|
| 460 |
+
<div className="iv-markdown">
|
| 461 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
| 462 |
+
</div>
|
| 463 |
+
)}
|
| 464 |
+
</div>
|
| 465 |
+
|
| 466 |
+
{/* sources */}
|
| 467 |
+
{msg.sources?.length > 0 && (
|
| 468 |
+
<div className="iv-sources">
|
| 469 |
+
<div className="iv-sources-top">
|
| 470 |
+
<div className="iv-sources-label">
|
| 471 |
+
SOURCES:
|
| 472 |
+
<div className="iv-source-chips">
|
| 473 |
+
{msg.sources
|
| 474 |
+
.filter((v: any, i: number, a: any[]) =>
|
| 475 |
+
a.findIndex(t => (t.metadata?.file_name || t.document_id) === (v.metadata?.file_name || v.document_id)) === i
|
| 476 |
+
)
|
| 477 |
+
.map((s: any, si: number) => (
|
| 478 |
+
<button
|
| 479 |
+
key={si}
|
| 480 |
+
className="iv-source-chip"
|
| 481 |
+
onClick={() => setDrawerSource(s)}
|
| 482 |
+
title="Click to view source text"
|
| 483 |
+
>
|
| 484 |
+
{s.metadata?.file_name || s.document_id}
|
| 485 |
+
</button>
|
| 486 |
+
))}
|
| 487 |
+
</div>
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
{msg.role === 'assistant' && !msg.eval_result && (
|
| 491 |
+
<button
|
| 492 |
+
className="iv-eval-btn"
|
| 493 |
+
onClick={() => runInlineEval(idx)}
|
| 494 |
+
disabled={msg.evaluating}
|
| 495 |
+
>
|
| 496 |
+
{msg.evaluating ? 'EVALUATING…' : 'EVALUATE QUALITY'}
|
| 497 |
+
</button>
|
| 498 |
+
)}
|
| 499 |
+
</div>
|
| 500 |
+
|
| 501 |
+
{/* eval results */}
|
| 502 |
+
{msg.eval_result && (
|
| 503 |
+
<div className="iv-eval-result">
|
| 504 |
+
<div className="iv-eval-title">EVALUATION RESULTS</div>
|
| 505 |
+
<div className="iv-eval-grid">
|
| 506 |
+
{[
|
| 507 |
+
{ label: 'OVERALL', value: msg.eval_result.overall_score ?? (msg.eval_result.faithfulness * 0.5 + (msg.eval_result.answer_relevancy || msg.eval_result.relevancy || 0) * 0.3 + (msg.eval_result.context_precision || msg.eval_result.precision || 0) * 0.2) },
|
| 508 |
+
{ label: 'FAITHFULNESS', value: msg.eval_result.faithfulness },
|
| 509 |
+
{ label: 'RELEVANCY', value: msg.eval_result.answer_relevancy ?? msg.eval_result.relevancy },
|
| 510 |
+
{ label: 'PRECISION', value: msg.eval_result.context_precision ?? msg.eval_result.precision }
|
| 511 |
+
].map((m, mi) => {
|
| 512 |
+
const val = typeof m.value === 'number' ? m.value : 0;
|
| 513 |
+
const pct = Math.round(val * 100);
|
| 514 |
+
return (
|
| 515 |
+
<div key={mi} className="iv-eval-metric">
|
| 516 |
+
<div className="iv-eval-label">{m.label}</div>
|
| 517 |
+
<div className="iv-eval-bar-wrap">
|
| 518 |
+
<div className="iv-eval-bar" style={{ width: `${pct}%`, background: confColor(val) }} />
|
| 519 |
+
</div>
|
| 520 |
+
<div className="iv-eval-pct" style={{ color: confColor(val) }}>{pct}%</div>
|
| 521 |
+
</div>
|
| 522 |
+
);
|
| 523 |
+
})}
|
| 524 |
+
</div>
|
| 525 |
+
</div>
|
| 526 |
+
)}
|
| 527 |
+
</div>
|
| 528 |
+
)}
|
| 529 |
+
</div>
|
| 530 |
+
</div>
|
| 531 |
+
))
|
| 532 |
+
)}
|
| 533 |
+
<div ref={endOfChatRef} />
|
| 534 |
+
</div>
|
| 535 |
+
|
| 536 |
+
{/* ── Input area ─────────────────────────────────────────────── */}
|
| 537 |
+
<form className="iv-input-area" onSubmit={handleSubmit}>
|
| 538 |
+
{/* Mode + agent ID row */}
|
| 539 |
+
<div className="iv-input-controls">
|
| 540 |
+
<div className="iv-ctrl-group">
|
| 541 |
+
<label className="iv-ctrl-label">INTERACTION MODE</label>
|
| 542 |
+
<div className="iv-select-wrap">
|
| 543 |
+
<select
|
| 544 |
+
className="iv-select iv-select-dark"
|
| 545 |
+
value={mode}
|
| 546 |
+
onChange={e => setMode(e.target.value as 'rag' | 'simulation')}
|
| 547 |
+
>
|
| 548 |
+
<option value="rag">STANDARD (GRAPH LOGIC)</option>
|
| 549 |
+
<option value="simulation">GOD-MODE (PERSONA INTERVIEW)</option>
|
| 550 |
+
</select>
|
| 551 |
+
<ChevronDown size={13} className="iv-select-chevron" />
|
| 552 |
+
</div>
|
| 553 |
+
</div>
|
| 554 |
+
|
| 555 |
+
{mode === 'simulation' && (
|
| 556 |
+
<div className="iv-ctrl-group">
|
| 557 |
+
<label className="iv-ctrl-label" style={{ color: '#f59e0b' }}>TARGET PERSONA (GOD MODE)</label>
|
| 558 |
+
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
| 559 |
+
<div className="iv-select-wrap">
|
| 560 |
+
<select
|
| 561 |
+
className="iv-select iv-select-dark"
|
| 562 |
+
style={{ borderColor: '#f59e0b', width: '200px' }}
|
| 563 |
+
value={agentNodes.some(n => n.id === agentId) ? agentId : ""}
|
| 564 |
+
onChange={e => setAgentId(e.target.value)}
|
| 565 |
+
>
|
| 566 |
+
<option value="" disabled>Select from graph...</option>
|
| 567 |
+
{agentNodes.map(n => (
|
| 568 |
+
<option key={n.id} value={n.id}>
|
| 569 |
+
[{n.type}] {n.label.length > 20 ? n.label.substring(0, 18) + '…' : n.label}
|
| 570 |
+
</option>
|
| 571 |
+
))}
|
| 572 |
+
</select>
|
| 573 |
+
<ChevronDown size={13} className="iv-select-chevron" />
|
| 574 |
+
</div>
|
| 575 |
+
<span style={{ color: '#666', fontSize: '0.65rem', fontFamily: 'var(--font-mono)' }}>OR</span>
|
| 576 |
+
<input
|
| 577 |
+
type="text"
|
| 578 |
+
value={agentId}
|
| 579 |
+
onChange={e => setAgentId(e.target.value)}
|
| 580 |
+
placeholder="Paste UUID..."
|
| 581 |
+
className="iv-agent-input"
|
| 582 |
+
style={{ width: '120px' }}
|
| 583 |
+
/>
|
| 584 |
+
</div>
|
| 585 |
+
</div>
|
| 586 |
+
)}
|
| 587 |
+
|
| 588 |
+
{/* Document scope chip (shows when doc selected) */}
|
| 589 |
+
{selectedDocId && (
|
| 590 |
+
<div className="iv-scope-chip">
|
| 591 |
+
<FileText size={12} />
|
| 592 |
+
<span>{documents.find(d => d.id === selectedDocId)?.filename?.substring(0, 24) || 'Filtered'}</span>
|
| 593 |
+
<button type="button" onClick={() => setSelectedDocId('')} title="Clear filter">
|
| 594 |
+
<X size={11} />
|
| 595 |
+
</button>
|
| 596 |
+
</div>
|
| 597 |
+
)}
|
| 598 |
+
</div>
|
| 599 |
+
|
| 600 |
+
{/* Text input row */}
|
| 601 |
+
<div className="iv-input-row">
|
| 602 |
+
<span className="iv-prompt-marker">></span>
|
| 603 |
+
<input
|
| 604 |
+
ref={inputRef}
|
| 605 |
+
type="text"
|
| 606 |
+
value={query}
|
| 607 |
+
onChange={e => setQuery(e.target.value)}
|
| 608 |
+
disabled={loading || (mode === 'simulation' && !agentId)}
|
| 609 |
+
placeholder={
|
| 610 |
+
mode === 'simulation'
|
| 611 |
+
? agentId ? 'INTERVIEW AGENT…' : 'ENTER AGENT ID ABOVE FIRST…'
|
| 612 |
+
: 'ENTER QUERY DIRECTIVE…'
|
| 613 |
+
}
|
| 614 |
+
className="iv-text-input"
|
| 615 |
+
autoComplete="off"
|
| 616 |
+
/>
|
| 617 |
+
<button
|
| 618 |
+
type="submit"
|
| 619 |
+
className="iv-send-btn"
|
| 620 |
+
disabled={!query.trim() || loading || (mode === 'simulation' && !agentId)}
|
| 621 |
+
title="Send (Enter)"
|
| 622 |
+
>
|
| 623 |
+
{loading ? (
|
| 624 |
+
<span className="iv-spinner" />
|
| 625 |
+
) : (
|
| 626 |
+
<Send size={18} />
|
| 627 |
+
)}
|
| 628 |
+
</button>
|
| 629 |
+
</div>
|
| 630 |
+
</form>
|
| 631 |
+
</div>
|
| 632 |
+
</div>
|
| 633 |
+
|
| 634 |
+
{/* ── Source detail drawer ─────────────────────────────────────────── */}
|
| 635 |
+
{drawerSource && (
|
| 636 |
+
<div className="iv-drawer-overlay" onClick={() => setDrawerSource(null)}>
|
| 637 |
+
<div className="iv-drawer" onClick={e => e.stopPropagation()}>
|
| 638 |
+
<div className="iv-drawer-header">
|
| 639 |
+
<h3 className="mono-text">SOURCE DETAIL</h3>
|
| 640 |
+
<button className="iv-drawer-close" onClick={() => setDrawerSource(null)}>
|
| 641 |
+
<X size={16} />
|
| 642 |
+
</button>
|
| 643 |
+
</div>
|
| 644 |
+
<div className="iv-drawer-body">
|
| 645 |
+
<div className="iv-drawer-meta">
|
| 646 |
+
<div className="iv-meta-row">
|
| 647 |
+
<span className="iv-meta-key">DOCUMENT</span>
|
| 648 |
+
<span>{drawerSource.metadata?.file_name || drawerSource.document_id || '—'}</span>
|
| 649 |
+
</div>
|
| 650 |
+
<div className="iv-meta-row">
|
| 651 |
+
<span className="iv-meta-key">RELEVANCE</span>
|
| 652 |
+
<span>{drawerSource.score != null ? (drawerSource.score * 100).toFixed(1) + '%' : 'N/A'}</span>
|
| 653 |
+
</div>
|
| 654 |
+
<div className="iv-meta-row">
|
| 655 |
+
<span className="iv-meta-key">CHUNK ID</span>
|
| 656 |
+
<span style={{ wordBreak: 'break-all' }}>{drawerSource.id || '—'}</span>
|
| 657 |
+
</div>
|
| 658 |
+
</div>
|
| 659 |
+
<hr className="iv-drawer-divider" />
|
| 660 |
+
<div className="iv-drawer-text">{drawerSource.text || 'No text available.'}</div>
|
| 661 |
+
</div>
|
| 662 |
+
</div>
|
| 663 |
+
</div>
|
| 664 |
+
)}
|
| 665 |
+
|
| 666 |
+
{/* ── Scoped styles ────────────────────────────────────────────────── */}
|
| 667 |
+
<style>{`
|
| 668 |
+
/* ── Root layout ───────────────────────────────────────────── */
|
| 669 |
+
.iv-root {
|
| 670 |
+
height: calc(100vh - 62px);
|
| 671 |
+
display: flex;
|
| 672 |
+
flex-direction: column;
|
| 673 |
+
background: var(--bg-color);
|
| 674 |
+
overflow: hidden;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
/* ── Header ───────────────────────────────────────────────── */
|
| 678 |
+
.iv-header {
|
| 679 |
+
display: flex;
|
| 680 |
+
align-items: center;
|
| 681 |
+
justify-content: space-between;
|
| 682 |
+
gap: 1rem;
|
| 683 |
+
padding: 0.65rem 1.5rem;
|
| 684 |
+
border-bottom: 3px solid var(--border-color);
|
| 685 |
+
flex-shrink: 0;
|
| 686 |
+
flex-wrap: wrap;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.iv-header-left {
|
| 690 |
+
display: flex;
|
| 691 |
+
align-items: center;
|
| 692 |
+
gap: 0.75rem;
|
| 693 |
+
min-width: 0;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.iv-title {
|
| 697 |
+
font-size: 1rem;
|
| 698 |
+
letter-spacing: 2px;
|
| 699 |
+
margin: 0;
|
| 700 |
+
line-height: 1.1;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
.iv-breadcrumb {
|
| 704 |
+
font-size: 0.7rem;
|
| 705 |
+
color: var(--muted-color);
|
| 706 |
+
margin: 0;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
.iv-sidebar-toggle {
|
| 710 |
+
border: 2px solid var(--border-color);
|
| 711 |
+
background: var(--bg-color);
|
| 712 |
+
color: var(--text-color);
|
| 713 |
+
width: 34px;
|
| 714 |
+
height: 34px;
|
| 715 |
+
display: flex;
|
| 716 |
+
align-items: center;
|
| 717 |
+
justify-content: center;
|
| 718 |
+
cursor: pointer;
|
| 719 |
+
flex-shrink: 0;
|
| 720 |
+
transition: all 0.13s;
|
| 721 |
+
}
|
| 722 |
+
.iv-sidebar-toggle:hover { background: var(--text-color); color: var(--bg-color); }
|
| 723 |
+
|
| 724 |
+
.iv-header-right {
|
| 725 |
+
display: flex;
|
| 726 |
+
align-items: flex-end;
|
| 727 |
+
gap: 0.75rem;
|
| 728 |
+
flex-wrap: wrap;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
/* ── Control group ─────────────────────────────────────────── */
|
| 732 |
+
.iv-ctrl-group {
|
| 733 |
+
display: flex;
|
| 734 |
+
flex-direction: column;
|
| 735 |
+
gap: 2px;
|
| 736 |
+
}
|
| 737 |
+
.iv-ctrl-label {
|
| 738 |
+
font-family: var(--font-mono);
|
| 739 |
+
font-size: 0.6rem;
|
| 740 |
+
font-weight: 700;
|
| 741 |
+
color: var(--muted-color);
|
| 742 |
+
letter-spacing: 1px;
|
| 743 |
+
text-transform: uppercase;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
/* ── Uniform select ────────────────────────────────────────── */
|
| 747 |
+
.iv-select-wrap {
|
| 748 |
+
position: relative;
|
| 749 |
+
display: inline-flex;
|
| 750 |
+
align-items: center;
|
| 751 |
+
}
|
| 752 |
+
.iv-select-chevron {
|
| 753 |
+
position: absolute;
|
| 754 |
+
right: 8px;
|
| 755 |
+
pointer-events: none;
|
| 756 |
+
color: var(--muted-color);
|
| 757 |
+
}
|
| 758 |
+
.iv-select {
|
| 759 |
+
font-family: var(--font-mono);
|
| 760 |
+
font-size: 0.82rem;
|
| 761 |
+
font-weight: 700;
|
| 762 |
+
border: 2px solid var(--border-color);
|
| 763 |
+
background: var(--bg-color);
|
| 764 |
+
color: var(--text-color);
|
| 765 |
+
padding: 0.32rem 2rem 0.32rem 0.65rem;
|
| 766 |
+
cursor: pointer;
|
| 767 |
+
appearance: none;
|
| 768 |
+
-webkit-appearance: none;
|
| 769 |
+
outline: none;
|
| 770 |
+
max-width: 260px;
|
| 771 |
+
}
|
| 772 |
+
.iv-select:focus { box-shadow: 2px 2px 0 var(--border-color); }
|
| 773 |
+
|
| 774 |
+
/* Dark variant (input area) */
|
| 775 |
+
.iv-select-dark {
|
| 776 |
+
background: #111;
|
| 777 |
+
color: #e5e7eb;
|
| 778 |
+
border-color: #333;
|
| 779 |
+
}
|
| 780 |
+
.iv-select-dark option { background: #111; color: #e5e7eb; }
|
| 781 |
+
|
| 782 |
+
/* ── GoT button ────────────────────────────────────────────── */
|
| 783 |
+
.iv-got-btn {
|
| 784 |
+
display: inline-flex;
|
| 785 |
+
align-items: center;
|
| 786 |
+
gap: 5px;
|
| 787 |
+
border: 2px solid var(--border-color);
|
| 788 |
+
background: var(--bg-color);
|
| 789 |
+
color: var(--text-color);
|
| 790 |
+
font-family: var(--font-mono);
|
| 791 |
+
font-size: 0.75rem;
|
| 792 |
+
font-weight: 700;
|
| 793 |
+
padding: 0.32rem 0.75rem;
|
| 794 |
+
cursor: pointer;
|
| 795 |
+
transition: all 0.13s;
|
| 796 |
+
white-space: nowrap;
|
| 797 |
+
}
|
| 798 |
+
.iv-got-btn.active { background: #000; color: #fff; border-color: #000; }
|
| 799 |
+
.iv-got-btn:hover:not(.active) { background: #f3f4f6; }
|
| 800 |
+
|
| 801 |
+
/* ── Info bar ──────────────────────────────────────────────── */
|
| 802 |
+
.iv-info-bar {
|
| 803 |
+
display: flex;
|
| 804 |
+
align-items: flex-start;
|
| 805 |
+
gap: 0.5rem;
|
| 806 |
+
padding: 0.45rem 1.5rem;
|
| 807 |
+
background: var(--surface-color);
|
| 808 |
+
border-bottom: 1px solid #e5e5e5;
|
| 809 |
+
font-size: 0.75rem;
|
| 810 |
+
color: var(--muted-color);
|
| 811 |
+
line-height: 1.5;
|
| 812 |
+
flex-shrink: 0;
|
| 813 |
+
}
|
| 814 |
+
.iv-info-bar strong { color: var(--text-color); }
|
| 815 |
+
.iv-info-bar kbd {
|
| 816 |
+
background: #e5e7eb; border: 1px solid #d1d5db;
|
| 817 |
+
border-radius: 3px; padding: 0 4px;
|
| 818 |
+
font-family: var(--font-mono); font-size: 0.7rem;
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
/* ── Body: sidebar + chat ──────────────────────────────────── */
|
| 822 |
+
.iv-body {
|
| 823 |
+
display: flex;
|
| 824 |
+
flex: 1;
|
| 825 |
+
min-height: 0;
|
| 826 |
+
overflow: hidden;
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
/* ── Sidebar ───────────────────────────────────────────────── */
|
| 830 |
+
.iv-sidebar {
|
| 831 |
+
display: flex;
|
| 832 |
+
flex-direction: column;
|
| 833 |
+
border-right: 3px solid var(--border-color);
|
| 834 |
+
background: var(--bg-color);
|
| 835 |
+
flex-shrink: 0;
|
| 836 |
+
overflow: hidden;
|
| 837 |
+
transition: width 0.22s ease;
|
| 838 |
+
}
|
| 839 |
+
.iv-sidebar.open { width: 220px; }
|
| 840 |
+
.iv-sidebar.closed { width: 0; border-right: none; }
|
| 841 |
+
|
| 842 |
+
.iv-new-thread {
|
| 843 |
+
width: 100%;
|
| 844 |
+
border: none;
|
| 845 |
+
border-bottom: 3px solid var(--border-color);
|
| 846 |
+
padding: 0.9rem 1rem;
|
| 847 |
+
background: #000;
|
| 848 |
+
color: #fff;
|
| 849 |
+
font-family: var(--font-mono);
|
| 850 |
+
font-size: 0.82rem;
|
| 851 |
+
font-weight: 700;
|
| 852 |
+
letter-spacing: 1px;
|
| 853 |
+
cursor: pointer;
|
| 854 |
+
flex-shrink: 0;
|
| 855 |
+
text-align: left;
|
| 856 |
+
transition: background 0.13s;
|
| 857 |
+
white-space: nowrap;
|
| 858 |
+
}
|
| 859 |
+
.iv-new-thread:hover { background: #222; }
|
| 860 |
+
|
| 861 |
+
.iv-thread-list {
|
| 862 |
+
flex: 1;
|
| 863 |
+
overflow-y: auto;
|
| 864 |
+
padding: 0.75rem;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.iv-thread-header {
|
| 868 |
+
font-family: var(--font-mono);
|
| 869 |
+
font-size: 0.6rem;
|
| 870 |
+
font-weight: 700;
|
| 871 |
+
color: var(--muted-color);
|
| 872 |
+
letter-spacing: 1px;
|
| 873 |
+
border-bottom: 1px dotted var(--border-color);
|
| 874 |
+
padding-bottom: 0.4rem;
|
| 875 |
+
margin-bottom: 0.6rem;
|
| 876 |
+
white-space: nowrap;
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
.iv-thread-item {
|
| 880 |
+
padding: 0.6rem 0.7rem;
|
| 881 |
+
border: 1.5px solid var(--border-color);
|
| 882 |
+
cursor: pointer;
|
| 883 |
+
margin-bottom: 0.4rem;
|
| 884 |
+
transition: background 0.13s;
|
| 885 |
+
}
|
| 886 |
+
.iv-thread-item:hover, .iv-thread-item.active {
|
| 887 |
+
background: var(--text-color);
|
| 888 |
+
color: var(--bg-color);
|
| 889 |
+
border-color: var(--text-color);
|
| 890 |
+
}
|
| 891 |
+
.iv-thread-title {
|
| 892 |
+
font-family: var(--font-mono);
|
| 893 |
+
font-size: 0.78rem;
|
| 894 |
+
font-weight: 600;
|
| 895 |
+
white-space: nowrap;
|
| 896 |
+
overflow: hidden;
|
| 897 |
+
text-overflow: ellipsis;
|
| 898 |
+
}
|
| 899 |
+
.iv-thread-date {
|
| 900 |
+
font-family: var(--font-mono);
|
| 901 |
+
font-size: 0.65rem;
|
| 902 |
+
opacity: 0.65;
|
| 903 |
+
margin-top: 3px;
|
| 904 |
+
}
|
| 905 |
+
.iv-empty-threads {
|
| 906 |
+
font-family: var(--font-mono);
|
| 907 |
+
color: #bbb;
|
| 908 |
+
font-size: 0.78rem;
|
| 909 |
+
text-align: center;
|
| 910 |
+
padding: 1.5rem 0;
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
/* ── Chat panel ────────────────────────────────────────────── */
|
| 914 |
+
.iv-chat-panel {
|
| 915 |
+
flex: 1;
|
| 916 |
+
min-width: 0;
|
| 917 |
+
display: flex;
|
| 918 |
+
flex-direction: column;
|
| 919 |
+
overflow: hidden;
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
/* ── Messages ──────────────────────────────────────────────── */
|
| 923 |
+
.iv-messages {
|
| 924 |
+
flex: 1;
|
| 925 |
+
overflow-y: auto;
|
| 926 |
+
padding: 1.5rem;
|
| 927 |
+
display: flex;
|
| 928 |
+
flex-direction: column;
|
| 929 |
+
gap: 1.5rem;
|
| 930 |
+
scroll-behavior: smooth;
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
.iv-empty-chat {
|
| 934 |
+
flex: 1;
|
| 935 |
+
display: flex;
|
| 936 |
+
flex-direction: column;
|
| 937 |
+
align-items: center;
|
| 938 |
+
justify-content: center;
|
| 939 |
+
gap: 1rem;
|
| 940 |
+
color: var(--muted-color);
|
| 941 |
+
font-family: var(--font-mono);
|
| 942 |
+
font-size: 0.88rem;
|
| 943 |
+
letter-spacing: 1.5px;
|
| 944 |
+
text-align: center;
|
| 945 |
+
padding: 3rem;
|
| 946 |
+
}
|
| 947 |
+
.iv-empty-hints {
|
| 948 |
+
display: flex;
|
| 949 |
+
flex-direction: column;
|
| 950 |
+
gap: 0.4rem;
|
| 951 |
+
margin-top: 0.75rem;
|
| 952 |
+
font-size: 0.72rem;
|
| 953 |
+
opacity: 0.6;
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
/* ── Message row ─────────────────────────────────────────── */
|
| 957 |
+
.iv-msg-row {
|
| 958 |
+
display: flex;
|
| 959 |
+
gap: 1rem;
|
| 960 |
+
align-items: flex-start;
|
| 961 |
+
max-width: 90%;
|
| 962 |
+
}
|
| 963 |
+
.iv-msg-row.user {
|
| 964 |
+
align-self: flex-end;
|
| 965 |
+
flex-direction: row-reverse;
|
| 966 |
+
max-width: 72%;
|
| 967 |
+
}
|
| 968 |
+
.iv-msg-row.assistant { align-self: flex-start; max-width: 90%; }
|
| 969 |
+
|
| 970 |
+
.iv-msg-avatar {
|
| 971 |
+
width: 36px;
|
| 972 |
+
height: 36px;
|
| 973 |
+
border: 2px solid var(--border-color);
|
| 974 |
+
display: flex;
|
| 975 |
+
align-items: center;
|
| 976 |
+
justify-content: center;
|
| 977 |
+
flex-shrink: 0;
|
| 978 |
+
background: var(--bg-color);
|
| 979 |
+
}
|
| 980 |
+
.iv-msg-row.assistant .iv-msg-avatar {
|
| 981 |
+
background: #000;
|
| 982 |
+
color: #fff;
|
| 983 |
+
border-color: #000;
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
.iv-msg-card {
|
| 987 |
+
flex: 1;
|
| 988 |
+
border: 2px solid var(--border-color);
|
| 989 |
+
background: var(--bg-color);
|
| 990 |
+
min-width: 0;
|
| 991 |
+
box-shadow: 4px 4px 0 rgba(0,0,0,0.05);
|
| 992 |
+
}
|
| 993 |
+
.iv-msg-row.assistant .iv-msg-card {
|
| 994 |
+
border-left: 4px solid #000;
|
| 995 |
+
}
|
| 996 |
+
.iv-msg-row.user .iv-msg-card {
|
| 997 |
+
border-color: #000;
|
| 998 |
+
background: #f8f8f8;
|
| 999 |
+
border-right: 4px solid #000;
|
| 1000 |
+
}
|
| 1001 |
+
|
| 1002 |
+
.iv-msg-header {
|
| 1003 |
+
display: flex;
|
| 1004 |
+
align-items: center;
|
| 1005 |
+
justify-content: space-between;
|
| 1006 |
+
flex-wrap: wrap;
|
| 1007 |
+
gap: 0.5rem;
|
| 1008 |
+
padding: 0.5rem 0.85rem;
|
| 1009 |
+
border-bottom: 1px dotted var(--border-color);
|
| 1010 |
+
background: var(--surface-color);
|
| 1011 |
+
}
|
| 1012 |
+
.iv-msg-role {
|
| 1013 |
+
font-family: var(--font-mono);
|
| 1014 |
+
font-size: 0.68rem;
|
| 1015 |
+
font-weight: 700;
|
| 1016 |
+
letter-spacing: 1px;
|
| 1017 |
+
color: var(--muted-color);
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
.iv-msg-badges {
|
| 1021 |
+
display: flex;
|
| 1022 |
+
align-items: center;
|
| 1023 |
+
gap: 0.4rem;
|
| 1024 |
+
flex-wrap: wrap;
|
| 1025 |
+
}
|
| 1026 |
+
.iv-badge {
|
| 1027 |
+
font-family: var(--font-mono);
|
| 1028 |
+
font-size: 0.65rem;
|
| 1029 |
+
font-weight: 700;
|
| 1030 |
+
color: #fff;
|
| 1031 |
+
padding: 1px 7px;
|
| 1032 |
+
letter-spacing: 0.3px;
|
| 1033 |
+
}
|
| 1034 |
+
.iv-badge-outline {
|
| 1035 |
+
font-family: var(--font-mono);
|
| 1036 |
+
font-size: 0.65rem;
|
| 1037 |
+
font-weight: 700;
|
| 1038 |
+
padding: 1px 6px;
|
| 1039 |
+
border: 1.5px solid;
|
| 1040 |
+
cursor: help;
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
/* ── Reasoning chain ─────────────────────────────────────── */
|
| 1044 |
+
.iv-reasoning {
|
| 1045 |
+
padding: 0.75rem 0.85rem;
|
| 1046 |
+
background: #fafafa;
|
| 1047 |
+
border-bottom: 1px dotted var(--border-color);
|
| 1048 |
+
display: flex;
|
| 1049 |
+
flex-direction: column;
|
| 1050 |
+
gap: 3px;
|
| 1051 |
+
}
|
| 1052 |
+
.iv-reasoning-step {
|
| 1053 |
+
display: flex;
|
| 1054 |
+
gap: 0.5rem;
|
| 1055 |
+
font-family: var(--font-mono);
|
| 1056 |
+
font-size: 0.75rem;
|
| 1057 |
+
color: #555;
|
| 1058 |
+
line-height: 1.5;
|
| 1059 |
+
}
|
| 1060 |
+
.iv-step-idx {
|
| 1061 |
+
background: #000;
|
| 1062 |
+
color: #fff;
|
| 1063 |
+
font-size: 0.6rem;
|
| 1064 |
+
font-weight: 700;
|
| 1065 |
+
width: 16px;
|
| 1066 |
+
height: 16px;
|
| 1067 |
+
display: inline-flex;
|
| 1068 |
+
align-items: center;
|
| 1069 |
+
justify-content: center;
|
| 1070 |
+
flex-shrink: 0;
|
| 1071 |
+
margin-top: 2px;
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
/* ── Message content ─────────────────────────────────────── */
|
| 1075 |
+
.iv-msg-content {
|
| 1076 |
+
padding: 0.85rem;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
.iv-markdown { font-family: var(--font-sans); font-size: 0.97rem; line-height: 1.8; }
|
| 1080 |
+
.iv-markdown p { margin-bottom: 0.75rem; }
|
| 1081 |
+
.iv-markdown p:last-child { margin-bottom: 0; }
|
| 1082 |
+
.iv-markdown ul, .iv-markdown ol { margin-bottom: 0.75rem; padding-left: 1.5rem; }
|
| 1083 |
+
.iv-markdown li { margin-bottom: 0.2rem; }
|
| 1084 |
+
.iv-markdown h1, .iv-markdown h2, .iv-markdown h3 { font-family: var(--font-display); margin: 1rem 0 0.5rem; }
|
| 1085 |
+
.iv-markdown code { font-family: var(--font-mono); background: #f3f4f6; padding: 1px 5px; font-size: 0.85em; }
|
| 1086 |
+
.iv-markdown pre { background: #1e293b; color: #e2e8f0; padding: 1rem; overflow-x: auto; margin-bottom: 0.75rem; }
|
| 1087 |
+
.iv-markdown pre code { background: transparent; color: inherit; padding: 0; }
|
| 1088 |
+
.iv-markdown table { border-collapse: collapse; width: 100%; margin-bottom: 0.75rem; font-size: 0.9rem; }
|
| 1089 |
+
.iv-markdown th, .iv-markdown td { border: 1px solid var(--border-color); padding: 0.4rem 0.6rem; }
|
| 1090 |
+
.iv-markdown th { background: var(--surface-color); font-family: var(--font-mono); font-size: 0.75rem; }
|
| 1091 |
+
.iv-markdown blockquote { border-left: 3px solid #000; margin: 0 0 0.75rem; padding: 0.5rem 0.75rem; color: #555; background: #fafafa; }
|
| 1092 |
+
|
| 1093 |
+
.iv-cursor { animation: blink 0.9s step-end infinite; font-size: 1.1rem; }
|
| 1094 |
+
@keyframes blink { 50% { opacity: 0; } }
|
| 1095 |
+
|
| 1096 |
+
/* ── Sources ──────────────────────────────────────────────── */
|
| 1097 |
+
.iv-sources {
|
| 1098 |
+
padding: 0.65rem 0.85rem;
|
| 1099 |
+
border-top: 1px dashed var(--border-color);
|
| 1100 |
+
background: #fafafa;
|
| 1101 |
+
}
|
| 1102 |
+
.iv-sources-top {
|
| 1103 |
+
display: flex;
|
| 1104 |
+
align-items: flex-start;
|
| 1105 |
+
justify-content: space-between;
|
| 1106 |
+
gap: 0.75rem;
|
| 1107 |
+
flex-wrap: wrap;
|
| 1108 |
+
}
|
| 1109 |
+
.iv-sources-label {
|
| 1110 |
+
font-family: var(--font-mono);
|
| 1111 |
+
font-size: 0.7rem;
|
| 1112 |
+
font-weight: 700;
|
| 1113 |
+
color: var(--muted-color);
|
| 1114 |
+
display: flex;
|
| 1115 |
+
align-items: center;
|
| 1116 |
+
gap: 0.5rem;
|
| 1117 |
+
flex-wrap: wrap;
|
| 1118 |
+
}
|
| 1119 |
+
.iv-source-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
| 1120 |
+
.iv-source-chip {
|
| 1121 |
+
background: var(--bg-color);
|
| 1122 |
+
border: 1px solid var(--border-color);
|
| 1123 |
+
padding: 2px 8px;
|
| 1124 |
+
cursor: pointer;
|
| 1125 |
+
font-family: var(--font-mono);
|
| 1126 |
+
font-size: 0.7rem;
|
| 1127 |
+
transition: all 0.12s;
|
| 1128 |
+
}
|
| 1129 |
+
.iv-source-chip:hover { background: #000; color: #fff; border-color: #000; }
|
| 1130 |
+
|
| 1131 |
+
.iv-eval-btn {
|
| 1132 |
+
font-family: var(--font-mono);
|
| 1133 |
+
font-size: 0.68rem;
|
| 1134 |
+
font-weight: 700;
|
| 1135 |
+
border: 2px solid #000;
|
| 1136 |
+
background: var(--bg-color);
|
| 1137 |
+
color: var(--text-color);
|
| 1138 |
+
padding: 2px 10px;
|
| 1139 |
+
cursor: pointer;
|
| 1140 |
+
letter-spacing: 0.5px;
|
| 1141 |
+
white-space: nowrap;
|
| 1142 |
+
transition: all 0.12s;
|
| 1143 |
+
flex-shrink: 0;
|
| 1144 |
+
}
|
| 1145 |
+
.iv-eval-btn:hover:not(:disabled) { background: #000; color: #fff; }
|
| 1146 |
+
.iv-eval-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 1147 |
+
|
| 1148 |
+
/* ── Eval results ─────────────────────────────────────────── */
|
| 1149 |
+
.iv-eval-result {
|
| 1150 |
+
margin-top: 0.65rem;
|
| 1151 |
+
padding: 0.75rem;
|
| 1152 |
+
border: 1px solid var(--border-color);
|
| 1153 |
+
background: var(--bg-color);
|
| 1154 |
+
}
|
| 1155 |
+
.iv-eval-title {
|
| 1156 |
+
font-family: var(--font-mono);
|
| 1157 |
+
font-size: 0.65rem;
|
| 1158 |
+
font-weight: 700;
|
| 1159 |
+
color: var(--muted-color);
|
| 1160 |
+
letter-spacing: 1px;
|
| 1161 |
+
margin-bottom: 0.6rem;
|
| 1162 |
+
}
|
| 1163 |
+
.iv-eval-grid { display: flex; flex-direction: column; gap: 0.4rem; }
|
| 1164 |
+
.iv-eval-metric { display: flex; align-items: center; gap: 0.6rem; }
|
| 1165 |
+
.iv-eval-label { font-family: var(--font-mono); font-size: 0.65rem; color: #666; width: 80px; flex-shrink: 0; }
|
| 1166 |
+
.iv-eval-bar-wrap { flex: 1; height: 5px; background: #e5e7eb; }
|
| 1167 |
+
.iv-eval-bar { height: 100%; transition: width 0.4s ease; }
|
| 1168 |
+
.iv-eval-pct { font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; width: 36px; text-align: right; }
|
| 1169 |
+
|
| 1170 |
+
/* ── Input area ───────────────────────────────────────────── */
|
| 1171 |
+
.iv-input-area {
|
| 1172 |
+
border-top: 3px solid var(--border-color);
|
| 1173 |
+
background: #000;
|
| 1174 |
+
flex-shrink: 0;
|
| 1175 |
+
display: flex;
|
| 1176 |
+
flex-direction: column;
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
.iv-input-controls {
|
| 1180 |
+
display: flex;
|
| 1181 |
+
align-items: flex-end;
|
| 1182 |
+
gap: 1rem;
|
| 1183 |
+
padding: 0.55rem 1rem 0.45rem;
|
| 1184 |
+
border-bottom: 1px dotted #333;
|
| 1185 |
+
flex-wrap: wrap;
|
| 1186 |
+
}
|
| 1187 |
+
.iv-input-controls .iv-ctrl-label { color: #888; }
|
| 1188 |
+
.iv-input-controls .iv-select-chevron { color: #888; }
|
| 1189 |
+
|
| 1190 |
+
.iv-agent-input {
|
| 1191 |
+
font-family: var(--font-mono);
|
| 1192 |
+
font-size: 0.82rem;
|
| 1193 |
+
font-weight: 600;
|
| 1194 |
+
border: 2px solid #444;
|
| 1195 |
+
background: #111;
|
| 1196 |
+
color: #fff;
|
| 1197 |
+
padding: 0.32rem 0.65rem;
|
| 1198 |
+
width: 160px;
|
| 1199 |
+
outline: none;
|
| 1200 |
+
}
|
| 1201 |
+
.iv-agent-input:focus { border-color: #f59e0b; box-shadow: 2px 2px 0 #f59e0b; }
|
| 1202 |
+
.iv-agent-input::placeholder { color: #888; }
|
| 1203 |
+
|
| 1204 |
+
.iv-scope-chip {
|
| 1205 |
+
display: inline-flex;
|
| 1206 |
+
align-items: center;
|
| 1207 |
+
gap: 5px;
|
| 1208 |
+
border: 1.5px solid #444;
|
| 1209 |
+
background: #111;
|
| 1210 |
+
color: #aaa;
|
| 1211 |
+
font-family: var(--font-mono);
|
| 1212 |
+
font-size: 0.7rem;
|
| 1213 |
+
padding: 3px 8px;
|
| 1214 |
+
align-self: flex-end;
|
| 1215 |
+
margin-bottom: 2px;
|
| 1216 |
+
}
|
| 1217 |
+
.iv-scope-chip button {
|
| 1218 |
+
background: none;
|
| 1219 |
+
border: none;
|
| 1220 |
+
color: #aaa;
|
| 1221 |
+
cursor: pointer;
|
| 1222 |
+
padding: 0;
|
| 1223 |
+
display: flex;
|
| 1224 |
+
align-items: center;
|
| 1225 |
+
}
|
| 1226 |
+
.iv-scope-chip button:hover { color: #ef4444; }
|
| 1227 |
+
|
| 1228 |
+
/* ── Text input row ───────────────────────────────────────── */
|
| 1229 |
+
.iv-input-row {
|
| 1230 |
+
display: flex;
|
| 1231 |
+
align-items: center;
|
| 1232 |
+
padding: 0 0.5rem 0 1rem;
|
| 1233 |
+
gap: 0.5rem;
|
| 1234 |
+
min-height: 56px;
|
| 1235 |
+
}
|
| 1236 |
+
|
| 1237 |
+
.iv-prompt-marker {
|
| 1238 |
+
font-family: var(--font-mono);
|
| 1239 |
+
font-size: 1.3rem;
|
| 1240 |
+
font-weight: 700;
|
| 1241 |
+
color: #fff;
|
| 1242 |
+
flex-shrink: 0;
|
| 1243 |
+
user-select: none;
|
| 1244 |
+
}
|
| 1245 |
+
|
| 1246 |
+
.iv-text-input {
|
| 1247 |
+
flex: 1;
|
| 1248 |
+
background: transparent;
|
| 1249 |
+
border: none;
|
| 1250 |
+
outline: none;
|
| 1251 |
+
font-family: var(--font-mono);
|
| 1252 |
+
font-size: 1rem;
|
| 1253 |
+
color: #fff;
|
| 1254 |
+
padding: 0.75rem 0.5rem;
|
| 1255 |
+
caret-color: #fff;
|
| 1256 |
+
}
|
| 1257 |
+
.iv-text-input::placeholder { color: #666; }
|
| 1258 |
+
.iv-text-input:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 1259 |
+
|
| 1260 |
+
.iv-send-btn {
|
| 1261 |
+
width: 44px;
|
| 1262 |
+
height: 44px;
|
| 1263 |
+
border: 2px solid #444;
|
| 1264 |
+
background: #111;
|
| 1265 |
+
color: #888;
|
| 1266 |
+
display: flex;
|
| 1267 |
+
align-items: center;
|
| 1268 |
+
justify-content: center;
|
| 1269 |
+
cursor: pointer;
|
| 1270 |
+
flex-shrink: 0;
|
| 1271 |
+
transition: all 0.13s;
|
| 1272 |
+
}
|
| 1273 |
+
.iv-send-btn:hover:not(:disabled) { background: #fff; color: #000; border-color: #fff; }
|
| 1274 |
+
.iv-send-btn:disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
|
| 1275 |
+
|
| 1276 |
+
.iv-spinner {
|
| 1277 |
+
width: 18px;
|
| 1278 |
+
height: 18px;
|
| 1279 |
+
border: 2px solid #444;
|
| 1280 |
+
border-top-color: #fff;
|
| 1281 |
+
border-radius: 50%;
|
| 1282 |
+
animation: spin 0.8s linear infinite;
|
| 1283 |
+
}
|
| 1284 |
+
@keyframes spin { 100% { transform: rotate(360deg); } }
|
| 1285 |
+
|
| 1286 |
+
/* ── Source drawer (modal style) ─────────────────────────── */
|
| 1287 |
+
.iv-drawer-overlay {
|
| 1288 |
+
position: fixed;
|
| 1289 |
+
inset: 0;
|
| 1290 |
+
background: rgba(0,0,0,0.4);
|
| 1291 |
+
z-index: 999;
|
| 1292 |
+
display: flex;
|
| 1293 |
+
align-items: stretch;
|
| 1294 |
+
justify-content: flex-end;
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
+
.iv-drawer {
|
| 1298 |
+
width: min(420px, 95vw);
|
| 1299 |
+
background: var(--bg-color);
|
| 1300 |
+
border-left: 3px solid var(--border-color);
|
| 1301 |
+
display: flex;
|
| 1302 |
+
flex-direction: column;
|
| 1303 |
+
animation: slideIn 0.22s ease-out;
|
| 1304 |
+
box-shadow: -8px 0 32px rgba(0,0,0,0.15);
|
| 1305 |
+
}
|
| 1306 |
+
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
| 1307 |
+
|
| 1308 |
+
.iv-drawer-header {
|
| 1309 |
+
padding: 1rem 1.25rem;
|
| 1310 |
+
border-bottom: 3px solid var(--border-color);
|
| 1311 |
+
background: var(--surface-color);
|
| 1312 |
+
display: flex;
|
| 1313 |
+
align-items: center;
|
| 1314 |
+
justify-content: space-between;
|
| 1315 |
+
flex-shrink: 0;
|
| 1316 |
+
}
|
| 1317 |
+
.iv-drawer-header h3 { margin: 0; font-size: 0.85rem; }
|
| 1318 |
+
|
| 1319 |
+
.iv-drawer-close {
|
| 1320 |
+
width: 32px;
|
| 1321 |
+
height: 32px;
|
| 1322 |
+
border: 2px solid var(--border-color);
|
| 1323 |
+
background: var(--bg-color);
|
| 1324 |
+
color: var(--text-color);
|
| 1325 |
+
display: flex;
|
| 1326 |
+
align-items: center;
|
| 1327 |
+
justify-content: center;
|
| 1328 |
+
cursor: pointer;
|
| 1329 |
+
transition: all 0.12s;
|
| 1330 |
+
}
|
| 1331 |
+
.iv-drawer-close:hover { background: #000; color: #fff; border-color: #000; }
|
| 1332 |
+
|
| 1333 |
+
.iv-drawer-body {
|
| 1334 |
+
flex: 1;
|
| 1335 |
+
overflow-y: auto;
|
| 1336 |
+
padding: 1.25rem;
|
| 1337 |
+
}
|
| 1338 |
+
|
| 1339 |
+
.iv-drawer-meta { margin-bottom: 1rem; }
|
| 1340 |
+
.iv-meta-row {
|
| 1341 |
+
display: flex;
|
| 1342 |
+
gap: 0.75rem;
|
| 1343 |
+
margin-bottom: 0.5rem;
|
| 1344 |
+
font-family: var(--font-mono);
|
| 1345 |
+
font-size: 0.8rem;
|
| 1346 |
+
align-items: flex-start;
|
| 1347 |
+
}
|
| 1348 |
+
.iv-meta-key { font-weight: 700; color: var(--muted-color); min-width: 80px; flex-shrink: 0; }
|
| 1349 |
+
|
| 1350 |
+
.iv-drawer-divider {
|
| 1351 |
+
border: none;
|
| 1352 |
+
border-top: 1px dashed var(--border-color);
|
| 1353 |
+
margin: 0.75rem 0 1rem;
|
| 1354 |
+
}
|
| 1355 |
+
|
| 1356 |
+
.iv-drawer-text {
|
| 1357 |
+
font-family: var(--font-mono);
|
| 1358 |
+
font-size: 0.82rem;
|
| 1359 |
+
line-height: 1.7;
|
| 1360 |
+
white-space: pre-wrap;
|
| 1361 |
+
color: #444;
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
/* ── Responsive ──────────────────────────────────────────── */
|
| 1365 |
+
@media (max-width: 768px) {
|
| 1366 |
+
.iv-header { padding: 0.5rem 0.75rem; }
|
| 1367 |
+
.iv-title { font-size: 0.88rem; letter-spacing: 1px; }
|
| 1368 |
+
.iv-info-bar { display: none; }
|
| 1369 |
+
.iv-sidebar.open { width: 200px; }
|
| 1370 |
+
.iv-messages { padding: 1rem 0.75rem; }
|
| 1371 |
+
.iv-msg-row { max-width: 100% !important; }
|
| 1372 |
+
.iv-msg-avatar { width: 28px; height: 28px; }
|
| 1373 |
+
.iv-msg-card { font-size: 0.9rem; }
|
| 1374 |
+
.iv-msg-content { padding: 0.6rem; }
|
| 1375 |
+
.iv-input-controls { padding: 0.4rem 0.75rem; gap: 0.5rem; }
|
| 1376 |
+
.iv-input-row { min-height: 46px; }
|
| 1377 |
+
.iv-text-input { font-size: 0.9rem; }
|
| 1378 |
+
.iv-select { max-width: 200px; }
|
| 1379 |
+
}
|
| 1380 |
+
|
| 1381 |
+
@media (max-width: 480px) {
|
| 1382 |
+
.iv-sidebar.open {
|
| 1383 |
+
position: absolute;
|
| 1384 |
+
left: 0; top: 0; bottom: 0;
|
| 1385 |
+
z-index: 50;
|
| 1386 |
+
border-right: 3px solid #000;
|
| 1387 |
+
box-shadow: 4px 0 20px rgba(0,0,0,0.2);
|
| 1388 |
+
}
|
| 1389 |
+
.iv-msg-row.user { max-width: 90% !important; }
|
| 1390 |
+
}
|
| 1391 |
+
`}</style>
|
| 1392 |
+
</div>
|
| 1393 |
+
);
|
| 1394 |
+
};
|
| 1395 |
+
|
| 1396 |
+
export default InteractionView;
|
frontend-react/src/views/Login.tsx
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { useAuth } from '../context/AuthContext';
|
| 4 |
+
import { LogIn, UserPlus, Eye, EyeOff } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
|
| 7 |
+
|
| 8 |
+
/* ─── Typewriter ─── */
|
| 9 |
+
const useTypewriter = (words: string[], speed = 80, pause = 2200) => {
|
| 10 |
+
const [index, setIndex] = useState(0);
|
| 11 |
+
const [sub, setSub] = useState(0);
|
| 12 |
+
const [deleting, setDeleting] = useState(false);
|
| 13 |
+
const [text, setText] = useState('');
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
const word = words[index % words.length];
|
| 16 |
+
const timer = setTimeout(() => {
|
| 17 |
+
if (!deleting) {
|
| 18 |
+
setText(word.slice(0, sub + 1));
|
| 19 |
+
setSub(s => s + 1);
|
| 20 |
+
if (sub + 1 === word.length) setTimeout(() => setDeleting(true), pause);
|
| 21 |
+
} else {
|
| 22 |
+
setText(word.slice(0, sub - 1));
|
| 23 |
+
setSub(s => s - 1);
|
| 24 |
+
if (sub - 1 === 0) { setDeleting(false); setIndex(i => i + 1); }
|
| 25 |
+
}
|
| 26 |
+
}, deleting ? speed / 2 : speed);
|
| 27 |
+
return () => clearTimeout(timer);
|
| 28 |
+
}, [sub, deleting, index]);
|
| 29 |
+
return text;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const Login: React.FC = () => {
|
| 33 |
+
const [isRegistering, setIsRegistering] = useState(false);
|
| 34 |
+
const [username, setUsername] = useState('');
|
| 35 |
+
const [password, setPassword] = useState('');
|
| 36 |
+
const [error, setError] = useState('');
|
| 37 |
+
const [loading, setLoading] = useState(false);
|
| 38 |
+
const [showPassword, setShowPassword] = useState(false);
|
| 39 |
+
|
| 40 |
+
const { login } = useAuth();
|
| 41 |
+
const navigate = useNavigate();
|
| 42 |
+
|
| 43 |
+
const rotatingWords = ['Knowledge Graphs.', 'Logic Engines.', 'LLM Reasoning.', 'Entity Networks.', 'Semantic Search.'];
|
| 44 |
+
const rotating = useTypewriter(rotatingWords);
|
| 45 |
+
|
| 46 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 47 |
+
e.preventDefault();
|
| 48 |
+
setError('');
|
| 49 |
+
setLoading(true);
|
| 50 |
+
try {
|
| 51 |
+
if (isRegistering) {
|
| 52 |
+
const regRes = await fetch(`${API_BASE}/auth/register`, {
|
| 53 |
+
method: 'POST',
|
| 54 |
+
headers: { 'Content-Type': 'application/json' },
|
| 55 |
+
body: JSON.stringify({ username, password, scopes: ['read', 'write', 'admin'] }),
|
| 56 |
+
});
|
| 57 |
+
if (!regRes.ok) {
|
| 58 |
+
const errData = await regRes.json();
|
| 59 |
+
throw new Error(errData.detail || 'Registration failed');
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
const loginRes = await fetch(`${API_BASE}/auth/login`, {
|
| 63 |
+
method: 'POST',
|
| 64 |
+
headers: { 'Content-Type': 'application/json' },
|
| 65 |
+
body: JSON.stringify({ username, password }),
|
| 66 |
+
});
|
| 67 |
+
if (!loginRes.ok) {
|
| 68 |
+
const errData = await loginRes.json();
|
| 69 |
+
throw new Error(errData.detail || 'Login failed');
|
| 70 |
+
}
|
| 71 |
+
const { access_token } = await loginRes.json();
|
| 72 |
+
const userRes = await fetch(`${API_BASE}/auth/me`, {
|
| 73 |
+
headers: { Authorization: `Bearer ${access_token}` },
|
| 74 |
+
});
|
| 75 |
+
if (!userRes.ok) throw new Error('Failed to fetch user profile');
|
| 76 |
+
const user = await userRes.json();
|
| 77 |
+
login(access_token, user);
|
| 78 |
+
navigate('/');
|
| 79 |
+
} catch (err: any) {
|
| 80 |
+
setError(err.message);
|
| 81 |
+
} finally {
|
| 82 |
+
setLoading(false);
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<div className="lp-root">
|
| 88 |
+
|
| 89 |
+
{/* ── Brand mark top-left ── */}
|
| 90 |
+
<div className="lp-brand">
|
| 91 |
+
<span className="lp-brand-dot" />
|
| 92 |
+
CORTEX
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{/* ── Version badge top-right ── */}
|
| 96 |
+
<div className="lp-version">v1.0 · PRODUCTION</div>
|
| 97 |
+
|
| 98 |
+
{/* ── Giant background wordmark ── */}
|
| 99 |
+
<div className="lp-bg-word" aria-hidden>COR</div>
|
| 100 |
+
<div className="lp-bg-word lp-bg-word-2" aria-hidden>TEX</div>
|
| 101 |
+
|
| 102 |
+
{/* ── Hero headline, top-center area ── */}
|
| 103 |
+
<div className="lp-headline">
|
| 104 |
+
<div className="lp-headline-super">AGENTIC KNOWLEDGE PLATFORM</div>
|
| 105 |
+
<h1 className="lp-headline-h1">
|
| 106 |
+
Enterprise-grade<br/>
|
| 107 |
+
<span className="lp-headline-rotating">
|
| 108 |
+
{rotating}<span className="lp-cursor">|</span>
|
| 109 |
+
</span>
|
| 110 |
+
</h1>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* ── Floating stat chips ── */}
|
| 114 |
+
<div className="lp-stat lp-stat-1">
|
| 115 |
+
<div className="lp-stat-num">Neo4j</div>
|
| 116 |
+
<div className="lp-stat-key">GRAPH ENGINE</div>
|
| 117 |
+
</div>
|
| 118 |
+
<div className="lp-stat lp-stat-2">
|
| 119 |
+
<div className="lp-stat-num">ReACT</div>
|
| 120 |
+
<div className="lp-stat-key">AGENT LOOP</div>
|
| 121 |
+
</div>
|
| 122 |
+
<div className="lp-stat lp-stat-3">
|
| 123 |
+
<div className="lp-stat-num">EVAL</div>
|
| 124 |
+
<div className="lp-stat-key">SCORING</div>
|
| 125 |
+
</div>
|
| 126 |
+
<div className="lp-stat lp-stat-4">
|
| 127 |
+
<div className="lp-stat-num">Multi-hop</div>
|
| 128 |
+
<div className="lp-stat-key">REASONING</div>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
{/* ── Vertical feature list bottom-left ── */}
|
| 132 |
+
<div className="lp-features">
|
| 133 |
+
<div className="lp-features-label">CAPABILITIES</div>
|
| 134 |
+
{[
|
| 135 |
+
'Document ingestion → Knowledge graph',
|
| 136 |
+
'LLM entity & relationship extraction',
|
| 137 |
+
'Agentic multi-step query reasoning',
|
| 138 |
+
'Hallucination risk scoring',
|
| 139 |
+
'Ontology drift detection & governance',
|
| 140 |
+
'Entity enrichment & deduplication',
|
| 141 |
+
'Interactive D3 force visualization',
|
| 142 |
+
'Export: JSON · Cypher · GraphML',
|
| 143 |
+
].map((f, i) => (
|
| 144 |
+
<div key={i} className="lp-feature-row">
|
| 145 |
+
<span className="lp-feature-tick">→</span>
|
| 146 |
+
<span>{f}</span>
|
| 147 |
+
</div>
|
| 148 |
+
))}
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* ── Horizontal tech stack bottom-center ── */}
|
| 152 |
+
<div className="lp-stack">
|
| 153 |
+
{['FastAPI', 'Neo4j', 'Redis', 'Celery', 'Gemini', 'LangChain'].map(t => (
|
| 154 |
+
<span key={t} className="lp-stack-tag">{t}</span>
|
| 155 |
+
))}
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{/* ── Bottom-right quote ── */}
|
| 159 |
+
<div className="lp-quote">
|
| 160 |
+
"Knowledge is only useful<br/>when it can be reasoned over."
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
{/* ── Decorative rule lines ── */}
|
| 164 |
+
<div className="lp-rule lp-rule-h1" aria-hidden />
|
| 165 |
+
<div className="lp-rule lp-rule-h2" aria-hidden />
|
| 166 |
+
<div className="lp-rule lp-rule-v1" aria-hidden />
|
| 167 |
+
|
| 168 |
+
{/* ── LOGIN CARD (right side, vertically centered) ── */}
|
| 169 |
+
<div className="lp-card-wrap">
|
| 170 |
+
<div className="login-card">
|
| 171 |
+
<h2 className="login-title">
|
| 172 |
+
{isRegistering ? 'INITIALIZE ACCESS' : 'SYSTEM AUTH'}
|
| 173 |
+
</h2>
|
| 174 |
+
|
| 175 |
+
{error && <div className="error-banner">{error}</div>}
|
| 176 |
+
|
| 177 |
+
<form onSubmit={handleSubmit} className="login-form">
|
| 178 |
+
<div className="input-group">
|
| 179 |
+
<label>IDENTIFIER [USER]</label>
|
| 180 |
+
<input
|
| 181 |
+
type="text"
|
| 182 |
+
value={username}
|
| 183 |
+
onChange={(e) => setUsername(e.target.value)}
|
| 184 |
+
required
|
| 185 |
+
className="mono-text"
|
| 186 |
+
autoComplete="username"
|
| 187 |
+
/>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
<div className="input-group">
|
| 191 |
+
<label>KEY [PASS]</label>
|
| 192 |
+
<div className="password-wrapper">
|
| 193 |
+
<input
|
| 194 |
+
type={showPassword ? 'text' : 'password'}
|
| 195 |
+
value={password}
|
| 196 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 197 |
+
required
|
| 198 |
+
className="mono-text"
|
| 199 |
+
autoComplete="current-password"
|
| 200 |
+
/>
|
| 201 |
+
<button
|
| 202 |
+
type="button"
|
| 203 |
+
className="eye-btn"
|
| 204 |
+
onClick={() => setShowPassword(!showPassword)}
|
| 205 |
+
tabIndex={-1}
|
| 206 |
+
title="Toggle visibility"
|
| 207 |
+
>
|
| 208 |
+
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
| 209 |
+
</button>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<button type="submit" className="app-btn auth-btn" disabled={loading}>
|
| 214 |
+
{loading
|
| 215 |
+
? 'PROCESSING...'
|
| 216 |
+
: isRegistering
|
| 217 |
+
? <><UserPlus size={16} /> REGISTER</>
|
| 218 |
+
: <><LogIn size={16} /> AUTHENTICATE →</>
|
| 219 |
+
}
|
| 220 |
+
</button>
|
| 221 |
+
</form>
|
| 222 |
+
|
| 223 |
+
<p className="toggle-mode">
|
| 224 |
+
<button type="button" className="text-btn" onClick={() => setIsRegistering(!isRegistering)}>
|
| 225 |
+
{isRegistering ? 'RETURN TO LOGIN' : 'REQUEST ACCESS (REGISTER)'}
|
| 226 |
+
</button>
|
| 227 |
+
</p>
|
| 228 |
+
|
| 229 |
+
<div className="lp-card-footer">
|
| 230 |
+
<span>CORTEX PLATFORM</span>
|
| 231 |
+
<span>SECURED · ENCRYPTED</span>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<style>{`
|
| 237 |
+
/* ── Root ── */
|
| 238 |
+
.lp-root {
|
| 239 |
+
position: fixed;
|
| 240 |
+
inset: 0;
|
| 241 |
+
background: #fff;
|
| 242 |
+
overflow: hidden;
|
| 243 |
+
font-family: var(--font-sans);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
/* ── Brand top-left ── */
|
| 247 |
+
.lp-brand {
|
| 248 |
+
position: absolute;
|
| 249 |
+
top: 2rem;
|
| 250 |
+
left: 2.5rem;
|
| 251 |
+
font-family: var(--font-mono);
|
| 252 |
+
font-size: 0.75rem;
|
| 253 |
+
font-weight: 900;
|
| 254 |
+
letter-spacing: 3px;
|
| 255 |
+
display: flex;
|
| 256 |
+
align-items: center;
|
| 257 |
+
gap: 0.5rem;
|
| 258 |
+
z-index: 10;
|
| 259 |
+
}
|
| 260 |
+
.lp-brand-dot {
|
| 261 |
+
width: 8px; height: 8px;
|
| 262 |
+
background: #000;
|
| 263 |
+
border-radius: 50%;
|
| 264 |
+
animation: brandPulse 2.5s ease-in-out infinite;
|
| 265 |
+
}
|
| 266 |
+
@keyframes brandPulse {
|
| 267 |
+
0%, 100% { transform: scale(1); }
|
| 268 |
+
50% { transform: scale(1.4); }
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
/* ── Version badge ── */
|
| 272 |
+
.lp-version {
|
| 273 |
+
position: absolute;
|
| 274 |
+
top: 2rem;
|
| 275 |
+
right: 420px;
|
| 276 |
+
font-family: var(--font-mono);
|
| 277 |
+
font-size: 0.65rem;
|
| 278 |
+
font-weight: 700;
|
| 279 |
+
letter-spacing: 2px;
|
| 280 |
+
color: #aaa;
|
| 281 |
+
z-index: 10;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/* ── Giant background wordmarks ── */
|
| 285 |
+
.lp-bg-word {
|
| 286 |
+
position: absolute;
|
| 287 |
+
top: -0.15em;
|
| 288 |
+
left: -0.05em;
|
| 289 |
+
font-family: var(--font-display);
|
| 290 |
+
font-size: clamp(200px, 28vw, 380px);
|
| 291 |
+
font-weight: 900;
|
| 292 |
+
line-height: 1;
|
| 293 |
+
color: transparent;
|
| 294 |
+
-webkit-text-stroke: 1.5px #ebebeb;
|
| 295 |
+
letter-spacing: -0.04em;
|
| 296 |
+
pointer-events: none;
|
| 297 |
+
user-select: none;
|
| 298 |
+
z-index: 0;
|
| 299 |
+
}
|
| 300 |
+
.lp-bg-word-2 {
|
| 301 |
+
top: auto;
|
| 302 |
+
bottom: -0.1em;
|
| 303 |
+
left: auto;
|
| 304 |
+
right: 360px;
|
| 305 |
+
-webkit-text-stroke: 1.5px #f0f0f0;
|
| 306 |
+
font-size: clamp(160px, 22vw, 300px);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
/* ── Hero headline ── */
|
| 310 |
+
.lp-headline {
|
| 311 |
+
position: absolute;
|
| 312 |
+
top: 5rem;
|
| 313 |
+
left: 2.5rem;
|
| 314 |
+
right: 420px;
|
| 315 |
+
z-index: 5;
|
| 316 |
+
}
|
| 317 |
+
.lp-headline-super {
|
| 318 |
+
font-family: var(--font-mono);
|
| 319 |
+
font-size: 0.65rem;
|
| 320 |
+
font-weight: 700;
|
| 321 |
+
letter-spacing: 4px;
|
| 322 |
+
color: #999;
|
| 323 |
+
margin-bottom: 0.75rem;
|
| 324 |
+
}
|
| 325 |
+
.lp-headline-h1 {
|
| 326 |
+
font-family: var(--font-display);
|
| 327 |
+
font-size: clamp(2rem, 3.8vw, 3.4rem);
|
| 328 |
+
font-weight: 800;
|
| 329 |
+
line-height: 1.15;
|
| 330 |
+
letter-spacing: -0.5px;
|
| 331 |
+
}
|
| 332 |
+
.lp-headline-rotating {
|
| 333 |
+
display: inline-block;
|
| 334 |
+
border-bottom: 4px solid #000;
|
| 335 |
+
min-width: 2ch;
|
| 336 |
+
}
|
| 337 |
+
.lp-cursor {
|
| 338 |
+
display: inline-block;
|
| 339 |
+
width: 2px;
|
| 340 |
+
animation: blink 0.9s step-end infinite;
|
| 341 |
+
font-weight: 300;
|
| 342 |
+
margin-left: 1px;
|
| 343 |
+
}
|
| 344 |
+
@keyframes blink { 50% { opacity: 0; } }
|
| 345 |
+
|
| 346 |
+
/* ── Floating stat chips ── */
|
| 347 |
+
.lp-stat {
|
| 348 |
+
position: absolute;
|
| 349 |
+
border: 2px solid #000;
|
| 350 |
+
padding: 0.6rem 0.9rem;
|
| 351 |
+
z-index: 5;
|
| 352 |
+
background: #fff;
|
| 353 |
+
transition: transform 0.2s, box-shadow 0.2s;
|
| 354 |
+
}
|
| 355 |
+
.lp-stat:hover {
|
| 356 |
+
transform: translateY(-3px);
|
| 357 |
+
box-shadow: 4px 4px 0 #000;
|
| 358 |
+
}
|
| 359 |
+
.lp-stat-num {
|
| 360 |
+
font-family: var(--font-mono);
|
| 361 |
+
font-size: 0.88rem;
|
| 362 |
+
font-weight: 900;
|
| 363 |
+
line-height: 1;
|
| 364 |
+
margin-bottom: 0.2rem;
|
| 365 |
+
}
|
| 366 |
+
.lp-stat-key {
|
| 367 |
+
font-family: var(--font-mono);
|
| 368 |
+
font-size: 0.58rem;
|
| 369 |
+
color: #888;
|
| 370 |
+
letter-spacing: 1px;
|
| 371 |
+
font-weight: 700;
|
| 372 |
+
}
|
| 373 |
+
.lp-stat-1 { top: 36%; left: 2.5rem; }
|
| 374 |
+
.lp-stat-2 { top: 37%; left: 9rem; }
|
| 375 |
+
.lp-stat-3 { top: 44%; left: 2.5rem; }
|
| 376 |
+
.lp-stat-4 { top: 44%; left: 9rem; }
|
| 377 |
+
|
| 378 |
+
/* ── Feature list ── */
|
| 379 |
+
.lp-features {
|
| 380 |
+
position: absolute;
|
| 381 |
+
bottom: 5rem;
|
| 382 |
+
left: 2.5rem;
|
| 383 |
+
width: 340px;
|
| 384 |
+
z-index: 5;
|
| 385 |
+
}
|
| 386 |
+
.lp-features-label {
|
| 387 |
+
font-family: var(--font-mono);
|
| 388 |
+
font-size: 0.6rem;
|
| 389 |
+
font-weight: 700;
|
| 390 |
+
letter-spacing: 3px;
|
| 391 |
+
color: #bbb;
|
| 392 |
+
margin-bottom: 0.6rem;
|
| 393 |
+
}
|
| 394 |
+
.lp-feature-row {
|
| 395 |
+
display: flex;
|
| 396 |
+
gap: 0.5rem;
|
| 397 |
+
font-family: var(--font-sans);
|
| 398 |
+
font-size: 0.78rem;
|
| 399 |
+
color: #333;
|
| 400 |
+
padding: 0.22rem 0;
|
| 401 |
+
border-bottom: 1px solid #f0f0f0;
|
| 402 |
+
line-height: 1.5;
|
| 403 |
+
}
|
| 404 |
+
.lp-feature-tick {
|
| 405 |
+
font-family: var(--font-mono);
|
| 406 |
+
font-size: 0.72rem;
|
| 407 |
+
font-weight: 700;
|
| 408 |
+
color: #000;
|
| 409 |
+
flex-shrink: 0;
|
| 410 |
+
margin-top: 1px;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
/* ── Tech stack ── */
|
| 414 |
+
.lp-stack {
|
| 415 |
+
position: absolute;
|
| 416 |
+
bottom: 1.75rem;
|
| 417 |
+
left: 2.5rem;
|
| 418 |
+
right: 420px;
|
| 419 |
+
display: flex;
|
| 420 |
+
gap: 0.5rem;
|
| 421 |
+
flex-wrap: wrap;
|
| 422 |
+
z-index: 5;
|
| 423 |
+
}
|
| 424 |
+
.lp-stack-tag {
|
| 425 |
+
font-family: var(--font-mono);
|
| 426 |
+
font-size: 0.65rem;
|
| 427 |
+
font-weight: 700;
|
| 428 |
+
letter-spacing: 0.5px;
|
| 429 |
+
border: 1.5px solid #000;
|
| 430 |
+
padding: 2px 8px;
|
| 431 |
+
background: #fff;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
/* ── Bottom-right quote ── */
|
| 435 |
+
.lp-quote {
|
| 436 |
+
position: absolute;
|
| 437 |
+
bottom: 4rem;
|
| 438 |
+
right: 420px;
|
| 439 |
+
width: 240px;
|
| 440 |
+
font-family: var(--font-display);
|
| 441 |
+
font-size: 0.82rem;
|
| 442 |
+
font-style: italic;
|
| 443 |
+
color: #bbb;
|
| 444 |
+
text-align: right;
|
| 445 |
+
line-height: 1.6;
|
| 446 |
+
z-index: 5;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
/* ── Decorative rules ── */
|
| 450 |
+
.lp-rule {
|
| 451 |
+
position: absolute;
|
| 452 |
+
background: #e8e8e8;
|
| 453 |
+
z-index: 4;
|
| 454 |
+
pointer-events: none;
|
| 455 |
+
}
|
| 456 |
+
.lp-rule-h1 {
|
| 457 |
+
top: 4rem;
|
| 458 |
+
left: 0; right: 0;
|
| 459 |
+
height: 1px;
|
| 460 |
+
}
|
| 461 |
+
.lp-rule-h2 {
|
| 462 |
+
bottom: 3.5rem;
|
| 463 |
+
left: 0; right: 0;
|
| 464 |
+
height: 1px;
|
| 465 |
+
}
|
| 466 |
+
.lp-rule-v1 {
|
| 467 |
+
top: 0; bottom: 0;
|
| 468 |
+
right: 400px;
|
| 469 |
+
width: 2px;
|
| 470 |
+
background: #000;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/* ── Login card ── */
|
| 474 |
+
.lp-card-wrap {
|
| 475 |
+
position: absolute;
|
| 476 |
+
top: 0; bottom: 0;
|
| 477 |
+
right: 0;
|
| 478 |
+
width: 400px;
|
| 479 |
+
display: flex;
|
| 480 |
+
align-items: center;
|
| 481 |
+
justify-content: center;
|
| 482 |
+
background: #fff;
|
| 483 |
+
z-index: 20;
|
| 484 |
+
padding: 2rem 2.5rem;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.login-card {
|
| 488 |
+
width: 100%;
|
| 489 |
+
animation: slideUp 0.45s ease both;
|
| 490 |
+
}
|
| 491 |
+
@keyframes slideUp {
|
| 492 |
+
from { transform: translateY(16px); opacity: 0; }
|
| 493 |
+
to { transform: translateY(0); opacity: 1; }
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.login-title {
|
| 497 |
+
font-family: var(--font-display);
|
| 498 |
+
font-size: 1.15rem;
|
| 499 |
+
font-weight: 800;
|
| 500 |
+
letter-spacing: 2px;
|
| 501 |
+
margin-bottom: 2rem;
|
| 502 |
+
text-transform: uppercase;
|
| 503 |
+
border-bottom: 3px solid #000;
|
| 504 |
+
padding-bottom: 1rem;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.input-group {
|
| 508 |
+
margin-bottom: 1.25rem;
|
| 509 |
+
}
|
| 510 |
+
.input-group label {
|
| 511 |
+
display: block;
|
| 512 |
+
font-family: var(--font-mono);
|
| 513 |
+
font-size: 0.72rem;
|
| 514 |
+
font-weight: 700;
|
| 515 |
+
letter-spacing: 1px;
|
| 516 |
+
margin-bottom: 0.4rem;
|
| 517 |
+
color: #555;
|
| 518 |
+
}
|
| 519 |
+
.input-group input {
|
| 520 |
+
width: 100%;
|
| 521 |
+
border: 2px solid #000;
|
| 522 |
+
padding: 0.75rem;
|
| 523 |
+
font-size: 0.95rem;
|
| 524 |
+
background: #fafafa;
|
| 525 |
+
transition: box-shadow 0.15s;
|
| 526 |
+
}
|
| 527 |
+
.input-group input:focus {
|
| 528 |
+
outline: none;
|
| 529 |
+
box-shadow: 3px 3px 0 #000;
|
| 530 |
+
background: #fff;
|
| 531 |
+
}
|
| 532 |
+
.password-wrapper {
|
| 533 |
+
position: relative;
|
| 534 |
+
display: flex;
|
| 535 |
+
align-items: center;
|
| 536 |
+
}
|
| 537 |
+
.password-wrapper input { padding-right: 2.5rem; }
|
| 538 |
+
.eye-btn {
|
| 539 |
+
position: absolute;
|
| 540 |
+
right: 0.5rem;
|
| 541 |
+
background: none !important;
|
| 542 |
+
border: none !important;
|
| 543 |
+
cursor: pointer;
|
| 544 |
+
color: #888;
|
| 545 |
+
display: flex;
|
| 546 |
+
align-items: center;
|
| 547 |
+
transition: color 0.15s !important;
|
| 548 |
+
}
|
| 549 |
+
.eye-btn:hover { background: none !important; color: #000 !important; }
|
| 550 |
+
|
| 551 |
+
.auth-btn {
|
| 552 |
+
width: 100%;
|
| 553 |
+
padding: 0.9rem;
|
| 554 |
+
display: flex;
|
| 555 |
+
align-items: center;
|
| 556 |
+
justify-content: center;
|
| 557 |
+
gap: 0.5rem;
|
| 558 |
+
margin-top: 0.5rem;
|
| 559 |
+
font-size: 0.85rem;
|
| 560 |
+
letter-spacing: 1px;
|
| 561 |
+
background: #000;
|
| 562 |
+
color: #fff;
|
| 563 |
+
border: 2px solid #000;
|
| 564 |
+
transition: background 0.15s, transform 0.15s;
|
| 565 |
+
}
|
| 566 |
+
.auth-btn:hover:not(:disabled) {
|
| 567 |
+
background: #222;
|
| 568 |
+
transform: translateY(-1px);
|
| 569 |
+
box-shadow: 3px 3px 0 rgba(0,0,0,0.2);
|
| 570 |
+
}
|
| 571 |
+
.auth-btn:disabled { opacity: 0.5; }
|
| 572 |
+
|
| 573 |
+
.error-banner {
|
| 574 |
+
background: #000;
|
| 575 |
+
color: #fff;
|
| 576 |
+
padding: 0.6rem 0.75rem;
|
| 577 |
+
margin-bottom: 1.25rem;
|
| 578 |
+
font-family: var(--font-mono);
|
| 579 |
+
font-size: 0.78rem;
|
| 580 |
+
border-left: 4px solid #dc2626;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.toggle-mode {
|
| 584 |
+
text-align: center;
|
| 585 |
+
margin-top: 1.25rem;
|
| 586 |
+
}
|
| 587 |
+
.text-btn {
|
| 588 |
+
background: none;
|
| 589 |
+
border: none;
|
| 590 |
+
color: #555;
|
| 591 |
+
text-decoration: underline;
|
| 592 |
+
text-underline-offset: 4px;
|
| 593 |
+
cursor: pointer;
|
| 594 |
+
font-family: var(--font-mono);
|
| 595 |
+
font-size: 0.72rem;
|
| 596 |
+
letter-spacing: 0.5px;
|
| 597 |
+
transition: color 0.15s;
|
| 598 |
+
}
|
| 599 |
+
.text-btn:hover { color: #000; background: none; }
|
| 600 |
+
|
| 601 |
+
.lp-card-footer {
|
| 602 |
+
display: flex;
|
| 603 |
+
justify-content: space-between;
|
| 604 |
+
margin-top: 2rem;
|
| 605 |
+
padding-top: 1rem;
|
| 606 |
+
border-top: 1px solid #e5e5e5;
|
| 607 |
+
font-family: var(--font-mono);
|
| 608 |
+
font-size: 0.6rem;
|
| 609 |
+
color: #bbb;
|
| 610 |
+
letter-spacing: 0.5px;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
/* ── Responsive ── */
|
| 614 |
+
@media (max-width: 800px) {
|
| 615 |
+
.lp-headline { right: 2rem; }
|
| 616 |
+
.lp-version { right: 2rem; }
|
| 617 |
+
.lp-rule-v1 { display: none; }
|
| 618 |
+
.lp-card-wrap {
|
| 619 |
+
position: fixed;
|
| 620 |
+
inset: 0;
|
| 621 |
+
width: 100%;
|
| 622 |
+
background: rgba(255,255,255,0.95);
|
| 623 |
+
backdrop-filter: blur(8px);
|
| 624 |
+
}
|
| 625 |
+
.lp-bg-word, .lp-bg-word-2, .lp-features, .lp-stack, .lp-quote,
|
| 626 |
+
.lp-stat-1, .lp-stat-2, .lp-stat-3, .lp-stat-4 { display: none; }
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
@media (max-height: 750px) {
|
| 630 |
+
.lp-headline { top: 4rem; }
|
| 631 |
+
.lp-headline-h1 { font-size: clamp(1.8rem, 3.5vw, 2.8rem); }
|
| 632 |
+
.lp-stat-1 { top: 32%; }
|
| 633 |
+
.lp-stat-2 { top: 33%; }
|
| 634 |
+
.lp-stat-3 { top: 41%; }
|
| 635 |
+
.lp-stat-4 { top: 41%; }
|
| 636 |
+
.lp-features { bottom: 4rem; transform: scale(0.9); transform-origin: left bottom; }
|
| 637 |
+
.lp-stack { display: none; }
|
| 638 |
+
}
|
| 639 |
+
`}</style>
|
| 640 |
+
</div>
|
| 641 |
+
);
|
| 642 |
+
};
|
| 643 |
+
|
| 644 |
+
export default Login;
|
frontend-react/src/views/Ontology.tsx
ADDED
|
@@ -0,0 +1,803 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import { DriftReport, DocumentInfo } from '../types/api';
|
| 4 |
+
import { Database, GitMerge, Settings, Sparkles, Save, Info, Zap, AlertTriangle, Check, X, FileText } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
|
| 7 |
+
|
| 8 |
+
const Ontology: React.FC = () => {
|
| 9 |
+
const { token, logout } = useAuth();
|
| 10 |
+
|
| 11 |
+
const [loading, setLoading] = useState(true);
|
| 12 |
+
const [saving, setSaving] = useState(false);
|
| 13 |
+
const [refining, setRefining] = useState(false);
|
| 14 |
+
const [deduping, setDeduping] = useState(false);
|
| 15 |
+
const [enriching, setEnriching] = useState(false);
|
| 16 |
+
const [detectingDrift, setDetectingDrift] = useState(false);
|
| 17 |
+
const [driftReports, setDriftReports] = useState<DriftReport[]>([]);
|
| 18 |
+
|
| 19 |
+
const [version, setVersion] = useState('');
|
| 20 |
+
const [entityTypes, setEntityTypes] = useState('');
|
| 21 |
+
const [relationshipTypes, setRelationshipTypes] = useState('');
|
| 22 |
+
const [properties, setProperties] = useState('');
|
| 23 |
+
const [feedback, setFeedback] = useState('');
|
| 24 |
+
|
| 25 |
+
// "Global" baseline so we can restore when switching back to global mode
|
| 26 |
+
const [globalEntityTypes, setGlobalEntityTypes] = useState('');
|
| 27 |
+
const [globalRelationshipTypes, setGlobalRelationshipTypes] = useState('');
|
| 28 |
+
const [globalProperties, setGlobalProperties] = useState('');
|
| 29 |
+
|
| 30 |
+
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
|
| 31 |
+
const [selectedDocId, setSelectedDocId] = useState<string>('');
|
| 32 |
+
const [stats, setStats] = useState<any>(null);
|
| 33 |
+
const [docSchemaLoading, setDocSchemaLoading] = useState(false);
|
| 34 |
+
|
| 35 |
+
const [message, setMessage] = useState('');
|
| 36 |
+
|
| 37 |
+
/* ── Fetch global ontology ─────────────────────────────────────── */
|
| 38 |
+
const fetchOntology = async () => {
|
| 39 |
+
setLoading(true);
|
| 40 |
+
try {
|
| 41 |
+
const res = await fetch(`${API_BASE}/ontology`, {
|
| 42 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 43 |
+
});
|
| 44 |
+
if (res.status === 401) { logout(); return; }
|
| 45 |
+
if (res.ok) {
|
| 46 |
+
const data = await res.json();
|
| 47 |
+
setVersion(data.version || '1.0');
|
| 48 |
+
const ent = data.entity_types?.join(', ') || '';
|
| 49 |
+
const rel = data.relationship_types?.join(', ') || '';
|
| 50 |
+
const props = JSON.stringify(data.properties || {}, null, 2);
|
| 51 |
+
setEntityTypes(ent);
|
| 52 |
+
setRelationshipTypes(rel);
|
| 53 |
+
setProperties(props);
|
| 54 |
+
// Save as global baseline
|
| 55 |
+
setGlobalEntityTypes(ent);
|
| 56 |
+
setGlobalRelationshipTypes(rel);
|
| 57 |
+
setGlobalProperties(props);
|
| 58 |
+
} else {
|
| 59 |
+
setMessage('No active ontology found. Please upload documents first.');
|
| 60 |
+
}
|
| 61 |
+
} catch (err) {
|
| 62 |
+
console.error(err);
|
| 63 |
+
setMessage('FAILED TO LOAD ONTOLOGY API');
|
| 64 |
+
} finally {
|
| 65 |
+
setLoading(false);
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const fetchDocuments = async () => {
|
| 70 |
+
try {
|
| 71 |
+
const res = await fetch(`${API_BASE}/documents`, {
|
| 72 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 73 |
+
});
|
| 74 |
+
if (res.ok) {
|
| 75 |
+
const data = await res.json();
|
| 76 |
+
setDocuments(data.documents);
|
| 77 |
+
}
|
| 78 |
+
} catch (err) {
|
| 79 |
+
console.error('Failed to fetch docs for dropdown', err);
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const fetchStats = async (docId: string) => {
|
| 84 |
+
try {
|
| 85 |
+
const url = new URL(`${API_BASE}/ontology/stats`);
|
| 86 |
+
if (docId) url.searchParams.append('document_id', docId);
|
| 87 |
+
const res = await fetch(url.toString(), {
|
| 88 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 89 |
+
});
|
| 90 |
+
if (res.ok) setStats(await res.json());
|
| 91 |
+
else setStats(null);
|
| 92 |
+
} catch { setStats(null); }
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
/* ── Fetch document-specific schema ─────────────────────────────── */
|
| 96 |
+
const fetchDocSchema = async (docId: string) => {
|
| 97 |
+
if (!docId) {
|
| 98 |
+
// Restore global schema
|
| 99 |
+
setEntityTypes(globalEntityTypes);
|
| 100 |
+
setRelationshipTypes(globalRelationshipTypes);
|
| 101 |
+
setProperties(globalProperties);
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
setDocSchemaLoading(true);
|
| 105 |
+
try {
|
| 106 |
+
const url = new URL(`${API_BASE}/ontology/stats`);
|
| 107 |
+
url.searchParams.append('document_id', docId);
|
| 108 |
+
const res = await fetch(url.toString(), {
|
| 109 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 110 |
+
});
|
| 111 |
+
if (res.ok) {
|
| 112 |
+
const data = await res.json();
|
| 113 |
+
// Populate editor with document-specific entity types and relationships
|
| 114 |
+
const docEntityTypes = (data.entity_stats || [])
|
| 115 |
+
.map((s: any) => s.type)
|
| 116 |
+
.filter(Boolean)
|
| 117 |
+
.join(', ');
|
| 118 |
+
const docRelTypes = (data.relationship_stats || [])
|
| 119 |
+
.map((s: any) => s.type)
|
| 120 |
+
.filter(Boolean)
|
| 121 |
+
.join(', ');
|
| 122 |
+
|
| 123 |
+
setEntityTypes(docEntityTypes || globalEntityTypes);
|
| 124 |
+
setRelationshipTypes(docRelTypes || globalRelationshipTypes);
|
| 125 |
+
// Properties: keep global properties since per-doc isn't tracked separately
|
| 126 |
+
setProperties(globalProperties);
|
| 127 |
+
}
|
| 128 |
+
} catch (err) {
|
| 129 |
+
console.error('Failed to fetch doc schema', err);
|
| 130 |
+
} finally {
|
| 131 |
+
setDocSchemaLoading(false);
|
| 132 |
+
}
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
useEffect(() => {
|
| 136 |
+
fetchOntology();
|
| 137 |
+
fetchDocuments();
|
| 138 |
+
fetchStats('');
|
| 139 |
+
}, [token]);
|
| 140 |
+
|
| 141 |
+
useEffect(() => {
|
| 142 |
+
fetchStats(selectedDocId);
|
| 143 |
+
fetchDocSchema(selectedDocId);
|
| 144 |
+
}, [selectedDocId]);
|
| 145 |
+
|
| 146 |
+
const handleSave = async (e: React.FormEvent) => {
|
| 147 |
+
e.preventDefault();
|
| 148 |
+
setSaving(true);
|
| 149 |
+
setMessage('');
|
| 150 |
+
|
| 151 |
+
let parsedProps = {};
|
| 152 |
+
try {
|
| 153 |
+
parsedProps = JSON.parse(properties);
|
| 154 |
+
} catch (e) {
|
| 155 |
+
setMessage('ERROR: PROPERTIES MUST BE VALID JSON');
|
| 156 |
+
setSaving(false);
|
| 157 |
+
return;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
try {
|
| 161 |
+
const res = await fetch(`${API_BASE}/ontology`, {
|
| 162 |
+
method: 'PUT',
|
| 163 |
+
headers: {
|
| 164 |
+
'Content-Type': 'application/json',
|
| 165 |
+
Authorization: `Bearer ${token}`
|
| 166 |
+
},
|
| 167 |
+
body: JSON.stringify({
|
| 168 |
+
entity_types: entityTypes.split(',').map(s => s.trim()).filter(Boolean),
|
| 169 |
+
relationship_types: relationshipTypes.split(',').map(s => s.trim()).filter(Boolean),
|
| 170 |
+
properties: parsedProps,
|
| 171 |
+
approved: true
|
| 172 |
+
})
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
if (res.status === 401) { logout(); return; }
|
| 176 |
+
|
| 177 |
+
if (res.ok) {
|
| 178 |
+
setMessage('ONTOLOGY SCHEMA UPDATED');
|
| 179 |
+
fetchOntology();
|
| 180 |
+
setSelectedDocId(''); // reset to global after save
|
| 181 |
+
} else {
|
| 182 |
+
setMessage('FAILED TO SAVE SCHEMA');
|
| 183 |
+
}
|
| 184 |
+
} catch (err) {
|
| 185 |
+
console.error(err);
|
| 186 |
+
setMessage('API ERROR DURING SAVE');
|
| 187 |
+
} finally {
|
| 188 |
+
setSaving(false);
|
| 189 |
+
}
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
const handleRefine = async () => {
|
| 193 |
+
setRefining(true);
|
| 194 |
+
setMessage('ANALYZING GRAPH FOR UPGRADES... (THIS MAY TAKE 30s+)');
|
| 195 |
+
try {
|
| 196 |
+
const res = await fetch(`${API_BASE}/ontology/refine`, {
|
| 197 |
+
method: 'POST',
|
| 198 |
+
headers: {
|
| 199 |
+
'Content-Type': 'application/json',
|
| 200 |
+
Authorization: `Bearer ${token}`
|
| 201 |
+
},
|
| 202 |
+
body: JSON.stringify({
|
| 203 |
+
feedback: feedback || undefined,
|
| 204 |
+
document_id: selectedDocId || undefined
|
| 205 |
+
})
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
if (res.status === 401) { logout(); return; }
|
| 209 |
+
|
| 210 |
+
if (res.ok) {
|
| 211 |
+
const data = await res.json();
|
| 212 |
+
setMessage(`SUCCESS: ${data.changes}`);
|
| 213 |
+
fetchOntology();
|
| 214 |
+
} else {
|
| 215 |
+
setMessage('FAILED TO REFINE SCHEMA');
|
| 216 |
+
}
|
| 217 |
+
} catch (err) {
|
| 218 |
+
console.error(err);
|
| 219 |
+
setMessage('API ERROR DURING REFINE');
|
| 220 |
+
} finally {
|
| 221 |
+
setRefining(false);
|
| 222 |
+
}
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
const handleDeduplicate = async () => {
|
| 226 |
+
if (!window.confirm("Run semantic merging? This cannot be undone.")) return;
|
| 227 |
+
setDeduping(true);
|
| 228 |
+
setMessage('SCANNING GRAPH FOR DUPLICATE ENTITIES... (THIS MAY TAKE AWHILE)');
|
| 229 |
+
try {
|
| 230 |
+
const res = await fetch(`${API_BASE}/entities/deduplicate`, {
|
| 231 |
+
method: 'POST',
|
| 232 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 233 |
+
});
|
| 234 |
+
if (res.status === 401) { logout(); return; }
|
| 235 |
+
if (res.ok) {
|
| 236 |
+
const data = await res.json();
|
| 237 |
+
setMessage(`DEDUPLICATION COMPLETE: Merged ${data.merged_count} entities.`);
|
| 238 |
+
} else {
|
| 239 |
+
setMessage('FAILED TO DEDUPLICATE ENTITIES');
|
| 240 |
+
}
|
| 241 |
+
} catch (err) {
|
| 242 |
+
console.error(err);
|
| 243 |
+
setMessage('API ERROR DURING DEDUPLICATION');
|
| 244 |
+
} finally {
|
| 245 |
+
setDeduping(false);
|
| 246 |
+
}
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
const handleEnrichEntities = async () => {
|
| 250 |
+
setEnriching(true);
|
| 251 |
+
setMessage('GENERATING ENTITY PROFILES FROM GRAPH NEIGHBORHOODS...');
|
| 252 |
+
try {
|
| 253 |
+
const res = await fetch(`${API_BASE}/entities/enrich`, {
|
| 254 |
+
method: 'POST',
|
| 255 |
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
| 256 |
+
body: JSON.stringify({ batch_size: 20, min_connections: 1 })
|
| 257 |
+
});
|
| 258 |
+
if (res.status === 401) { logout(); return; }
|
| 259 |
+
if (res.ok) {
|
| 260 |
+
const data = await res.json();
|
| 261 |
+
setMessage(`ENRICHMENT COMPLETE: ${data.message || `${data.enriched_count ?? '?'} entities profiled.`}`);
|
| 262 |
+
} else {
|
| 263 |
+
setMessage('FAILED TO ENRICH ENTITIES');
|
| 264 |
+
}
|
| 265 |
+
} catch {
|
| 266 |
+
setMessage('API ERROR DURING ENRICHMENT');
|
| 267 |
+
} finally {
|
| 268 |
+
setEnriching(false);
|
| 269 |
+
}
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const handleDetectDrift = async () => {
|
| 273 |
+
setDetectingDrift(true);
|
| 274 |
+
setMessage('ANALYZING GRAPH DATA FOR SCHEMA DRIFT...');
|
| 275 |
+
try {
|
| 276 |
+
const res = await fetch(`${API_BASE}/ontology/drift/detect`, {
|
| 277 |
+
method: 'POST',
|
| 278 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 279 |
+
});
|
| 280 |
+
if (res.status === 401) { logout(); return; }
|
| 281 |
+
if (res.ok) {
|
| 282 |
+
const data = await res.json();
|
| 283 |
+
setMessage(`DRIFT REPORT CREATED: ID ${data.report_id || data.id || '—'}`);
|
| 284 |
+
fetchDriftReports();
|
| 285 |
+
} else {
|
| 286 |
+
setMessage('FAILED TO DETECT DRIFT');
|
| 287 |
+
}
|
| 288 |
+
} catch {
|
| 289 |
+
setMessage('API ERROR DURING DRIFT DETECTION');
|
| 290 |
+
} finally {
|
| 291 |
+
setDetectingDrift(false);
|
| 292 |
+
}
|
| 293 |
+
};
|
| 294 |
+
|
| 295 |
+
const fetchDriftReports = async () => {
|
| 296 |
+
try {
|
| 297 |
+
const res = await fetch(`${API_BASE}/ontology/drift`, {
|
| 298 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 299 |
+
});
|
| 300 |
+
if (res.ok) {
|
| 301 |
+
const data = await res.json();
|
| 302 |
+
setDriftReports(data.reports || []);
|
| 303 |
+
}
|
| 304 |
+
} catch {}
|
| 305 |
+
};
|
| 306 |
+
|
| 307 |
+
const handleDriftAction = async (id: string, action: 'approve' | 'reject') => {
|
| 308 |
+
const res = await fetch(`${API_BASE}/ontology/drift/${id}/${action}`, {
|
| 309 |
+
method: 'POST', headers: { Authorization: `Bearer ${token}` }
|
| 310 |
+
});
|
| 311 |
+
if (res.ok) {
|
| 312 |
+
setDriftReports(d => d.filter(r => r.id !== id));
|
| 313 |
+
setMessage(`Drift report ${action}d.`);
|
| 314 |
+
}
|
| 315 |
+
};
|
| 316 |
+
|
| 317 |
+
const selectedDoc = documents.find(d => d.id === selectedDocId);
|
| 318 |
+
|
| 319 |
+
return (
|
| 320 |
+
<div className="container" style={{ animation: 'fadeIn 0.5s ease' }}>
|
| 321 |
+
<div className="page-header flex-between">
|
| 322 |
+
<div>
|
| 323 |
+
<h1>ONTOLOGY MANAGEMENT</h1>
|
| 324 |
+
<p className="mono-text">SCHEMA CONTROL & GRAPH REFINEMENT</p>
|
| 325 |
+
</div>
|
| 326 |
+
<Database size={32} />
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
{/* Help info bar */}
|
| 330 |
+
<div className="page-info-bar">
|
| 331 |
+
<Info size={14}/>
|
| 332 |
+
<span>
|
| 333 |
+
<strong>ENTITY TYPES</strong> define what kinds of nodes exist in your graph.
|
| 334 |
+
<strong> RELATIONSHIP TYPES</strong> define how they connect.
|
| 335 |
+
Use <strong>LLM REFINEMENT</strong> to auto-suggest schema improvements from your data.
|
| 336 |
+
Use <strong>DRIFT DETECTION</strong> to detect when new data doesn't fit the current schema.
|
| 337 |
+
Use <strong>ENTITY ENRICHMENT</strong> to synthesize rich profiles for all graph nodes.
|
| 338 |
+
</span>
|
| 339 |
+
</div>
|
| 340 |
+
|
| 341 |
+
<div className="ontology-layout">
|
| 342 |
+
{/* Schema Editor */}
|
| 343 |
+
<div className="card editor-card">
|
| 344 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
|
| 345 |
+
<h2 className="mono-text flex-center" style={{ gap: '0.5rem', margin: 0 }}>
|
| 346 |
+
<Settings size={20}/> EDIT SCHEMA {version ? `v${version}` : ''}
|
| 347 |
+
</h2>
|
| 348 |
+
{selectedDocId && (
|
| 349 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: '#000', color: '#fff', padding: '3px 10px', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.5px' }}>
|
| 350 |
+
<FileText size={11} />
|
| 351 |
+
DOC SCOPE: {selectedDoc?.filename?.slice(0, 22) ?? selectedDocId.slice(0, 12)}…
|
| 352 |
+
</div>
|
| 353 |
+
)}
|
| 354 |
+
</div>
|
| 355 |
+
|
| 356 |
+
{/* Document selector in editor header */}
|
| 357 |
+
<div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: '#f5f5f5', border: '1.5px solid #e5e5e5' }}>
|
| 358 |
+
<label className="control-label" style={{ display: 'block', marginBottom: '0.4rem' }}>
|
| 359 |
+
POPULATE FROM DOCUMENT
|
| 360 |
+
</label>
|
| 361 |
+
<select
|
| 362 |
+
className="mono-text doc-dropdown"
|
| 363 |
+
value={selectedDocId}
|
| 364 |
+
onChange={(e) => setSelectedDocId(e.target.value)}
|
| 365 |
+
style={{ width: '100%' }}
|
| 366 |
+
>
|
| 367 |
+
<option value="">🌐 GLOBAL — Full Ontology Schema</option>
|
| 368 |
+
{documents.map(doc => (
|
| 369 |
+
<option key={doc.id} value={doc.id}>📄 {doc.filename}</option>
|
| 370 |
+
))}
|
| 371 |
+
</select>
|
| 372 |
+
{docSchemaLoading && (
|
| 373 |
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted-color)', marginTop: '0.4rem' }}>
|
| 374 |
+
↻ Loading document schema…
|
| 375 |
+
</div>
|
| 376 |
+
)}
|
| 377 |
+
{selectedDocId && !docSchemaLoading && (
|
| 378 |
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: '#16a34a', marginTop: '0.4rem', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
| 379 |
+
<Check size={11}/> Schema populated from "{selectedDoc?.filename ?? selectedDocId}"
|
| 380 |
+
</div>
|
| 381 |
+
)}
|
| 382 |
+
</div>
|
| 383 |
+
|
| 384 |
+
{loading ? (
|
| 385 |
+
<div className="mono-text" style={{ padding: '2rem', textAlign: 'center' }}>LOADING SCHEMA...</div>
|
| 386 |
+
) : (
|
| 387 |
+
<form onSubmit={handleSave} className="schema-form">
|
| 388 |
+
<div className="form-group">
|
| 389 |
+
<label className="mono-text">ENTITY TYPES (COMMA-SEPARATED)</label>
|
| 390 |
+
<textarea
|
| 391 |
+
value={entityTypes}
|
| 392 |
+
onChange={(e) => setEntityTypes(e.target.value)}
|
| 393 |
+
className="mono-text"
|
| 394 |
+
rows={3}
|
| 395 |
+
placeholder="Person, Organization, Location, Event…"
|
| 396 |
+
/>
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
<div className="form-group">
|
| 400 |
+
<label className="mono-text">RELATIONSHIP TYPES (COMMA-SEPARATED)</label>
|
| 401 |
+
<textarea
|
| 402 |
+
value={relationshipTypes}
|
| 403 |
+
onChange={(e) => setRelationshipTypes(e.target.value)}
|
| 404 |
+
className="mono-text"
|
| 405 |
+
rows={3}
|
| 406 |
+
placeholder="WORKS_FOR, LOCATED_IN, RELATED_TO…"
|
| 407 |
+
/>
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
<div className="form-group">
|
| 411 |
+
<label className="mono-text">PROPERTIES BINDING (JSON FORMAT)</label>
|
| 412 |
+
<textarea
|
| 413 |
+
value={properties}
|
| 414 |
+
onChange={(e) => setProperties(e.target.value)}
|
| 415 |
+
className="mono-text dict-editor"
|
| 416 |
+
rows={8}
|
| 417 |
+
/>
|
| 418 |
+
</div>
|
| 419 |
+
|
| 420 |
+
<button
|
| 421 |
+
type="submit"
|
| 422 |
+
className="app-btn full-width"
|
| 423 |
+
disabled={saving}
|
| 424 |
+
style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
|
| 425 |
+
>
|
| 426 |
+
<Save size={18} /> {saving ? 'SAVING...' : 'COMMIT SCHEMA CHANGES'}
|
| 427 |
+
</button>
|
| 428 |
+
</form>
|
| 429 |
+
)}
|
| 430 |
+
|
| 431 |
+
</div>
|
| 432 |
+
|
| 433 |
+
{/* AI Tools */}
|
| 434 |
+
<div className="tools-card-container">
|
| 435 |
+
<div className="card tools-card">
|
| 436 |
+
<h2 className="mono-text flex-center" style={{ gap: '0.5rem' }}>
|
| 437 |
+
<Sparkles size={20}/> LLM REFINEMENT
|
| 438 |
+
</h2>
|
| 439 |
+
<p style={{ marginTop: '1rem', marginBottom: '0.75rem', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
| 440 |
+
Use the LLM Agent to scan existing document chunks and automatically suggest expansions or restructuring to the current ontology schema.
|
| 441 |
+
</p>
|
| 442 |
+
|
| 443 |
+
<div className="refine-info-box">
|
| 444 |
+
<Info size={13} />
|
| 445 |
+
<span>
|
| 446 |
+
<strong>Global mode:</strong> samples random chunks from all documents.<br/>
|
| 447 |
+
<strong>Targeted mode:</strong> only scans chunks from the selected document — useful for domain-specific refinement.
|
| 448 |
+
</span>
|
| 449 |
+
</div>
|
| 450 |
+
|
| 451 |
+
<label className="control-label" style={{ display: 'block', marginBottom: '0.4rem', marginTop: '1rem' }}>REFINEMENT SCOPE</label>
|
| 452 |
+
<select
|
| 453 |
+
className="mono-text doc-dropdown"
|
| 454 |
+
value={selectedDocId}
|
| 455 |
+
onChange={(e) => setSelectedDocId(e.target.value)}
|
| 456 |
+
style={{ width: '100%', marginBottom: '1rem' }}
|
| 457 |
+
>
|
| 458 |
+
<option value="">🌐 GLOBAL — ALL DOCUMENTS (RANDOM CHUNKS)</option>
|
| 459 |
+
{documents.map(doc => (
|
| 460 |
+
<option key={doc.id} value={doc.id}>📄 TARGET: {doc.filename}</option>
|
| 461 |
+
))}
|
| 462 |
+
</select>
|
| 463 |
+
<label className="control-label" style={{ display: 'block', marginBottom: '0.4rem' }}>OPTIONAL CRITERIA</label>
|
| 464 |
+
<textarea
|
| 465 |
+
placeholder="e.g., 'Focus heavily on extracting medical symptoms and treatment names'"
|
| 466 |
+
value={feedback}
|
| 467 |
+
onChange={(e) => setFeedback(e.target.value)}
|
| 468 |
+
className="mono-text"
|
| 469 |
+
rows={3}
|
| 470 |
+
style={{ width: '100%', marginBottom: '1rem' }}
|
| 471 |
+
/>
|
| 472 |
+
<button
|
| 473 |
+
onClick={handleRefine}
|
| 474 |
+
className="app-btn outline-btn full-width"
|
| 475 |
+
disabled={refining || loading}
|
| 476 |
+
style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
|
| 477 |
+
>
|
| 478 |
+
<Sparkles size={18} /> {refining ? 'ANALYZING GRAPH...' : 'REFINE SCHEMA'}
|
| 479 |
+
</button>
|
| 480 |
+
</div>
|
| 481 |
+
|
| 482 |
+
<div className="card tools-card" style={{ marginTop: '2rem' }}>
|
| 483 |
+
<h2 className="mono-text flex-center" style={{ gap: '0.5rem' }}>
|
| 484 |
+
<GitMerge size={20}/> IDENTITY RESOLUTION
|
| 485 |
+
</h2>
|
| 486 |
+
<p style={{ marginTop: '1rem', marginBottom: '1rem', fontSize: '0.9rem', lineHeight: 1.5 }}>
|
| 487 |
+
Scan the entire Knowledge Graph and use Semantic Embedding comparisons to detect and permanently merge duplicated entities.
|
| 488 |
+
</p>
|
| 489 |
+
<button
|
| 490 |
+
onClick={handleDeduplicate}
|
| 491 |
+
className="app-btn outline-btn full-width"
|
| 492 |
+
disabled={deduping || loading}
|
| 493 |
+
style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
|
| 494 |
+
>
|
| 495 |
+
<GitMerge size={18} /> {deduping ? 'MERGING...' : 'DEDUPLICATE ENTITIES'}
|
| 496 |
+
</button>
|
| 497 |
+
</div>
|
| 498 |
+
|
| 499 |
+
{/* Entity Enrichment */}
|
| 500 |
+
<div className="card tools-card" style={{ marginTop: '2rem' }}>
|
| 501 |
+
<h2 className="mono-text flex-center" style={{ gap: '0.5rem' }}>
|
| 502 |
+
<Zap size={20}/> ENTITY ENRICHMENT
|
| 503 |
+
</h2>
|
| 504 |
+
<p style={{ marginTop: '1rem', marginBottom: '1rem', fontSize: '0.9rem', lineHeight: 1.5 }}>
|
| 505 |
+
Generate rich LLM-synthesized profiles for all eligible entities using their graph neighborhood context. Profiles power the Entity Chat feature.
|
| 506 |
+
</p>
|
| 507 |
+
<button
|
| 508 |
+
onClick={handleEnrichEntities}
|
| 509 |
+
className="app-btn outline-btn full-width"
|
| 510 |
+
disabled={enriching || loading}
|
| 511 |
+
style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
|
| 512 |
+
>
|
| 513 |
+
<Zap size={18} /> {enriching ? 'ENRICHING...' : 'ENRICH ALL ENTITIES'}
|
| 514 |
+
</button>
|
| 515 |
+
</div>
|
| 516 |
+
|
| 517 |
+
{/* Drift Detection */}
|
| 518 |
+
<div className="card tools-card" style={{ marginTop: '2rem' }}>
|
| 519 |
+
<h2 className="mono-text flex-center" style={{ gap: '0.5rem' }}>
|
| 520 |
+
<AlertTriangle size={20}/> ONTOLOGY DRIFT
|
| 521 |
+
</h2>
|
| 522 |
+
<p style={{ marginTop: '1rem', marginBottom: '1rem', fontSize: '0.9rem', lineHeight: 1.5 }}>
|
| 523 |
+
Detect when new incoming data no longer fits the current schema. The drift detector proposes additions for review.
|
| 524 |
+
</p>
|
| 525 |
+
<button
|
| 526 |
+
onClick={handleDetectDrift}
|
| 527 |
+
className="app-btn outline-btn full-width"
|
| 528 |
+
disabled={detectingDrift || loading}
|
| 529 |
+
style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginBottom: driftReports.length > 0 ? '1rem' : 0 }}
|
| 530 |
+
>
|
| 531 |
+
<AlertTriangle size={18} /> {detectingDrift ? 'DETECTING...' : 'DETECT DRIFT'}
|
| 532 |
+
</button>
|
| 533 |
+
{driftReports.length > 0 && (
|
| 534 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
|
| 535 |
+
<div className="control-label" style={{ marginBottom: '0.25rem' }}>PENDING DRIFT REPORTS</div>
|
| 536 |
+
{driftReports.slice(0, 3).map(r => (
|
| 537 |
+
<div key={r.id} style={{ border: '1px solid #e5e5e5', padding: '0.6rem 0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
|
| 538 |
+
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
| 539 |
+
{r.summary || r.id}
|
| 540 |
+
</span>
|
| 541 |
+
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
| 542 |
+
<button onClick={() => handleDriftAction(r.id, 'approve')}
|
| 543 |
+
style={{ background: '#f0fdf4', color: '#16a34a', border: '1px solid #16a34a', padding: '2px 8px', cursor: 'pointer', fontSize: '0.72rem' }}>
|
| 544 |
+
<Check size={10}/>
|
| 545 |
+
</button>
|
| 546 |
+
<button onClick={() => handleDriftAction(r.id, 'reject')}
|
| 547 |
+
style={{ background: '#fef2f2', color: '#dc2626', border: '1px solid #dc2626', padding: '2px 8px', cursor: 'pointer', fontSize: '0.72rem' }}>
|
| 548 |
+
<X size={10}/>
|
| 549 |
+
</button>
|
| 550 |
+
</div>
|
| 551 |
+
</div>
|
| 552 |
+
))}
|
| 553 |
+
</div>
|
| 554 |
+
)}
|
| 555 |
+
</div>
|
| 556 |
+
|
| 557 |
+
{/* ── Live Stats Panel ── */}
|
| 558 |
+
{stats && (
|
| 559 |
+
<div className="card tools-card stats-panel" style={{ marginTop: '2rem' }}>
|
| 560 |
+
<h2 className="mono-text flex-center" style={{ gap: '0.5rem', marginBottom: '1rem' }}>
|
| 561 |
+
<Database size={20}/> GRAPH STATISTICS
|
| 562 |
+
{selectedDocId && <span className="scope-badge">DOC FILTERED</span>}
|
| 563 |
+
</h2>
|
| 564 |
+
|
| 565 |
+
<div className="stats-summary">
|
| 566 |
+
<div className="sum-chip">
|
| 567 |
+
<div className="sum-val">{stats.total_entities}</div>
|
| 568 |
+
<div className="sum-key">TOTAL ENTITIES</div>
|
| 569 |
+
</div>
|
| 570 |
+
<div className="sum-chip">
|
| 571 |
+
<div className="sum-val">{stats.total_relationships}</div>
|
| 572 |
+
<div className="sum-key">RELATIONSHIPS</div>
|
| 573 |
+
</div>
|
| 574 |
+
</div>
|
| 575 |
+
|
| 576 |
+
{stats.entity_stats?.length > 0 && (
|
| 577 |
+
<>
|
| 578 |
+
<div className="stat-section-lbl">ENTITY TYPES</div>
|
| 579 |
+
{stats.entity_stats.slice(0, 8).map((s: any) => (
|
| 580 |
+
<div key={s.type} className="stat-bar-row">
|
| 581 |
+
<span className="stat-bar-label">{s.type}</span>
|
| 582 |
+
<div className="stat-bar-track">
|
| 583 |
+
<div
|
| 584 |
+
className="stat-bar-fill"
|
| 585 |
+
style={{ width: `${Math.min(100, (s.count / stats.total_entities) * 100)}%` }}
|
| 586 |
+
/>
|
| 587 |
+
</div>
|
| 588 |
+
<span className="stat-bar-count">{s.count}</span>
|
| 589 |
+
</div>
|
| 590 |
+
))}
|
| 591 |
+
</>
|
| 592 |
+
)}
|
| 593 |
+
</div>
|
| 594 |
+
)}
|
| 595 |
+
</div>
|
| 596 |
+
</div>
|
| 597 |
+
|
| 598 |
+
{message && (
|
| 599 |
+
<div className={`status-toast ${message.includes('ERROR') || message.includes('FAILED') ? 'error' : ''}`}>
|
| 600 |
+
<Info size={20} /> {message}
|
| 601 |
+
<button
|
| 602 |
+
onClick={() => setMessage('')}
|
| 603 |
+
className="toast-dismiss-btn"
|
| 604 |
+
>
|
| 605 |
+
×
|
| 606 |
+
</button>
|
| 607 |
+
</div>
|
| 608 |
+
)}
|
| 609 |
+
|
| 610 |
+
<style>{`
|
| 611 |
+
.doc-dropdown {
|
| 612 |
+
background: var(--bg-color);
|
| 613 |
+
color: var(--text-color);
|
| 614 |
+
border: 2px solid var(--border-color);
|
| 615 |
+
padding: 0.5rem;
|
| 616 |
+
font-size: 0.9rem;
|
| 617 |
+
font-weight: bold;
|
| 618 |
+
outline: none;
|
| 619 |
+
max-width: 100%;
|
| 620 |
+
text-overflow: ellipsis;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.ontology-layout {
|
| 624 |
+
display: grid;
|
| 625 |
+
grid-template-columns: 3fr 2fr;
|
| 626 |
+
gap: 2rem;
|
| 627 |
+
margin-top: 1rem;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
@media (max-width: 768px) {
|
| 631 |
+
.ontology-layout {
|
| 632 |
+
grid-template-columns: 1fr;
|
| 633 |
+
}
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
.form-group {
|
| 637 |
+
margin-bottom: 1.5rem;
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
.form-group label {
|
| 641 |
+
display: block;
|
| 642 |
+
margin-bottom: 0.5rem;
|
| 643 |
+
font-weight: bold;
|
| 644 |
+
font-size: 0.85rem;
|
| 645 |
+
color: #555;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
.form-group textarea {
|
| 649 |
+
width: 100%;
|
| 650 |
+
padding: 1rem;
|
| 651 |
+
border: 2px solid var(--border-color);
|
| 652 |
+
background: var(--bg-color);
|
| 653 |
+
color: var(--text-color);
|
| 654 |
+
resize: vertical;
|
| 655 |
+
font-size: 0.9rem;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.form-group textarea:focus {
|
| 659 |
+
outline: none;
|
| 660 |
+
border-color: #555;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.dict-editor {
|
| 664 |
+
font-family: var(--font-mono);
|
| 665 |
+
white-space: pre;
|
| 666 |
+
background-color: #fafafa !important;
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
.status-toast {
|
| 670 |
+
position: fixed;
|
| 671 |
+
bottom: 2rem;
|
| 672 |
+
right: 2rem;
|
| 673 |
+
background: var(--text-color);
|
| 674 |
+
color: var(--bg-color);
|
| 675 |
+
padding: 1rem 1.5rem;
|
| 676 |
+
border-left: 6px solid var(--text-color);
|
| 677 |
+
box-shadow: 4px 4px 0 var(--border-color);
|
| 678 |
+
display: flex;
|
| 679 |
+
align-items: center;
|
| 680 |
+
gap: 1rem;
|
| 681 |
+
z-index: 9999;
|
| 682 |
+
font-family: var(--font-mono);
|
| 683 |
+
font-weight: bold;
|
| 684 |
+
font-size: 0.9rem;
|
| 685 |
+
animation: slideUp 0.3s ease-out;
|
| 686 |
+
max-width: 400px;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.status-toast.error {
|
| 690 |
+
border-left-color: #ff0000;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.refine-info-box {
|
| 694 |
+
background: #f5f5f5;
|
| 695 |
+
border-left: 3px solid #000;
|
| 696 |
+
padding: 0.5rem 0.75rem;
|
| 697 |
+
font-size: 0.78rem;
|
| 698 |
+
line-height: 1.6;
|
| 699 |
+
display: flex;
|
| 700 |
+
gap: 0.5rem;
|
| 701 |
+
align-items: flex-start;
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
.control-label {
|
| 705 |
+
font-family: var(--font-mono);
|
| 706 |
+
font-size: 0.7rem;
|
| 707 |
+
font-weight: 700;
|
| 708 |
+
color: #666;
|
| 709 |
+
letter-spacing: 1px;
|
| 710 |
+
text-transform: uppercase;
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
.outline-btn {
|
| 714 |
+
background: transparent !important;
|
| 715 |
+
color: var(--text-color) !important;
|
| 716 |
+
border: 2px solid var(--text-color) !important;
|
| 717 |
+
}
|
| 718 |
+
.outline-btn:hover:not(:disabled) {
|
| 719 |
+
background: var(--text-color) !important;
|
| 720 |
+
color: var(--bg-color) !important;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
/* ── Stats panel ── */
|
| 724 |
+
.stats-summary {
|
| 725 |
+
display: grid;
|
| 726 |
+
grid-template-columns: 1fr 1fr;
|
| 727 |
+
gap: 0.75rem;
|
| 728 |
+
margin-bottom: 1rem;
|
| 729 |
+
}
|
| 730 |
+
.sum-chip {
|
| 731 |
+
border: 1.5px solid #e5e5e5;
|
| 732 |
+
padding: 0.6rem 0.75rem;
|
| 733 |
+
text-align: center;
|
| 734 |
+
}
|
| 735 |
+
.sum-val {
|
| 736 |
+
font-family: var(--font-mono);
|
| 737 |
+
font-size: 1.4rem;
|
| 738 |
+
font-weight: 700;
|
| 739 |
+
line-height: 1;
|
| 740 |
+
}
|
| 741 |
+
.sum-key {
|
| 742 |
+
font-family: var(--font-mono);
|
| 743 |
+
font-size: 0.62rem;
|
| 744 |
+
color: #888;
|
| 745 |
+
letter-spacing: 1px;
|
| 746 |
+
margin-top: 0.2rem;
|
| 747 |
+
}
|
| 748 |
+
.stat-section-lbl {
|
| 749 |
+
font-family: var(--font-mono);
|
| 750 |
+
font-size: 0.65rem;
|
| 751 |
+
font-weight: 700;
|
| 752 |
+
color: #888;
|
| 753 |
+
letter-spacing: 1px;
|
| 754 |
+
margin-bottom: 0.5rem;
|
| 755 |
+
}
|
| 756 |
+
.stat-bar-row {
|
| 757 |
+
display: flex;
|
| 758 |
+
align-items: center;
|
| 759 |
+
gap: 0.5rem;
|
| 760 |
+
margin-bottom: 0.35rem;
|
| 761 |
+
font-family: var(--font-mono);
|
| 762 |
+
font-size: 0.75rem;
|
| 763 |
+
}
|
| 764 |
+
.stat-bar-label {
|
| 765 |
+
width: 90px;
|
| 766 |
+
flex-shrink: 0;
|
| 767 |
+
white-space: nowrap;
|
| 768 |
+
overflow: hidden;
|
| 769 |
+
text-overflow: ellipsis;
|
| 770 |
+
color: #444;
|
| 771 |
+
}
|
| 772 |
+
.stat-bar-track {
|
| 773 |
+
flex: 1;
|
| 774 |
+
height: 6px;
|
| 775 |
+
background: #e5e5e5;
|
| 776 |
+
border-radius: 2px;
|
| 777 |
+
overflow: hidden;
|
| 778 |
+
}
|
| 779 |
+
.stat-bar-fill {
|
| 780 |
+
height: 100%;
|
| 781 |
+
background: #000;
|
| 782 |
+
transition: width 0.4s ease;
|
| 783 |
+
}
|
| 784 |
+
.stat-bar-count {
|
| 785 |
+
width: 28px;
|
| 786 |
+
text-align: right;
|
| 787 |
+
color: #666;
|
| 788 |
+
font-size: 0.72rem;
|
| 789 |
+
}
|
| 790 |
+
.scope-badge {
|
| 791 |
+
font-size: 0.6rem;
|
| 792 |
+
background: #000;
|
| 793 |
+
color: #fff;
|
| 794 |
+
padding: 2px 6px;
|
| 795 |
+
letter-spacing: 0.5px;
|
| 796 |
+
margin-left: 0.4rem;
|
| 797 |
+
}
|
| 798 |
+
`}</style>
|
| 799 |
+
</div>
|
| 800 |
+
);
|
| 801 |
+
};
|
| 802 |
+
|
| 803 |
+
export default Ontology;
|
frontend-react/src/views/Process.tsx
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import { DocumentInfo } from '../types/api';
|
| 4 |
+
import { Upload, FilePlus, Activity, Trash2, Eye, Globe, X, FileText, Link, Hash, BookOpen } from 'lucide-react';
|
| 5 |
+
import ReactMarkdown from 'react-markdown';
|
| 6 |
+
import remarkGfm from 'remark-gfm';
|
| 7 |
+
|
| 8 |
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
|
| 9 |
+
|
| 10 |
+
interface PreviewData {
|
| 11 |
+
filename: string;
|
| 12 |
+
file_type: string;
|
| 13 |
+
word_count: number;
|
| 14 |
+
char_count: number;
|
| 15 |
+
content: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const Process: React.FC = () => {
|
| 19 |
+
const { token, logout } = useAuth();
|
| 20 |
+
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
|
| 21 |
+
const [loadingDocs, setLoadingDocs] = useState(true);
|
| 22 |
+
const [uploading, setUploading] = useState(false);
|
| 23 |
+
const [file, setFile] = useState<File | null>(null);
|
| 24 |
+
const [url, setUrl] = useState('');
|
| 25 |
+
const [message, setMessage] = useState('');
|
| 26 |
+
|
| 27 |
+
// Preview modal state
|
| 28 |
+
const [previewOpen, setPreviewOpen] = useState(false);
|
| 29 |
+
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
|
| 30 |
+
const [previewLoading, setPreviewLoading] = useState(false);
|
| 31 |
+
|
| 32 |
+
interface TaskState {
|
| 33 |
+
status: string;
|
| 34 |
+
progress?: {
|
| 35 |
+
current_chunk?: number;
|
| 36 |
+
total_chunks?: number;
|
| 37 |
+
file?: string;
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const [tasks, setTasks] = useState<Record<string, TaskState>>({});
|
| 42 |
+
|
| 43 |
+
const fetchDocuments = async () => {
|
| 44 |
+
setLoadingDocs(true);
|
| 45 |
+
try {
|
| 46 |
+
const res = await fetch(`${API_BASE}/documents`, {
|
| 47 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 48 |
+
});
|
| 49 |
+
if (res.status === 401) { logout(); return; }
|
| 50 |
+
if (res.ok) {
|
| 51 |
+
const data = await res.json();
|
| 52 |
+
setDocuments(data.documents);
|
| 53 |
+
}
|
| 54 |
+
} catch (err) {
|
| 55 |
+
console.error('Failed to fetch docs', err);
|
| 56 |
+
} finally {
|
| 57 |
+
setLoadingDocs(false);
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
fetchDocuments();
|
| 63 |
+
const interval = setInterval(fetchDocuments, 10000);
|
| 64 |
+
return () => clearInterval(interval);
|
| 65 |
+
}, [token]);
|
| 66 |
+
|
| 67 |
+
useEffect(() => {
|
| 68 |
+
const activeTasks = Object.keys(tasks).filter(
|
| 69 |
+
tid => tasks[tid].status !== 'completed' && tasks[tid].status !== 'failure'
|
| 70 |
+
);
|
| 71 |
+
if (activeTasks.length === 0) return;
|
| 72 |
+
|
| 73 |
+
const interval = setInterval(() => {
|
| 74 |
+
activeTasks.forEach(async (tid) => {
|
| 75 |
+
try {
|
| 76 |
+
const res = await fetch(`${API_BASE}/documents/status/${tid}`, {
|
| 77 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 78 |
+
});
|
| 79 |
+
if (res.status === 401) { logout(); return; }
|
| 80 |
+
if (res.ok) {
|
| 81 |
+
const data = await res.json();
|
| 82 |
+
setTasks(prev => ({ ...prev, [tid]: { status: data.status, progress: data.progress } }));
|
| 83 |
+
if (data.status === 'completed' || data.status === 'failure') fetchDocuments();
|
| 84 |
+
}
|
| 85 |
+
} catch (e) {}
|
| 86 |
+
});
|
| 87 |
+
}, 2000);
|
| 88 |
+
|
| 89 |
+
return () => clearInterval(interval);
|
| 90 |
+
}, [tasks, token]);
|
| 91 |
+
|
| 92 |
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 93 |
+
if (e.target.files && e.target.files.length > 0) setFile(e.target.files[0]);
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const handleUpload = async (e: React.FormEvent) => {
|
| 97 |
+
e.preventDefault();
|
| 98 |
+
if (!file) return;
|
| 99 |
+
setUploading(true);
|
| 100 |
+
setMessage('');
|
| 101 |
+
const formData = new FormData();
|
| 102 |
+
formData.append('file', file);
|
| 103 |
+
try {
|
| 104 |
+
const res = await fetch(`${API_BASE}/documents/upload`, {
|
| 105 |
+
method: 'POST',
|
| 106 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 107 |
+
body: formData
|
| 108 |
+
});
|
| 109 |
+
if (res.status === 401) { logout(); return; }
|
| 110 |
+
const data = await res.json();
|
| 111 |
+
if (!res.ok) {
|
| 112 |
+
setMessage(`UPLOAD FAILED: ${data.detail}`);
|
| 113 |
+
} else {
|
| 114 |
+
setMessage('FILE UPLOADED. INGESTION TASK QUEUED.');
|
| 115 |
+
if (data.task_id) setTasks(prev => ({ ...prev, [data.task_id]: { status: 'pending' } }));
|
| 116 |
+
setFile(null);
|
| 117 |
+
fetchDocuments();
|
| 118 |
+
}
|
| 119 |
+
} catch (err: any) {
|
| 120 |
+
setMessage(`ERROR: ${err.message}`);
|
| 121 |
+
} finally {
|
| 122 |
+
setUploading(false);
|
| 123 |
+
}
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
const handleUrlScrape = async (e: React.FormEvent) => {
|
| 127 |
+
e.preventDefault();
|
| 128 |
+
if (!url.trim()) return;
|
| 129 |
+
setUploading(true);
|
| 130 |
+
setMessage('');
|
| 131 |
+
try {
|
| 132 |
+
const res = await fetch(`${API_BASE}/documents/scrape`, {
|
| 133 |
+
method: 'POST',
|
| 134 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 135 |
+
body: JSON.stringify({ url: url.trim() })
|
| 136 |
+
});
|
| 137 |
+
if (res.status === 401) { logout(); return; }
|
| 138 |
+
const data = await res.json();
|
| 139 |
+
if (!res.ok) {
|
| 140 |
+
setMessage(`SCRAPE FAILED: ${data.detail}`);
|
| 141 |
+
} else {
|
| 142 |
+
setMessage('URL SCRAPED. INGESTION TASK QUEUED.');
|
| 143 |
+
if (data.task_id) setTasks(prev => ({ ...prev, [data.task_id]: { status: 'pending' } }));
|
| 144 |
+
setUrl('');
|
| 145 |
+
fetchDocuments();
|
| 146 |
+
}
|
| 147 |
+
} catch (err: any) {
|
| 148 |
+
setMessage(`ERROR: ${err.message}`);
|
| 149 |
+
} finally {
|
| 150 |
+
setUploading(false);
|
| 151 |
+
}
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
const handleDelete = async (docId: string) => {
|
| 155 |
+
if (!window.confirm('WARNING: Delete this document and all its graph data?')) return;
|
| 156 |
+
try {
|
| 157 |
+
const res = await fetch(`${API_BASE}/documents/${docId}`, {
|
| 158 |
+
method: 'DELETE',
|
| 159 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 160 |
+
});
|
| 161 |
+
if (res.status === 401) { logout(); return; }
|
| 162 |
+
fetchDocuments();
|
| 163 |
+
} catch (err) {
|
| 164 |
+
console.error(err);
|
| 165 |
+
}
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const handleView = async (doc: any) => {
|
| 169 |
+
const fileType = (doc.file_type || '').toLowerCase();
|
| 170 |
+
|
| 171 |
+
// Text/scraped files → use inline preview modal
|
| 172 |
+
if (fileType === '.txt' || fileType === '.md' || fileType === '') {
|
| 173 |
+
setPreviewOpen(true);
|
| 174 |
+
setPreviewLoading(true);
|
| 175 |
+
setPreviewData(null);
|
| 176 |
+
try {
|
| 177 |
+
const res = await fetch(`${API_BASE}/documents/${doc.id}/preview`, {
|
| 178 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 179 |
+
});
|
| 180 |
+
if (res.status === 401) { logout(); return; }
|
| 181 |
+
if (res.ok) {
|
| 182 |
+
const data = await res.json();
|
| 183 |
+
setPreviewData(data);
|
| 184 |
+
} else {
|
| 185 |
+
const err = await res.json();
|
| 186 |
+
setMessage(`PREVIEW ERROR: ${err.detail}`);
|
| 187 |
+
setPreviewOpen(false);
|
| 188 |
+
}
|
| 189 |
+
} catch (err) {
|
| 190 |
+
setMessage('ERROR: Failed to load preview.');
|
| 191 |
+
setPreviewOpen(false);
|
| 192 |
+
} finally {
|
| 193 |
+
setPreviewLoading(false);
|
| 194 |
+
}
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// Binary files (PDF) → open in new tab as before
|
| 199 |
+
const newTab = window.open('', '_blank');
|
| 200 |
+
if (!newTab) { setMessage('ERROR: Please allow popups to view PDFs.'); return; }
|
| 201 |
+
newTab.document.write('<div style="font-family:monospace;padding:20px;">Fetching secure document...</div>');
|
| 202 |
+
try {
|
| 203 |
+
const res = await fetch(`${API_BASE}/documents/${doc.id}/download`, {
|
| 204 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 205 |
+
});
|
| 206 |
+
if (res.status === 401) { newTab.close(); logout(); return; }
|
| 207 |
+
if (res.ok) {
|
| 208 |
+
const contentType = res.headers.get('content-type') || 'application/pdf';
|
| 209 |
+
const blob = await res.blob();
|
| 210 |
+
const fileBlob = new Blob([blob], { type: contentType });
|
| 211 |
+
const blobUrl = window.URL.createObjectURL(fileBlob);
|
| 212 |
+
newTab.location.href = blobUrl;
|
| 213 |
+
} else {
|
| 214 |
+
newTab.close();
|
| 215 |
+
setMessage('ERROR: Document file not found on server.');
|
| 216 |
+
}
|
| 217 |
+
} catch (err) {
|
| 218 |
+
newTab.close();
|
| 219 |
+
setMessage('ERROR: Failed to open document.');
|
| 220 |
+
}
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
const isTxtDoc = (doc: any) => {
|
| 224 |
+
const ft = (doc.file_type || '').toLowerCase();
|
| 225 |
+
return ft === '.txt' || ft === '.md' || ft === '';
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
return (
|
| 229 |
+
<div className="container" style={{ animation: 'fadeIn 0.5s ease' }}>
|
| 230 |
+
<div className="page-header flex-between">
|
| 231 |
+
<div>
|
| 232 |
+
<h1>INGESTION PIPELINE</h1>
|
| 233 |
+
<p className="mono-text">DATA PROCESSING & GRAPH CONSTRUCTION</p>
|
| 234 |
+
</div>
|
| 235 |
+
<Activity size={32} />
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
<div className="process-layout">
|
| 239 |
+
{/* Upload / Scrape Section */}
|
| 240 |
+
<div className="card upload-card">
|
| 241 |
+
<h2 className="mono-text flex-center"><FilePlus style={{ marginRight: '0.5rem' }}/> ADD DOCUMENT</h2>
|
| 242 |
+
|
| 243 |
+
<form onSubmit={handleUpload} className="upload-form">
|
| 244 |
+
<div className="file-input-wrapper">
|
| 245 |
+
<input type="file" id="file-upload" onChange={handleFileChange} accept=".pdf,.txt,.md" />
|
| 246 |
+
<label htmlFor="file-upload" className="file-label">
|
| 247 |
+
{file ? file.name : 'SELECT FILE FOR INGESTION'}
|
| 248 |
+
</label>
|
| 249 |
+
</div>
|
| 250 |
+
<button
|
| 251 |
+
type="submit"
|
| 252 |
+
className="app-btn full-width"
|
| 253 |
+
disabled={!file || uploading}
|
| 254 |
+
style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '8px' }}
|
| 255 |
+
>
|
| 256 |
+
{uploading && file ? 'UPLOADING...' : <><Upload size={18} /> INITIALIZE FILE INGESTION</>}
|
| 257 |
+
</button>
|
| 258 |
+
</form>
|
| 259 |
+
|
| 260 |
+
<hr style={{ margin: '2rem 0', borderColor: 'var(--border-color)', borderStyle: 'dotted' }} />
|
| 261 |
+
|
| 262 |
+
<h2 className="mono-text flex-center"><Globe style={{ marginRight: '0.5rem' }}/> SCRAPE URL</h2>
|
| 263 |
+
<p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '1rem', lineHeight: 1.5 }}>
|
| 264 |
+
Paste any public URL — articles, docs, Wikipedia pages — to scrape and ingest the content into your knowledge graph.
|
| 265 |
+
</p>
|
| 266 |
+
<form onSubmit={handleUrlScrape} className="upload-form">
|
| 267 |
+
<input
|
| 268 |
+
type="url"
|
| 269 |
+
className="query-input mono-text"
|
| 270 |
+
placeholder="https://example.com/article"
|
| 271 |
+
value={url}
|
| 272 |
+
onChange={(e) => setUrl(e.target.value)}
|
| 273 |
+
required
|
| 274 |
+
style={{ width: '100%', marginBottom: '1rem' }}
|
| 275 |
+
/>
|
| 276 |
+
<button
|
| 277 |
+
type="submit"
|
| 278 |
+
className="app-btn full-width"
|
| 279 |
+
disabled={!url || uploading}
|
| 280 |
+
style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
|
| 281 |
+
>
|
| 282 |
+
{uploading && url ? 'SCRAPING...' : <><Activity size={18} /> INGEST FROM URL</>}
|
| 283 |
+
</button>
|
| 284 |
+
</form>
|
| 285 |
+
|
| 286 |
+
{Object.keys(tasks).length > 0 && (
|
| 287 |
+
<div className="task-tracker" style={{ marginTop: '1rem' }}>
|
| 288 |
+
<h4 className="mono-text">ACTIVE TASKS</h4>
|
| 289 |
+
{Object.entries(tasks).map(([tid, taskObj]) => {
|
| 290 |
+
const pct = (taskObj.progress?.current_chunk && taskObj.progress?.total_chunks)
|
| 291 |
+
? Math.round((taskObj.progress.current_chunk / taskObj.progress.total_chunks) * 100)
|
| 292 |
+
: 0;
|
| 293 |
+
return (
|
| 294 |
+
<div key={tid} className="task-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '8px' }}>
|
| 295 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
| 296 |
+
<span title={tid}>{tid.substring(0,8)}...</span>
|
| 297 |
+
<span className={`status-badge ${taskObj.status}`}>
|
| 298 |
+
{taskObj.status.toUpperCase()}{taskObj.status === 'processing' && pct > 0 ? ` ${pct}%` : ''}
|
| 299 |
+
</span>
|
| 300 |
+
</div>
|
| 301 |
+
{taskObj.status === 'processing' && taskObj.progress?.total_chunks && (
|
| 302 |
+
<div style={{ width: '100%', height: '4px', background: '#e0e0e0', borderRadius: '2px', overflow: 'hidden' }}>
|
| 303 |
+
<div style={{ width: `${pct}%`, height: '100%', background: '#000', transition: 'width 0.3s ease' }}></div>
|
| 304 |
+
</div>
|
| 305 |
+
)}
|
| 306 |
+
</div>
|
| 307 |
+
);
|
| 308 |
+
})}
|
| 309 |
+
</div>
|
| 310 |
+
)}
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
{/* Corpus Table */}
|
| 314 |
+
<div className="card documents-card">
|
| 315 |
+
<h2 className="mono-text">CORPUS INDEX</h2>
|
| 316 |
+
<div className="table-wrapper">
|
| 317 |
+
<table className="docs-table">
|
| 318 |
+
<thead>
|
| 319 |
+
<tr>
|
| 320 |
+
<th>DOCUMENT NAME</th>
|
| 321 |
+
<th>TYPE</th>
|
| 322 |
+
<th>SIZE</th>
|
| 323 |
+
<th>INGESTED</th>
|
| 324 |
+
<th>ACTIONS</th>
|
| 325 |
+
</tr>
|
| 326 |
+
</thead>
|
| 327 |
+
<tbody>
|
| 328 |
+
{documents.length === 0 && !loadingDocs ? (
|
| 329 |
+
<tr><td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }} className="mono-text">
|
| 330 |
+
<p style={{ marginBottom: '1rem' }}>NO DOCUMENTS INDEXED IN GRAPH DATABASE</p>
|
| 331 |
+
<p style={{ color: '#666', fontSize: '0.85rem' }}>Upload a file or scrape a URL above to begin.</p>
|
| 332 |
+
</td></tr>
|
| 333 |
+
) : loadingDocs ? (
|
| 334 |
+
<tr><td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }} className="mono-text">
|
| 335 |
+
FETCHING CORPUS DATA...
|
| 336 |
+
</td></tr>
|
| 337 |
+
) : (
|
| 338 |
+
documents.map((doc: any) => (
|
| 339 |
+
<tr key={doc.id}>
|
| 340 |
+
<td className="mono-text" style={{ maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
| 341 |
+
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
| 342 |
+
{isTxtDoc(doc) ? <Link size={14} /> : <FileText size={14} />}
|
| 343 |
+
{doc.filename}
|
| 344 |
+
</span>
|
| 345 |
+
</td>
|
| 346 |
+
<td>
|
| 347 |
+
<span className={`type-badge ${isTxtDoc(doc) ? 'scraped' : 'pdf'}`}>
|
| 348 |
+
{isTxtDoc(doc) ? 'WEB / TXT' : (doc.file_type || 'PDF').toUpperCase()}
|
| 349 |
+
</span>
|
| 350 |
+
</td>
|
| 351 |
+
<td className="mono-text">{(doc.size_bytes / 1024).toFixed(1)} KB</td>
|
| 352 |
+
<td className="mono-text" style={{ fontSize: '0.8rem' }}>{doc.upload_date?.substring(0,19) || '—'}</td>
|
| 353 |
+
<td style={{ whiteSpace: 'nowrap' }}>
|
| 354 |
+
<button
|
| 355 |
+
className="icon-btn"
|
| 356 |
+
onClick={() => handleView(doc)}
|
| 357 |
+
title={isTxtDoc(doc) ? 'Preview Content' : 'Open PDF'}
|
| 358 |
+
style={{ marginRight: '10px' }}
|
| 359 |
+
>
|
| 360 |
+
<Eye size={16} />
|
| 361 |
+
</button>
|
| 362 |
+
<button className="icon-btn" onClick={() => handleDelete(doc.id)} title="Delete Document">
|
| 363 |
+
<Trash2 size={16} />
|
| 364 |
+
</button>
|
| 365 |
+
</td>
|
| 366 |
+
</tr>
|
| 367 |
+
))
|
| 368 |
+
)}
|
| 369 |
+
</tbody>
|
| 370 |
+
</table>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
|
| 375 |
+
{/* Document Preview Modal */}
|
| 376 |
+
{previewOpen && (
|
| 377 |
+
<div className="preview-overlay" onClick={() => setPreviewOpen(false)}>
|
| 378 |
+
<div className="preview-modal" onClick={(e) => e.stopPropagation()}>
|
| 379 |
+
{/* Modal Header */}
|
| 380 |
+
<div className="preview-header">
|
| 381 |
+
<div className="preview-header-left">
|
| 382 |
+
<BookOpen size={20} />
|
| 383 |
+
<div>
|
| 384 |
+
<div className="preview-title">{previewData?.filename || 'Loading...'}</div>
|
| 385 |
+
{previewData && (
|
| 386 |
+
<div className="preview-meta-row">
|
| 387 |
+
<span className="preview-meta-chip"><Hash size={12} /> {previewData.word_count.toLocaleString()} words</span>
|
| 388 |
+
<span className="preview-meta-chip"><FileText size={12} /> {(previewData.char_count / 1000).toFixed(1)}K chars</span>
|
| 389 |
+
<span className="type-badge scraped">WEB SCRAPE</span>
|
| 390 |
+
</div>
|
| 391 |
+
)}
|
| 392 |
+
</div>
|
| 393 |
+
</div>
|
| 394 |
+
<button className="icon-btn" onClick={() => setPreviewOpen(false)} style={{ fontSize: '1.5rem' }}>
|
| 395 |
+
<X size={22} />
|
| 396 |
+
</button>
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
{/* Modal Body */}
|
| 400 |
+
<div className="preview-body">
|
| 401 |
+
{previewLoading ? (
|
| 402 |
+
<div className="loading-spinner"><Activity size={32} style={{ animation: 'pulse 1.5s infinite' }} /> Fetching Content...</div>
|
| 403 |
+
) : previewData ? (
|
| 404 |
+
<div className="markdown-content formatted">
|
| 405 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{previewData.content}</ReactMarkdown>
|
| 406 |
+
</div>
|
| 407 |
+
) : (
|
| 408 |
+
<div className="error-text">Failed to load content.</div>
|
| 409 |
+
)}
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
</div>
|
| 413 |
+
)}
|
| 414 |
+
|
| 415 |
+
{message && (
|
| 416 |
+
<div className={`status-toast ${message.includes('ERROR') || message.includes('FAILED') ? 'error' : ''}`}>
|
| 417 |
+
<Activity size={20} /> {message}
|
| 418 |
+
<button
|
| 419 |
+
onClick={() => setMessage('')}
|
| 420 |
+
className="toast-dismiss-btn"
|
| 421 |
+
>
|
| 422 |
+
×
|
| 423 |
+
</button>
|
| 424 |
+
</div>
|
| 425 |
+
)}
|
| 426 |
+
|
| 427 |
+
<style>{`
|
| 428 |
+
.process-layout {
|
| 429 |
+
display: grid;
|
| 430 |
+
grid-template-columns: 1fr 2fr;
|
| 431 |
+
gap: 2rem;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
@media (max-width: 900px) {
|
| 435 |
+
.process-layout { grid-template-columns: 1fr; }
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.file-input-wrapper {
|
| 439 |
+
position: relative;
|
| 440 |
+
overflow: hidden;
|
| 441 |
+
display: inline-block;
|
| 442 |
+
width: 100%;
|
| 443 |
+
border: 2px dashed var(--border-color);
|
| 444 |
+
text-align: center;
|
| 445 |
+
background: var(--hover-bg);
|
| 446 |
+
transition: var(--transition-speed);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.file-input-wrapper:hover { background: #e0e0e0; }
|
| 450 |
+
|
| 451 |
+
.file-input-wrapper input[type=file] {
|
| 452 |
+
font-size: 100px;
|
| 453 |
+
position: absolute;
|
| 454 |
+
left: 0; top: 0;
|
| 455 |
+
opacity: 0;
|
| 456 |
+
cursor: pointer;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.file-label {
|
| 460 |
+
display: block;
|
| 461 |
+
padding: 2rem;
|
| 462 |
+
font-family: var(--font-mono);
|
| 463 |
+
font-weight: 600;
|
| 464 |
+
cursor: pointer;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.task-tracker {
|
| 468 |
+
margin-top: 2rem;
|
| 469 |
+
border-top: 2px solid var(--border-color);
|
| 470 |
+
padding-top: 1rem;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.task-row {
|
| 474 |
+
display: flex;
|
| 475 |
+
justify-content: space-between;
|
| 476 |
+
padding: 0.5rem 0;
|
| 477 |
+
font-family: var(--font-mono);
|
| 478 |
+
font-size: 0.85rem;
|
| 479 |
+
border-bottom: 1px dotted var(--border-color);
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.status-badge {
|
| 483 |
+
padding: 0.1rem 0.5rem;
|
| 484 |
+
background: var(--text-color);
|
| 485 |
+
color: var(--bg-color);
|
| 486 |
+
font-weight: 700;
|
| 487 |
+
font-size: 0.75rem;
|
| 488 |
+
font-family: var(--font-mono);
|
| 489 |
+
}
|
| 490 |
+
.status-badge.processing { background: #666; }
|
| 491 |
+
.status-badge.completed { background: #000; }
|
| 492 |
+
.status-badge.failure { background: #990000; color: white; }
|
| 493 |
+
|
| 494 |
+
.type-badge {
|
| 495 |
+
display: inline-block;
|
| 496 |
+
padding: 0.15rem 0.5rem;
|
| 497 |
+
font-size: 0.7rem;
|
| 498 |
+
font-family: var(--font-mono);
|
| 499 |
+
font-weight: 700;
|
| 500 |
+
letter-spacing: 0.5px;
|
| 501 |
+
border: 1.5px solid var(--border-color);
|
| 502 |
+
}
|
| 503 |
+
.type-badge.scraped { background: #000; color: #fff; border-color: #000; }
|
| 504 |
+
.type-badge.pdf { background: transparent; color: #444; }
|
| 505 |
+
|
| 506 |
+
.table-wrapper {
|
| 507 |
+
overflow-x: auto;
|
| 508 |
+
margin-top: 1rem;
|
| 509 |
+
width: 100%;
|
| 510 |
+
-webkit-overflow-scrolling: touch;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.docs-table {
|
| 514 |
+
width: 100%;
|
| 515 |
+
border-collapse: collapse;
|
| 516 |
+
border: 2px solid var(--border-color);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.docs-table th, .docs-table td {
|
| 520 |
+
border: 1px solid var(--border-color);
|
| 521 |
+
padding: 0.75rem;
|
| 522 |
+
text-align: left;
|
| 523 |
+
font-size: 0.9rem;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.docs-table th {
|
| 527 |
+
background-color: var(--text-color);
|
| 528 |
+
color: var(--bg-color);
|
| 529 |
+
font-family: var(--font-display);
|
| 530 |
+
letter-spacing: 1px;
|
| 531 |
+
white-space: nowrap;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
.docs-table tr:hover { background-color: var(--hover-bg); }
|
| 535 |
+
|
| 536 |
+
.icon-btn {
|
| 537 |
+
background: none;
|
| 538 |
+
border: none;
|
| 539 |
+
cursor: pointer;
|
| 540 |
+
color: var(--text-color);
|
| 541 |
+
padding: 4px;
|
| 542 |
+
}
|
| 543 |
+
.icon-btn:hover { color: #ff0000; }
|
| 544 |
+
|
| 545 |
+
/* Preview Modal */
|
| 546 |
+
.preview-overlay {
|
| 547 |
+
position: fixed;
|
| 548 |
+
inset: 0;
|
| 549 |
+
background: rgba(0, 0, 0, 0.65);
|
| 550 |
+
z-index: 5000;
|
| 551 |
+
display: flex;
|
| 552 |
+
align-items: center;
|
| 553 |
+
justify-content: center;
|
| 554 |
+
padding: 2rem;
|
| 555 |
+
animation: fadeIn 0.2s ease;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.preview-modal {
|
| 559 |
+
background: var(--bg-color);
|
| 560 |
+
border: 3px solid var(--border-color);
|
| 561 |
+
box-shadow: 8px 8px 0 var(--border-color);
|
| 562 |
+
width: 100%;
|
| 563 |
+
max-width: 860px;
|
| 564 |
+
max-height: 85vh;
|
| 565 |
+
display: flex;
|
| 566 |
+
flex-direction: column;
|
| 567 |
+
animation: scaleIn 0.2s ease;
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
@keyframes scaleIn {
|
| 571 |
+
from { transform: scale(0.95); opacity: 0; }
|
| 572 |
+
to { transform: scale(1); opacity: 1; }
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
.preview-header {
|
| 576 |
+
display: flex;
|
| 577 |
+
align-items: flex-start;
|
| 578 |
+
justify-content: space-between;
|
| 579 |
+
padding: 1.25rem 1.5rem;
|
| 580 |
+
border-bottom: 2px solid var(--border-color);
|
| 581 |
+
background: var(--text-color);
|
| 582 |
+
color: var(--bg-color);
|
| 583 |
+
gap: 1rem;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.preview-header-left {
|
| 587 |
+
display: flex;
|
| 588 |
+
align-items: flex-start;
|
| 589 |
+
gap: 1rem;
|
| 590 |
+
flex: 1;
|
| 591 |
+
min-width: 0;
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
.preview-title {
|
| 595 |
+
font-family: var(--font-mono);
|
| 596 |
+
font-weight: 700;
|
| 597 |
+
font-size: 0.95rem;
|
| 598 |
+
word-break: break-all;
|
| 599 |
+
margin-bottom: 0.4rem;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.preview-meta-row {
|
| 603 |
+
display: flex;
|
| 604 |
+
flex-wrap: wrap;
|
| 605 |
+
gap: 0.5rem;
|
| 606 |
+
align-items: center;
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
.preview-meta-chip {
|
| 610 |
+
display: inline-flex;
|
| 611 |
+
align-items: center;
|
| 612 |
+
gap: 4px;
|
| 613 |
+
background: rgba(255,255,255,0.15);
|
| 614 |
+
padding: 0.15rem 0.5rem;
|
| 615 |
+
font-size: 0.75rem;
|
| 616 |
+
font-family: var(--font-mono);
|
| 617 |
+
border-radius: 2px;
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
.preview-header .icon-btn { color: var(--bg-color); }
|
| 621 |
+
.preview-header .icon-btn:hover { color: #ffaaaa; }
|
| 622 |
+
|
| 623 |
+
.preview-body {
|
| 624 |
+
flex: 1;
|
| 625 |
+
overflow-y: auto;
|
| 626 |
+
padding: 0;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.markdown-content.formatted {
|
| 630 |
+
padding: 2rem;
|
| 631 |
+
font-family: var(--font-sans);
|
| 632 |
+
line-height: 1.6;
|
| 633 |
+
color: #333;
|
| 634 |
+
font-size: 1rem;
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.markdown-content.formatted h1,
|
| 638 |
+
.markdown-content.formatted h2,
|
| 639 |
+
.markdown-content.formatted h3 {
|
| 640 |
+
font-family: var(--font-mono);
|
| 641 |
+
color: #000;
|
| 642 |
+
margin-top: 1.5em;
|
| 643 |
+
margin-bottom: 0.5em;
|
| 644 |
+
border-bottom: 2px solid #eaeaea;
|
| 645 |
+
padding-bottom: 0.3em;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
.markdown-content.formatted p {
|
| 649 |
+
margin-bottom: 1em;
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.markdown-content.formatted ul,
|
| 653 |
+
.markdown-content.formatted ol {
|
| 654 |
+
margin-bottom: 1em;
|
| 655 |
+
padding-left: 2em;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.markdown-content.formatted a {
|
| 659 |
+
color: #0056b3;
|
| 660 |
+
text-decoration: underline;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.markdown-content.formatted code {
|
| 664 |
+
font-family: var(--font-mono);
|
| 665 |
+
background: #f4f4f4;
|
| 666 |
+
padding: 0.1em 0.3em;
|
| 667 |
+
border-radius: 3px;
|
| 668 |
+
font-size: 0.9em;
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
.markdown-content.formatted pre {
|
| 672 |
+
background: #111;
|
| 673 |
+
color: #fff;
|
| 674 |
+
padding: 1em;
|
| 675 |
+
overflow-x: auto;
|
| 676 |
+
border-radius: 4px;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
.markdown-content.formatted pre code {
|
| 680 |
+
background: transparent;
|
| 681 |
+
color: inherit;
|
| 682 |
+
padding: 0;
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
.markdown-content.formatted blockquote {
|
| 686 |
+
border-left: 4px solid #ccc;
|
| 687 |
+
margin: 0;
|
| 688 |
+
padding-left: 1em;
|
| 689 |
+
color: #666;
|
| 690 |
+
font-style: italic;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.preview-loading {
|
| 694 |
+
display: flex;
|
| 695 |
+
flex-direction: column;
|
| 696 |
+
align-items: center;
|
| 697 |
+
justify-content: center;
|
| 698 |
+
gap: 1rem;
|
| 699 |
+
height: 300px;
|
| 700 |
+
font-family: var(--font-mono);
|
| 701 |
+
font-weight: bold;
|
| 702 |
+
letter-spacing: 2px;
|
| 703 |
+
color: #666;
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
.preview-content {
|
| 707 |
+
font-family: var(--font-mono);
|
| 708 |
+
font-size: 0.82rem;
|
| 709 |
+
line-height: 1.7;
|
| 710 |
+
white-space: pre-wrap;
|
| 711 |
+
word-break: break-word;
|
| 712 |
+
padding: 2rem;
|
| 713 |
+
margin: 0;
|
| 714 |
+
color: #111;
|
| 715 |
+
border: none;
|
| 716 |
+
background: #fafafa;
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
.spin-slow {
|
| 720 |
+
animation: spin 1.5s linear infinite;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
@keyframes spin {
|
| 724 |
+
100% { transform: rotate(360deg); }
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
.status-toast {
|
| 728 |
+
position: fixed;
|
| 729 |
+
bottom: 2rem;
|
| 730 |
+
right: 2rem;
|
| 731 |
+
background: var(--text-color);
|
| 732 |
+
color: var(--bg-color);
|
| 733 |
+
padding: 1rem 1.5rem;
|
| 734 |
+
border-left: 6px solid var(--text-color);
|
| 735 |
+
box-shadow: 4px 4px 0 var(--border-color);
|
| 736 |
+
display: flex;
|
| 737 |
+
align-items: center;
|
| 738 |
+
gap: 1rem;
|
| 739 |
+
z-index: 9999;
|
| 740 |
+
font-family: var(--font-mono);
|
| 741 |
+
font-weight: bold;
|
| 742 |
+
font-size: 0.9rem;
|
| 743 |
+
animation: slideUp 0.3s ease-out;
|
| 744 |
+
max-width: 420px;
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
.status-toast.error { border-left-color: #ff0000; }
|
| 748 |
+
|
| 749 |
+
@keyframes slideUp {
|
| 750 |
+
from { transform: translateY(100px); opacity: 0; }
|
| 751 |
+
to { transform: translateY(0); opacity: 1; }
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
@keyframes fadeIn {
|
| 755 |
+
from { opacity: 0; }
|
| 756 |
+
to { opacity: 1; }
|
| 757 |
+
}
|
| 758 |
+
`}</style>
|
| 759 |
+
</div>
|
| 760 |
+
);
|
| 761 |
+
};
|
| 762 |
+
|
| 763 |
+
export default Process;
|
frontend-react/src/views/SimulationRunView.tsx
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import { GraphNode, GraphEdge, DocumentInfo } from '../types/api';
|
| 4 |
+
import GraphCanvas, { DEFAULT_OPTIONS } from '../components/GraphCanvas';
|
| 5 |
+
import type { GraphOptions, GraphCanvasHandle } from '../components/GraphCanvas';
|
| 6 |
+
import {
|
| 7 |
+
RefreshCw, Play, Database, Info, Maximize2, Minimize2,
|
| 8 |
+
Download, SlidersHorizontal, X, Layers, Tag, Search,
|
| 9 |
+
Image, GitBranch, HelpCircle
|
| 10 |
+
} from 'lucide-react';
|
| 11 |
+
|
| 12 |
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
|
| 13 |
+
|
| 14 |
+
const TYPE_COLORS = [
|
| 15 |
+
'#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261',
|
| 16 |
+
'#6a4c93','#1982c4','#8ac926','#ff595e','#6a994e',
|
| 17 |
+
'#bc4749','#a8dadc'
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
const SimulationRunView: React.FC = () => {
|
| 21 |
+
const { token, logout } = useAuth();
|
| 22 |
+
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [] });
|
| 23 |
+
const [loading, setLoading] = useState(false);
|
| 24 |
+
const [limit, setLimit] = useState(100);
|
| 25 |
+
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
|
| 26 |
+
const [selectedDocId, setSelectedDocId] = useState('');
|
| 27 |
+
const [nodeCount, setNodeCount] = useState(0);
|
| 28 |
+
const [edgeCount, setEdgeCount] = useState(0);
|
| 29 |
+
|
| 30 |
+
// UI state
|
| 31 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 32 |
+
const [showPanel, setShowPanel] = useState(false);
|
| 33 |
+
const [showHelp, setShowHelp] = useState(false);
|
| 34 |
+
const [showSearch, setShowSearch] = useState(false);
|
| 35 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 36 |
+
const [highlightNodeIds, setHighlightNodeIds] = useState<Set<string>>(new Set());
|
| 37 |
+
|
| 38 |
+
// Graph options
|
| 39 |
+
const [options, setOptions] = useState<GraphOptions>({ ...DEFAULT_OPTIONS });
|
| 40 |
+
|
| 41 |
+
// Canvas handle
|
| 42 |
+
const canvasRef = useRef<GraphCanvasHandle>(null);
|
| 43 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 44 |
+
|
| 45 |
+
// Derived legend
|
| 46 |
+
const typeLegend = React.useMemo(() => {
|
| 47 |
+
const types = [...new Set(graphData.nodes.map(n => n.type || 'Unknown'))];
|
| 48 |
+
return types.map((t, i) => ({ type: t, color: TYPE_COLORS[i % TYPE_COLORS.length] }));
|
| 49 |
+
}, [graphData.nodes]);
|
| 50 |
+
|
| 51 |
+
// Graph stats
|
| 52 |
+
const degreeStats = React.useMemo(() => {
|
| 53 |
+
if (!graphData.nodes.length) return null;
|
| 54 |
+
const degMap = new Map<string, number>();
|
| 55 |
+
graphData.nodes.forEach(n => degMap.set(n.id, 0));
|
| 56 |
+
graphData.edges.forEach(e => {
|
| 57 |
+
degMap.set(e.source, (degMap.get(e.source) || 0) + 1);
|
| 58 |
+
degMap.set(e.target, (degMap.get(e.target) || 0) + 1);
|
| 59 |
+
});
|
| 60 |
+
const degrees = [...degMap.values()];
|
| 61 |
+
const avg = degrees.reduce((a, b) => a + b, 0) / degrees.length;
|
| 62 |
+
const max = Math.max(...degrees);
|
| 63 |
+
const hubs = graphData.nodes.filter(n => (degMap.get(n.id) || 0) >= avg * 2);
|
| 64 |
+
return { avg: avg.toFixed(1), max, hubs: hubs.slice(0, 5) };
|
| 65 |
+
}, [graphData]);
|
| 66 |
+
|
| 67 |
+
// ── Data fetching ──────────────────────────────────────────────────────
|
| 68 |
+
const fetchDocuments = useCallback(async () => {
|
| 69 |
+
try {
|
| 70 |
+
const res = await fetch(`${API_BASE}/documents`, { headers: { Authorization: `Bearer ${token}` } });
|
| 71 |
+
if (res.ok) setDocuments((await res.json()).documents);
|
| 72 |
+
} catch {}
|
| 73 |
+
}, [token]);
|
| 74 |
+
|
| 75 |
+
const fetchGraph = useCallback(async (docId: string, nodeLimit: number) => {
|
| 76 |
+
setLoading(true);
|
| 77 |
+
try {
|
| 78 |
+
const url = new URL(`${API_BASE}/graph/visualization`);
|
| 79 |
+
url.searchParams.append('limit', nodeLimit.toString());
|
| 80 |
+
if (docId) url.searchParams.append('document_id', docId);
|
| 81 |
+
const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` } });
|
| 82 |
+
if (res.status === 401) { logout(); return; }
|
| 83 |
+
if (res.ok) {
|
| 84 |
+
const data = await res.json();
|
| 85 |
+
setGraphData(data);
|
| 86 |
+
setNodeCount(data.nodes?.length ?? 0);
|
| 87 |
+
setEdgeCount(data.edges?.length ?? 0);
|
| 88 |
+
setHighlightNodeIds(new Set());
|
| 89 |
+
setSearchQuery('');
|
| 90 |
+
}
|
| 91 |
+
} catch (err) {
|
| 92 |
+
console.error('Graph fetch error:', err);
|
| 93 |
+
} finally {
|
| 94 |
+
setLoading(false);
|
| 95 |
+
}
|
| 96 |
+
}, [token, logout]);
|
| 97 |
+
|
| 98 |
+
const handleNodeUpdate = useCallback(async (nodeId: string, newName: string) => {
|
| 99 |
+
try {
|
| 100 |
+
const res = await fetch(`${API_BASE}/entities/${nodeId}`, {
|
| 101 |
+
method: 'PUT',
|
| 102 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 103 |
+
body: JSON.stringify({ name: newName })
|
| 104 |
+
});
|
| 105 |
+
if (res.status === 401) { logout(); return; }
|
| 106 |
+
if (res.ok) {
|
| 107 |
+
setGraphData(prev => ({
|
| 108 |
+
...prev,
|
| 109 |
+
nodes: prev.nodes.map(n => n.id === nodeId ? { ...n, label: newName } : n)
|
| 110 |
+
}));
|
| 111 |
+
}
|
| 112 |
+
} catch {}
|
| 113 |
+
}, [token, logout]);
|
| 114 |
+
|
| 115 |
+
useEffect(() => { fetchDocuments(); }, [fetchDocuments]);
|
| 116 |
+
useEffect(() => { fetchGraph(selectedDocId, limit); }, [fetchGraph, selectedDocId, limit]);
|
| 117 |
+
|
| 118 |
+
// Search handler
|
| 119 |
+
const handleSearch = useCallback((q: string) => {
|
| 120 |
+
setSearchQuery(q);
|
| 121 |
+
if (!q.trim()) {
|
| 122 |
+
setHighlightNodeIds(new Set());
|
| 123 |
+
return;
|
| 124 |
+
}
|
| 125 |
+
const lower = q.toLowerCase();
|
| 126 |
+
const matched = new Set(
|
| 127 |
+
graphData.nodes
|
| 128 |
+
.filter(n =>
|
| 129 |
+
n.label?.toLowerCase().includes(lower) ||
|
| 130 |
+
n.type?.toLowerCase().includes(lower)
|
| 131 |
+
)
|
| 132 |
+
.map(n => n.id)
|
| 133 |
+
);
|
| 134 |
+
setHighlightNodeIds(matched);
|
| 135 |
+
// Zoom to first match
|
| 136 |
+
if (matched.size > 0) {
|
| 137 |
+
const firstId = [...matched][0];
|
| 138 |
+
canvasRef.current?.highlightNode(firstId);
|
| 139 |
+
}
|
| 140 |
+
}, [graphData.nodes]);
|
| 141 |
+
|
| 142 |
+
// ── Fullscreen ─────────────────────────────────────────────────────────
|
| 143 |
+
const toggleFullscreen = useCallback(() => {
|
| 144 |
+
if (!isFullscreen) {
|
| 145 |
+
containerRef.current?.requestFullscreen?.().catch(() => setIsFullscreen(true));
|
| 146 |
+
} else {
|
| 147 |
+
document.exitFullscreen?.().catch(() => setIsFullscreen(false));
|
| 148 |
+
}
|
| 149 |
+
setIsFullscreen(f => !f);
|
| 150 |
+
}, [isFullscreen]);
|
| 151 |
+
|
| 152 |
+
useEffect(() => {
|
| 153 |
+
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
| 154 |
+
document.addEventListener('fullscreenchange', handler);
|
| 155 |
+
return () => document.removeEventListener('fullscreenchange', handler);
|
| 156 |
+
}, []);
|
| 157 |
+
|
| 158 |
+
// ── Option helpers ─────────────────────────────────────────────────────
|
| 159 |
+
const setOpt = <K extends keyof GraphOptions>(key: K, val: GraphOptions[K]) =>
|
| 160 |
+
setOptions(prev => ({ ...prev, [key]: val }));
|
| 161 |
+
|
| 162 |
+
const selectedDocName = documents.find(d => d.id === selectedDocId)?.filename || '';
|
| 163 |
+
|
| 164 |
+
return (
|
| 165 |
+
<div ref={containerRef} className={`simulation-root${isFullscreen ? ' fullscreen' : ''}`}>
|
| 166 |
+
|
| 167 |
+
{/* ── Top bar ──────────────────────────────────────────────────────── */}
|
| 168 |
+
<div className="sim-topbar">
|
| 169 |
+
<div className="sim-title-group">
|
| 170 |
+
<h2>GRAPH VISUALIZATION</h2>
|
| 171 |
+
<div className="sim-chips">
|
| 172 |
+
<span className="s-chip"><Database size={11}/> {nodeCount} nodes</span>
|
| 173 |
+
<span className="s-chip">{edgeCount} edges</span>
|
| 174 |
+
{degreeStats && <span className="s-chip" title="Average connections per node">avg° {degreeStats.avg}</span>}
|
| 175 |
+
{selectedDocId && (
|
| 176 |
+
<span className="s-chip active" title={selectedDocName}>
|
| 177 |
+
{selectedDocName.length > 22 ? selectedDocName.substring(0,20)+'…' : selectedDocName}
|
| 178 |
+
</span>
|
| 179 |
+
)}
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<div className="sim-controls">
|
| 184 |
+
{/* Doc filter */}
|
| 185 |
+
<div className="ctrl-grp">
|
| 186 |
+
<label className="ctrl-lbl">DOCUMENT</label>
|
| 187 |
+
<select className="sim-sel" value={selectedDocId} onChange={e => setSelectedDocId(e.target.value)}>
|
| 188 |
+
<option value="">🌐 ALL</option>
|
| 189 |
+
{documents.map(doc => (
|
| 190 |
+
<option key={doc.id} value={doc.id}>📄 {doc.filename.length > 28 ? doc.filename.substring(0,26)+'…' : doc.filename}</option>
|
| 191 |
+
))}
|
| 192 |
+
</select>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{/* Limit */}
|
| 196 |
+
<div className="ctrl-grp">
|
| 197 |
+
<label className="ctrl-lbl">NODES</label>
|
| 198 |
+
<select className="sim-sel" value={limit} onChange={e => setLimit(Number(e.target.value))}>
|
| 199 |
+
{[50, 100, 200, 500, 1000].map(v => <option key={v} value={v}>{v}</option>)}
|
| 200 |
+
</select>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* Action buttons */}
|
| 204 |
+
<div className="sim-actions">
|
| 205 |
+
<button className="sim-icon-btn" title="Refresh graph" onClick={() => fetchGraph(selectedDocId, limit)} disabled={loading}>
|
| 206 |
+
{loading ? <RefreshCw className="spin" size={15}/> : <Play size={15}/>}<span>REFRESH</span>
|
| 207 |
+
</button>
|
| 208 |
+
|
| 209 |
+
<button className="sim-icon-btn" title="Fit to view" onClick={() => canvasRef.current?.fitView()}>
|
| 210 |
+
<Layers size={15}/><span>FIT</span>
|
| 211 |
+
</button>
|
| 212 |
+
|
| 213 |
+
{/* Edge labels quick toggle */}
|
| 214 |
+
<button
|
| 215 |
+
className={`sim-icon-btn${options.showEdgeLabels ? ' active' : ''}`}
|
| 216 |
+
title={options.showEdgeLabels ? 'Hide edge labels' : 'Show edge labels'}
|
| 217 |
+
onClick={() => setOpt('showEdgeLabels', !options.showEdgeLabels)}
|
| 218 |
+
>
|
| 219 |
+
<Tag size={15}/><span>LABELS</span>
|
| 220 |
+
</button>
|
| 221 |
+
|
| 222 |
+
{/* Search */}
|
| 223 |
+
<button
|
| 224 |
+
className={`sim-icon-btn${showSearch ? ' active' : ''}`}
|
| 225 |
+
title="Search nodes"
|
| 226 |
+
onClick={() => { setShowSearch(s => !s); if (showSearch) { setSearchQuery(''); setHighlightNodeIds(new Set()); } }}
|
| 227 |
+
>
|
| 228 |
+
<Search size={15}/><span>SEARCH</span>
|
| 229 |
+
</button>
|
| 230 |
+
|
| 231 |
+
{/* Export PNG */}
|
| 232 |
+
<button className="sim-icon-btn" title="Export as PNG" onClick={() => canvasRef.current?.exportPNG()}>
|
| 233 |
+
<Image size={15}/><span>PNG</span>
|
| 234 |
+
</button>
|
| 235 |
+
|
| 236 |
+
{/* Export SVG */}
|
| 237 |
+
<button className="sim-icon-btn" title="Export as SVG" onClick={() => canvasRef.current?.exportSVG()}>
|
| 238 |
+
<Download size={15}/><span>SVG</span>
|
| 239 |
+
</button>
|
| 240 |
+
|
| 241 |
+
{/* Options panel */}
|
| 242 |
+
<button className={`sim-icon-btn${showPanel ? ' active' : ''}`} title="Advanced options" onClick={() => setShowPanel(p => !p)}>
|
| 243 |
+
<SlidersHorizontal size={15}/><span>OPTIONS</span>
|
| 244 |
+
</button>
|
| 245 |
+
|
| 246 |
+
{/* Fullscreen */}
|
| 247 |
+
<button className="sim-icon-btn" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'} onClick={toggleFullscreen}>
|
| 248 |
+
{isFullscreen ? <Minimize2 size={15}/> : <Maximize2 size={15}/>}
|
| 249 |
+
<span>{isFullscreen ? 'EXIT' : 'FULL'}</span>
|
| 250 |
+
</button>
|
| 251 |
+
|
| 252 |
+
{/* Help */}
|
| 253 |
+
<button className={`sim-icon-btn${showHelp ? ' active' : ''}`} title="Keyboard shortcuts & help" onClick={() => setShowHelp(h => !h)}>
|
| 254 |
+
<HelpCircle size={15}/><span>?</span>
|
| 255 |
+
</button>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
|
| 260 |
+
{/* ── Node Search bar ──────────────────────────────────────────────── */}
|
| 261 |
+
{showSearch && (
|
| 262 |
+
<div className="search-bar-row">
|
| 263 |
+
<Search size={14}/>
|
| 264 |
+
<input
|
| 265 |
+
autoFocus
|
| 266 |
+
type="text"
|
| 267 |
+
className="sim-search-input"
|
| 268 |
+
placeholder="Search by node name or type…"
|
| 269 |
+
value={searchQuery}
|
| 270 |
+
onChange={e => handleSearch(e.target.value)}
|
| 271 |
+
/>
|
| 272 |
+
{highlightNodeIds.size > 0 && (
|
| 273 |
+
<span className="search-result-badge">{highlightNodeIds.size} match{highlightNodeIds.size !== 1 ? 'es' : ''}</span>
|
| 274 |
+
)}
|
| 275 |
+
<button className="sim-icon-btn" style={{ padding: '0.2rem 0.5rem' }} onClick={() => { setSearchQuery(''); setHighlightNodeIds(new Set()); }}>
|
| 276 |
+
<X size={13}/>
|
| 277 |
+
</button>
|
| 278 |
+
</div>
|
| 279 |
+
)}
|
| 280 |
+
|
| 281 |
+
{/* ── Help modal ───────────────────────────────────────────────────── */}
|
| 282 |
+
{showHelp && (
|
| 283 |
+
<div className="advanced-panel">
|
| 284 |
+
<div className="panel-header">
|
| 285 |
+
<span>CONTROLS REFERENCE</span>
|
| 286 |
+
<button className="toast-dismiss-btn" onClick={() => setShowHelp(false)}><X size={15}/></button>
|
| 287 |
+
</div>
|
| 288 |
+
<div className="panel-body" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
|
| 289 |
+
<div>
|
| 290 |
+
<div className="opt-label">MOUSE / TOUCH</div>
|
| 291 |
+
<table className="help-table">
|
| 292 |
+
<tbody>
|
| 293 |
+
{[
|
| 294 |
+
['Single click', 'Zoom to node'],
|
| 295 |
+
['Double click', 'Edit node name'],
|
| 296 |
+
['Drag node', 'Pin node position'],
|
| 297 |
+
['Hover', 'Highlight neighbors'],
|
| 298 |
+
['Scroll', 'Zoom in / out'],
|
| 299 |
+
['Drag canvas', 'Pan view'],
|
| 300 |
+
['Click canvas bg', 'Reset highlight'],
|
| 301 |
+
].map(([k, v]) => (
|
| 302 |
+
<tr key={k}><td className="help-key">{k}</td><td className="help-val">{v}</td></tr>
|
| 303 |
+
))}
|
| 304 |
+
</tbody>
|
| 305 |
+
</table>
|
| 306 |
+
</div>
|
| 307 |
+
<div>
|
| 308 |
+
<div className="opt-label">TOOLBAR BUTTONS</div>
|
| 309 |
+
<table className="help-table">
|
| 310 |
+
<tbody>
|
| 311 |
+
{[
|
| 312 |
+
['REFRESH', 'Reload graph from Neo4j'],
|
| 313 |
+
['FIT', 'Reset zoom/pan to center'],
|
| 314 |
+
['LABELS', 'Toggle edge relation text'],
|
| 315 |
+
['SEARCH', 'Find & highlight nodes'],
|
| 316 |
+
['PNG/SVG', 'Export graph image'],
|
| 317 |
+
['OPTIONS', 'Physics & display settings'],
|
| 318 |
+
['FULL', 'Toggle fullscreen mode'],
|
| 319 |
+
].map(([k, v]) => (
|
| 320 |
+
<tr key={k}><td className="help-key">{k}</td><td className="help-val">{v}</td></tr>
|
| 321 |
+
))}
|
| 322 |
+
</tbody>
|
| 323 |
+
</table>
|
| 324 |
+
</div>
|
| 325 |
+
<div>
|
| 326 |
+
<div className="opt-label">TIPS</div>
|
| 327 |
+
<ul className="help-tips">
|
| 328 |
+
<li>Set <strong>NODES</strong> to 50 for better performance on large graphs</li>
|
| 329 |
+
<li>Enable <strong>Curved edges</strong> to reduce visual overlap</li>
|
| 330 |
+
<li><strong>Node size by degree</strong> makes hubs visually prominent</li>
|
| 331 |
+
<li>Use <strong>SEARCH</strong> to find entities by name or type</li>
|
| 332 |
+
<li>Increase <strong>Charge Strength</strong> to spread out dense clusters</li>
|
| 333 |
+
</ul>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
)}
|
| 338 |
+
|
| 339 |
+
{/* ── Advanced options panel ────────────────────────────────────────── */}
|
| 340 |
+
{showPanel && (
|
| 341 |
+
<div className="advanced-panel">
|
| 342 |
+
<div className="panel-header">
|
| 343 |
+
<span>ADVANCED GRAPH OPTIONS</span>
|
| 344 |
+
<button className="toast-dismiss-btn" onClick={() => setShowPanel(false)}><X size={15}/></button>
|
| 345 |
+
</div>
|
| 346 |
+
<div className="panel-body">
|
| 347 |
+
{/* Display toggles */}
|
| 348 |
+
<div className="opt-section">
|
| 349 |
+
<div className="opt-label">DISPLAY</div>
|
| 350 |
+
<div className="opt-row">
|
| 351 |
+
{([
|
| 352 |
+
['colorByType', 'Color nodes by entity type'],
|
| 353 |
+
['showLabels', 'Show node name labels'],
|
| 354 |
+
['showEdgeLabels', 'Show edge relation labels'],
|
| 355 |
+
['showCurvedEdges', 'Curved edges (arc routing)'],
|
| 356 |
+
['nodeSizeByDegree', 'Node size by connection count'],
|
| 357 |
+
] as [keyof GraphOptions, string][]).map(([key, label]) => (
|
| 358 |
+
<label key={key} className="opt-toggle">
|
| 359 |
+
<input type="checkbox"
|
| 360 |
+
checked={options[key] as boolean}
|
| 361 |
+
onChange={e => setOpt(key, e.target.checked)}
|
| 362 |
+
/>
|
| 363 |
+
<span>{label}</span>
|
| 364 |
+
</label>
|
| 365 |
+
))}
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
|
| 369 |
+
{/* Physics sliders */}
|
| 370 |
+
<div className="opt-section">
|
| 371 |
+
<div className="opt-label">PHYSICS</div>
|
| 372 |
+
<div className="opt-sliders">
|
| 373 |
+
{([
|
| 374 |
+
['nodeRadius', 'Node Radius', 8, 40, 1],
|
| 375 |
+
['linkDistance', 'Link Distance', 40, 500, 10],
|
| 376 |
+
['chargeStrength', 'Repulsion', -1200, -30, 10],
|
| 377 |
+
['centerGravity', 'Center Gravity', 0, 0.5, 0.01],
|
| 378 |
+
] as [keyof GraphOptions, string, number, number, number][]).map(([key, label, min, max, step]) => (
|
| 379 |
+
<div key={key} className="slider-row">
|
| 380 |
+
<label>{label}: <strong>{typeof options[key] === 'number' ? (options[key] as number).toFixed(key === 'centerGravity' ? 2 : 0) : options[key]}</strong></label>
|
| 381 |
+
<input type="range" min={min} max={max} step={step}
|
| 382 |
+
value={options[key] as number}
|
| 383 |
+
onChange={e => setOpt(key, Number(e.target.value))}
|
| 384 |
+
/>
|
| 385 |
+
</div>
|
| 386 |
+
))}
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
|
| 390 |
+
<button className="reset-btn" onClick={() => setOptions({ ...DEFAULT_OPTIONS })}>
|
| 391 |
+
↺ RESET TO DEFAULTS
|
| 392 |
+
</button>
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
)}
|
| 396 |
+
|
| 397 |
+
{/* ── Canvas + Legend + Stats ───────────────────────────────────────── */}
|
| 398 |
+
<div className="canvas-area">
|
| 399 |
+
<div className="canvas-wrapper">
|
| 400 |
+
{loading && graphData.nodes.length === 0 ? (
|
| 401 |
+
<div className="loading-overlay">
|
| 402 |
+
<RefreshCw className="spin" size={28}/>
|
| 403 |
+
<span>INITIALIZING PHYSICS ENGINE...</span>
|
| 404 |
+
</div>
|
| 405 |
+
) : graphData.nodes.length === 0 ? (
|
| 406 |
+
<div className="loading-overlay empty">
|
| 407 |
+
<Database size={40}/>
|
| 408 |
+
<span>
|
| 409 |
+
{selectedDocId
|
| 410 |
+
? 'No entities found for this document.\nTry a different document or re-ingest.'
|
| 411 |
+
: 'No entity data in graph.\nIngest documents via the PROCESS tab first.'}
|
| 412 |
+
</span>
|
| 413 |
+
</div>
|
| 414 |
+
) : (
|
| 415 |
+
<GraphCanvas
|
| 416 |
+
ref={canvasRef}
|
| 417 |
+
data={graphData}
|
| 418 |
+
onNodeUpdate={handleNodeUpdate}
|
| 419 |
+
options={options}
|
| 420 |
+
highlightNodeIds={highlightNodeIds}
|
| 421 |
+
/>
|
| 422 |
+
)}
|
| 423 |
+
|
| 424 |
+
{loading && graphData.nodes.length > 0 && (
|
| 425 |
+
<div className="refresh-pill">
|
| 426 |
+
<RefreshCw className="spin" size={13}/> REFRESHING
|
| 427 |
+
</div>
|
| 428 |
+
)}
|
| 429 |
+
</div>
|
| 430 |
+
|
| 431 |
+
{/* ── Sidebar: Legend + Stats ─────────────────────────────────────── */}
|
| 432 |
+
<div className="sidebar-panels">
|
| 433 |
+
{/* Type Legend */}
|
| 434 |
+
{options.colorByType && typeLegend.length > 0 && (
|
| 435 |
+
<div className="legend-panel">
|
| 436 |
+
<div className="legend-title">ENTITY TYPES</div>
|
| 437 |
+
{typeLegend.map(({ type, color }) => (
|
| 438 |
+
<div key={type} className="legend-row">
|
| 439 |
+
<span className="legend-dot" style={{ background: color }}/>
|
| 440 |
+
<span className="legend-type">{type}</span>
|
| 441 |
+
</div>
|
| 442 |
+
))}
|
| 443 |
+
</div>
|
| 444 |
+
)}
|
| 445 |
+
|
| 446 |
+
{/* Graph stats */}
|
| 447 |
+
{degreeStats && (
|
| 448 |
+
<div className="stats-sidebar">
|
| 449 |
+
<div className="legend-title">NETWORK STATS</div>
|
| 450 |
+
<div className="stat-row"><span>Avg Degree</span><strong>{degreeStats.avg}</strong></div>
|
| 451 |
+
<div className="stat-row"><span>Max Degree</span><strong>{degreeStats.max}</strong></div>
|
| 452 |
+
{degreeStats.hubs.length > 0 && (
|
| 453 |
+
<>
|
| 454 |
+
<div className="legend-title" style={{ marginTop: '0.6rem' }}>HUB NODES</div>
|
| 455 |
+
{degreeStats.hubs.map((n: any) => (
|
| 456 |
+
<div
|
| 457 |
+
key={n.id}
|
| 458 |
+
className="hub-node-row"
|
| 459 |
+
title="Click to zoom to this node"
|
| 460 |
+
onClick={() => canvasRef.current?.highlightNode(n.id)}
|
| 461 |
+
>
|
| 462 |
+
<GitBranch size={10}/>
|
| 463 |
+
<span>{n.label?.length > 14 ? n.label.substring(0,12)+'…' : n.label}</span>
|
| 464 |
+
</div>
|
| 465 |
+
))}
|
| 466 |
+
</>
|
| 467 |
+
)}
|
| 468 |
+
</div>
|
| 469 |
+
)}
|
| 470 |
+
</div>
|
| 471 |
+
</div>
|
| 472 |
+
|
| 473 |
+
{/* ── Hint bar ──────────────────────────────────────────────────────── */}
|
| 474 |
+
<div className="sim-hint">
|
| 475 |
+
<Info size={11}/>
|
| 476 |
+
<span>
|
| 477 |
+
<strong>Click</strong> node to zoom · <strong>Double-click</strong> to rename · <strong>Hover</strong> to highlight neighbors ·
|
| 478 |
+
<strong> Drag</strong> to pin · <strong>Scroll</strong> to zoom
|
| 479 |
+
</span>
|
| 480 |
+
</div>
|
| 481 |
+
|
| 482 |
+
<style>{`
|
| 483 |
+
.simulation-root {
|
| 484 |
+
height: calc(100vh - 62px);
|
| 485 |
+
display: flex;
|
| 486 |
+
flex-direction: column;
|
| 487 |
+
padding: 0.75rem 1.25rem 0.5rem;
|
| 488 |
+
background: var(--bg-color);
|
| 489 |
+
overflow: hidden;
|
| 490 |
+
gap: 0;
|
| 491 |
+
}
|
| 492 |
+
.simulation-root.fullscreen {
|
| 493 |
+
height: 100vh;
|
| 494 |
+
position: fixed;
|
| 495 |
+
inset: 0;
|
| 496 |
+
z-index: 9999;
|
| 497 |
+
background: #fff;
|
| 498 |
+
padding: 0.75rem 1.25rem 0.5rem;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
/* Top bar */
|
| 502 |
+
.sim-topbar {
|
| 503 |
+
display: flex;
|
| 504 |
+
align-items: flex-start;
|
| 505 |
+
justify-content: space-between;
|
| 506 |
+
flex-wrap: wrap;
|
| 507 |
+
gap: 0.5rem;
|
| 508 |
+
border-bottom: 2px solid #000;
|
| 509 |
+
padding-bottom: 0.6rem;
|
| 510 |
+
margin-bottom: 0.5rem;
|
| 511 |
+
}
|
| 512 |
+
.sim-title-group h2 { margin: 0 0 0.2rem; font-size: 1rem; letter-spacing: 2px; }
|
| 513 |
+
.sim-chips { display: flex; flex-wrap: wrap; gap: 5px; }
|
| 514 |
+
.s-chip {
|
| 515 |
+
display: inline-flex; align-items: center; gap: 3px;
|
| 516 |
+
border: 1.5px solid #000; padding: 1px 7px;
|
| 517 |
+
font-family: var(--font-mono); font-size: 0.7rem; font-weight: 700;
|
| 518 |
+
}
|
| 519 |
+
.s-chip.active { background: #000; color: #fff; }
|
| 520 |
+
|
| 521 |
+
.sim-controls { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 0.6rem; }
|
| 522 |
+
.ctrl-grp { display: flex; flex-direction: column; gap: 2px; }
|
| 523 |
+
.ctrl-lbl {
|
| 524 |
+
font-family: var(--font-mono); font-size: 0.62rem;
|
| 525 |
+
font-weight: 700; color: #888; letter-spacing: 1px;
|
| 526 |
+
}
|
| 527 |
+
.sim-sel {
|
| 528 |
+
font-family: var(--font-mono); font-size: 0.8rem;
|
| 529 |
+
border: 2px solid #000; background: #fff; color: #000;
|
| 530 |
+
padding: 0.28rem 0.55rem; cursor: pointer; min-width: 120px;
|
| 531 |
+
max-width: 180px;
|
| 532 |
+
}
|
| 533 |
+
.sim-sel:focus { outline: none; box-shadow: 2px 2px 0 #000; }
|
| 534 |
+
|
| 535 |
+
.sim-actions { display: flex; flex-wrap: wrap; gap: 5px; align-items: flex-end; }
|
| 536 |
+
.sim-icon-btn {
|
| 537 |
+
display: inline-flex; align-items: center; gap: 4px;
|
| 538 |
+
border: 2px solid #000; background: #fff; color: #000;
|
| 539 |
+
padding: 0.28rem 0.65rem; font-family: var(--font-mono);
|
| 540 |
+
font-size: 0.7rem; font-weight: 700; cursor: pointer;
|
| 541 |
+
letter-spacing: 0.5px; transition: all 0.13s ease;
|
| 542 |
+
white-space: nowrap;
|
| 543 |
+
}
|
| 544 |
+
.sim-icon-btn:hover, .sim-icon-btn.active { background: #000; color: #fff; }
|
| 545 |
+
.sim-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
|
| 546 |
+
|
| 547 |
+
/* Search bar */
|
| 548 |
+
.search-bar-row {
|
| 549 |
+
display: flex; align-items: center; gap: 0.5rem;
|
| 550 |
+
border: 2px solid #000; padding: 0.3rem 0.75rem;
|
| 551 |
+
margin-bottom: 0.5rem; background: #fafafa;
|
| 552 |
+
}
|
| 553 |
+
.sim-search-input {
|
| 554 |
+
flex: 1; border: none; background: transparent;
|
| 555 |
+
font-family: var(--font-mono); font-size: 0.85rem;
|
| 556 |
+
color: #000; outline: none;
|
| 557 |
+
}
|
| 558 |
+
.search-result-badge {
|
| 559 |
+
font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700;
|
| 560 |
+
background: #000; color: #fff; padding: 1px 8px;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
/* Advanced panel */
|
| 564 |
+
.advanced-panel {
|
| 565 |
+
border: 2px solid #000; background: #fff;
|
| 566 |
+
margin-bottom: 0.5rem; box-shadow: 3px 3px 0 #000;
|
| 567 |
+
}
|
| 568 |
+
.panel-header {
|
| 569 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 570 |
+
padding: 0.4rem 0.9rem;
|
| 571 |
+
background: #000; color: #fff;
|
| 572 |
+
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 700;
|
| 573 |
+
letter-spacing: 1px;
|
| 574 |
+
}
|
| 575 |
+
.panel-body {
|
| 576 |
+
display: flex; flex-wrap: wrap; gap: 1.25rem; padding: 0.9rem 1rem;
|
| 577 |
+
}
|
| 578 |
+
.opt-section { flex: 1; min-width: 160px; }
|
| 579 |
+
.opt-label {
|
| 580 |
+
font-family: var(--font-mono); font-size: 0.62rem; font-weight: 700;
|
| 581 |
+
color: #888; letter-spacing: 1px; margin-bottom: 0.45rem;
|
| 582 |
+
}
|
| 583 |
+
.opt-row { display: flex; flex-direction: column; gap: 5px; }
|
| 584 |
+
.opt-toggle {
|
| 585 |
+
display: flex; align-items: center; gap: 7px;
|
| 586 |
+
font-family: var(--font-mono); font-size: 0.77rem; cursor: pointer;
|
| 587 |
+
}
|
| 588 |
+
.opt-toggle input { cursor: pointer; }
|
| 589 |
+
.opt-sliders { display: flex; flex-direction: column; gap: 0.55rem; }
|
| 590 |
+
.slider-row {
|
| 591 |
+
display: flex; flex-direction: column; gap: 3px;
|
| 592 |
+
font-family: var(--font-mono); font-size: 0.75rem;
|
| 593 |
+
}
|
| 594 |
+
.slider-row input[type=range] { width: 100%; cursor: pointer; }
|
| 595 |
+
.reset-btn {
|
| 596 |
+
align-self: flex-end;
|
| 597 |
+
border: 2px solid #000; background: #fff; color: #000;
|
| 598 |
+
font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700;
|
| 599 |
+
padding: 0.3rem 0.9rem; cursor: pointer; letter-spacing: 0.5px;
|
| 600 |
+
transition: all 0.13s ease;
|
| 601 |
+
}
|
| 602 |
+
.reset-btn:hover { background: #000; color: #fff; }
|
| 603 |
+
|
| 604 |
+
/* Help table */
|
| 605 |
+
.help-table { width: 100%; border-collapse: collapse; }
|
| 606 |
+
.help-table td { vertical-align: top; padding: 2px 0; font-family: var(--font-mono); font-size: 0.74rem; }
|
| 607 |
+
.help-key { color: #000; font-weight: 700; white-space: nowrap; padding-right: 0.75rem; }
|
| 608 |
+
.help-val { color: #555; }
|
| 609 |
+
.help-tips { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
|
| 610 |
+
.help-tips li { font-family: var(--font-mono); font-size: 0.74rem; color: #555; line-height: 1.4; }
|
| 611 |
+
.help-tips li::before { content: '→ '; color: #000; font-weight: 700; }
|
| 612 |
+
.help-tips strong { color: #000; }
|
| 613 |
+
|
| 614 |
+
/* Canvas area */
|
| 615 |
+
.canvas-area {
|
| 616 |
+
flex: 1; display: flex; gap: 0.6rem; min-height: 0; overflow: hidden;
|
| 617 |
+
}
|
| 618 |
+
.canvas-wrapper {
|
| 619 |
+
flex: 1; border: 2px solid #000;
|
| 620 |
+
background: #fafafa; position: relative; overflow: hidden; min-height: 0;
|
| 621 |
+
}
|
| 622 |
+
.loading-overlay {
|
| 623 |
+
position: absolute; inset: 0;
|
| 624 |
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
| 625 |
+
gap: 1rem; background: rgba(255,255,255,0.92);
|
| 626 |
+
font-family: var(--font-mono); font-size: 0.88rem;
|
| 627 |
+
letter-spacing: 1.2px; color: #555;
|
| 628 |
+
white-space: pre-line; text-align: center; padding: 2rem; z-index: 10;
|
| 629 |
+
}
|
| 630 |
+
.loading-overlay.empty { color: #bbb; }
|
| 631 |
+
.refresh-pill {
|
| 632 |
+
position: absolute; top: 8px; right: 8px;
|
| 633 |
+
display: flex; align-items: center; gap: 5px;
|
| 634 |
+
background: rgba(0,0,0,0.85); color: #fff;
|
| 635 |
+
font-family: var(--font-mono); font-size: 0.7rem;
|
| 636 |
+
padding: 2px 9px; z-index: 20;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
/* Sidebar panels */
|
| 640 |
+
.sidebar-panels {
|
| 641 |
+
display: flex; flex-direction: column; gap: 0.5rem;
|
| 642 |
+
width: 150px; flex-shrink: 0; overflow-y: auto;
|
| 643 |
+
}
|
| 644 |
+
.legend-panel, .stats-sidebar {
|
| 645 |
+
border: 2px solid #000; background: #fff; padding: 0.65rem 0.75rem; flex-shrink: 0;
|
| 646 |
+
}
|
| 647 |
+
.legend-title {
|
| 648 |
+
font-family: var(--font-mono); font-size: 0.6rem; font-weight: 700;
|
| 649 |
+
color: #888; letter-spacing: 1px; margin-bottom: 0.5rem; text-transform: uppercase;
|
| 650 |
+
}
|
| 651 |
+
.legend-row { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
|
| 652 |
+
.legend-dot {
|
| 653 |
+
width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0;
|
| 654 |
+
border: 1px solid rgba(0,0,0,0.15);
|
| 655 |
+
}
|
| 656 |
+
.legend-type {
|
| 657 |
+
font-family: var(--font-mono); font-size: 0.72rem; font-weight: 600;
|
| 658 |
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
| 659 |
+
}
|
| 660 |
+
.stat-row {
|
| 661 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 662 |
+
font-family: var(--font-mono); font-size: 0.72rem;
|
| 663 |
+
color: #555; margin-bottom: 3px;
|
| 664 |
+
}
|
| 665 |
+
.stat-row strong { color: #000; font-weight: 700; }
|
| 666 |
+
.hub-node-row {
|
| 667 |
+
display: flex; align-items: center; gap: 5px;
|
| 668 |
+
font-family: var(--font-mono); font-size: 0.69rem;
|
| 669 |
+
color: #333; margin-bottom: 3px; cursor: pointer;
|
| 670 |
+
padding: 2px 4px; transition: background 0.12s;
|
| 671 |
+
}
|
| 672 |
+
.hub-node-row:hover { background: #f0f0f0; }
|
| 673 |
+
|
| 674 |
+
/* Hint bar */
|
| 675 |
+
.sim-hint {
|
| 676 |
+
display: flex; align-items: center; gap: 5px;
|
| 677 |
+
font-size: 0.69rem; color: #999; font-family: var(--font-mono);
|
| 678 |
+
padding: 0.25rem 0; border-top: 1px dashed #ccc; margin-top: 0.25rem;
|
| 679 |
+
flex-shrink: 0;
|
| 680 |
+
}
|
| 681 |
+
.sim-hint strong { color: #555; }
|
| 682 |
+
|
| 683 |
+
.spin { animation: spin 1.2s linear infinite; }
|
| 684 |
+
@keyframes spin { 100% { transform: rotate(360deg); } }
|
| 685 |
+
`}</style>
|
| 686 |
+
</div>
|
| 687 |
+
);
|
| 688 |
+
};
|
| 689 |
+
|
| 690 |
+
export default SimulationRunView;
|
frontend-react/tsconfig.app.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
| 4 |
+
"target": "ES2023",
|
| 5 |
+
"useDefineForClassFields": true,
|
| 6 |
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
| 7 |
+
"module": "ESNext",
|
| 8 |
+
"types": ["vite/client"],
|
| 9 |
+
"skipLibCheck": true,
|
| 10 |
+
|
| 11 |
+
/* Bundler mode */
|
| 12 |
+
"moduleResolution": "bundler",
|
| 13 |
+
"allowImportingTsExtensions": true,
|
| 14 |
+
"verbatimModuleSyntax": true,
|
| 15 |
+
"moduleDetection": "force",
|
| 16 |
+
"noEmit": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
|
| 19 |
+
/* Linting */
|
| 20 |
+
"strict": true,
|
| 21 |
+
"noUnusedLocals": true,
|
| 22 |
+
"noUnusedParameters": true,
|
| 23 |
+
"erasableSyntaxOnly": true,
|
| 24 |
+
"noFallthroughCasesInSwitch": true,
|
| 25 |
+
"noUncheckedSideEffectImports": true
|
| 26 |
+
},
|
| 27 |
+
"include": ["src"]
|
| 28 |
+
}
|
frontend-react/tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
frontend-react/tsconfig.node.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
| 4 |
+
"target": "ES2023",
|
| 5 |
+
"lib": ["ES2023"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"types": ["node"],
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
|
| 10 |
+
/* Bundler mode */
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"allowImportingTsExtensions": true,
|
| 13 |
+
"verbatimModuleSyntax": true,
|
| 14 |
+
"moduleDetection": "force",
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
|
| 17 |
+
/* Linting */
|
| 18 |
+
"strict": true,
|
| 19 |
+
"noUnusedLocals": true,
|
| 20 |
+
"noUnusedParameters": true,
|
| 21 |
+
"erasableSyntaxOnly": true,
|
| 22 |
+
"noFallthroughCasesInSwitch": true,
|
| 23 |
+
"noUncheckedSideEffectImports": true
|
| 24 |
+
},
|
| 25 |
+
"include": ["vite.config.ts"]
|
| 26 |
+
}
|
frontend-react/vite.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
import tailwindcss from '@tailwindcss/vite'
|
| 4 |
+
|
| 5 |
+
// https://vite.dev/config/
|
| 6 |
+
export default defineConfig({
|
| 7 |
+
plugins: [
|
| 8 |
+
react(),
|
| 9 |
+
tailwindcss(),
|
| 10 |
+
],
|
| 11 |
+
server: {
|
| 12 |
+
host: true, // Listen on all network interfaces to allow LAN access
|
| 13 |
+
}
|
| 14 |
+
})
|
main.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Entry point for the application
|
| 3 |
+
You can run the API server using: uv run python main.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from src.graph_rag_service.main import main
|
| 7 |
+
|
| 8 |
+
if __name__ == "__main__":
|
| 9 |
+
main()
|
package-lock.json
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "graph-rag",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "graph-rag",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"hasInstallScript": true,
|
| 11 |
+
"devDependencies": {
|
| 12 |
+
"concurrently": "^8.2.2"
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
"node_modules/@babel/runtime": {
|
| 16 |
+
"version": "7.29.2",
|
| 17 |
+
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
| 18 |
+
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
| 19 |
+
"dev": true,
|
| 20 |
+
"license": "MIT",
|
| 21 |
+
"engines": {
|
| 22 |
+
"node": ">=6.9.0"
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"node_modules/ansi-regex": {
|
| 26 |
+
"version": "5.0.1",
|
| 27 |
+
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
| 28 |
+
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
| 29 |
+
"dev": true,
|
| 30 |
+
"license": "MIT",
|
| 31 |
+
"engines": {
|
| 32 |
+
"node": ">=8"
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
"node_modules/ansi-styles": {
|
| 36 |
+
"version": "4.3.0",
|
| 37 |
+
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
| 38 |
+
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
| 39 |
+
"dev": true,
|
| 40 |
+
"license": "MIT",
|
| 41 |
+
"dependencies": {
|
| 42 |
+
"color-convert": "^2.0.1"
|
| 43 |
+
},
|
| 44 |
+
"engines": {
|
| 45 |
+
"node": ">=8"
|
| 46 |
+
},
|
| 47 |
+
"funding": {
|
| 48 |
+
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
| 49 |
+
}
|
| 50 |
+
},
|
| 51 |
+
"node_modules/chalk": {
|
| 52 |
+
"version": "4.1.2",
|
| 53 |
+
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
| 54 |
+
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
| 55 |
+
"dev": true,
|
| 56 |
+
"license": "MIT",
|
| 57 |
+
"dependencies": {
|
| 58 |
+
"ansi-styles": "^4.1.0",
|
| 59 |
+
"supports-color": "^7.1.0"
|
| 60 |
+
},
|
| 61 |
+
"engines": {
|
| 62 |
+
"node": ">=10"
|
| 63 |
+
},
|
| 64 |
+
"funding": {
|
| 65 |
+
"url": "https://github.com/chalk/chalk?sponsor=1"
|
| 66 |
+
}
|
| 67 |
+
},
|
| 68 |
+
"node_modules/chalk/node_modules/supports-color": {
|
| 69 |
+
"version": "7.2.0",
|
| 70 |
+
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
| 71 |
+
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
| 72 |
+
"dev": true,
|
| 73 |
+
"license": "MIT",
|
| 74 |
+
"dependencies": {
|
| 75 |
+
"has-flag": "^4.0.0"
|
| 76 |
+
},
|
| 77 |
+
"engines": {
|
| 78 |
+
"node": ">=8"
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
"node_modules/cliui": {
|
| 82 |
+
"version": "8.0.1",
|
| 83 |
+
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
| 84 |
+
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
| 85 |
+
"dev": true,
|
| 86 |
+
"license": "ISC",
|
| 87 |
+
"dependencies": {
|
| 88 |
+
"string-width": "^4.2.0",
|
| 89 |
+
"strip-ansi": "^6.0.1",
|
| 90 |
+
"wrap-ansi": "^7.0.0"
|
| 91 |
+
},
|
| 92 |
+
"engines": {
|
| 93 |
+
"node": ">=12"
|
| 94 |
+
}
|
| 95 |
+
},
|
| 96 |
+
"node_modules/color-convert": {
|
| 97 |
+
"version": "2.0.1",
|
| 98 |
+
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
| 99 |
+
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
| 100 |
+
"dev": true,
|
| 101 |
+
"license": "MIT",
|
| 102 |
+
"dependencies": {
|
| 103 |
+
"color-name": "~1.1.4"
|
| 104 |
+
},
|
| 105 |
+
"engines": {
|
| 106 |
+
"node": ">=7.0.0"
|
| 107 |
+
}
|
| 108 |
+
},
|
| 109 |
+
"node_modules/color-name": {
|
| 110 |
+
"version": "1.1.4",
|
| 111 |
+
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
| 112 |
+
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
| 113 |
+
"dev": true,
|
| 114 |
+
"license": "MIT"
|
| 115 |
+
},
|
| 116 |
+
"node_modules/concurrently": {
|
| 117 |
+
"version": "8.2.2",
|
| 118 |
+
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
| 119 |
+
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
| 120 |
+
"dev": true,
|
| 121 |
+
"license": "MIT",
|
| 122 |
+
"dependencies": {
|
| 123 |
+
"chalk": "^4.1.2",
|
| 124 |
+
"date-fns": "^2.30.0",
|
| 125 |
+
"lodash": "^4.17.21",
|
| 126 |
+
"rxjs": "^7.8.1",
|
| 127 |
+
"shell-quote": "^1.8.1",
|
| 128 |
+
"spawn-command": "0.0.2",
|
| 129 |
+
"supports-color": "^8.1.1",
|
| 130 |
+
"tree-kill": "^1.2.2",
|
| 131 |
+
"yargs": "^17.7.2"
|
| 132 |
+
},
|
| 133 |
+
"bin": {
|
| 134 |
+
"conc": "dist/bin/concurrently.js",
|
| 135 |
+
"concurrently": "dist/bin/concurrently.js"
|
| 136 |
+
},
|
| 137 |
+
"engines": {
|
| 138 |
+
"node": "^14.13.0 || >=16.0.0"
|
| 139 |
+
},
|
| 140 |
+
"funding": {
|
| 141 |
+
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
| 142 |
+
}
|
| 143 |
+
},
|
| 144 |
+
"node_modules/date-fns": {
|
| 145 |
+
"version": "2.30.0",
|
| 146 |
+
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
| 147 |
+
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
| 148 |
+
"dev": true,
|
| 149 |
+
"license": "MIT",
|
| 150 |
+
"dependencies": {
|
| 151 |
+
"@babel/runtime": "^7.21.0"
|
| 152 |
+
},
|
| 153 |
+
"engines": {
|
| 154 |
+
"node": ">=0.11"
|
| 155 |
+
},
|
| 156 |
+
"funding": {
|
| 157 |
+
"type": "opencollective",
|
| 158 |
+
"url": "https://opencollective.com/date-fns"
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
"node_modules/emoji-regex": {
|
| 162 |
+
"version": "8.0.0",
|
| 163 |
+
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
| 164 |
+
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
| 165 |
+
"dev": true,
|
| 166 |
+
"license": "MIT"
|
| 167 |
+
},
|
| 168 |
+
"node_modules/escalade": {
|
| 169 |
+
"version": "3.2.0",
|
| 170 |
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
| 171 |
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
| 172 |
+
"dev": true,
|
| 173 |
+
"license": "MIT",
|
| 174 |
+
"engines": {
|
| 175 |
+
"node": ">=6"
|
| 176 |
+
}
|
| 177 |
+
},
|
| 178 |
+
"node_modules/get-caller-file": {
|
| 179 |
+
"version": "2.0.5",
|
| 180 |
+
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
| 181 |
+
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
| 182 |
+
"dev": true,
|
| 183 |
+
"license": "ISC",
|
| 184 |
+
"engines": {
|
| 185 |
+
"node": "6.* || 8.* || >= 10.*"
|
| 186 |
+
}
|
| 187 |
+
},
|
| 188 |
+
"node_modules/has-flag": {
|
| 189 |
+
"version": "4.0.0",
|
| 190 |
+
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
| 191 |
+
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
| 192 |
+
"dev": true,
|
| 193 |
+
"license": "MIT",
|
| 194 |
+
"engines": {
|
| 195 |
+
"node": ">=8"
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
"node_modules/is-fullwidth-code-point": {
|
| 199 |
+
"version": "3.0.0",
|
| 200 |
+
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
| 201 |
+
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
| 202 |
+
"dev": true,
|
| 203 |
+
"license": "MIT",
|
| 204 |
+
"engines": {
|
| 205 |
+
"node": ">=8"
|
| 206 |
+
}
|
| 207 |
+
},
|
| 208 |
+
"node_modules/lodash": {
|
| 209 |
+
"version": "4.18.1",
|
| 210 |
+
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
| 211 |
+
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
| 212 |
+
"dev": true,
|
| 213 |
+
"license": "MIT"
|
| 214 |
+
},
|
| 215 |
+
"node_modules/require-directory": {
|
| 216 |
+
"version": "2.1.1",
|
| 217 |
+
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
| 218 |
+
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
| 219 |
+
"dev": true,
|
| 220 |
+
"license": "MIT",
|
| 221 |
+
"engines": {
|
| 222 |
+
"node": ">=0.10.0"
|
| 223 |
+
}
|
| 224 |
+
},
|
| 225 |
+
"node_modules/rxjs": {
|
| 226 |
+
"version": "7.8.2",
|
| 227 |
+
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
| 228 |
+
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
| 229 |
+
"dev": true,
|
| 230 |
+
"license": "Apache-2.0",
|
| 231 |
+
"dependencies": {
|
| 232 |
+
"tslib": "^2.1.0"
|
| 233 |
+
}
|
| 234 |
+
},
|
| 235 |
+
"node_modules/shell-quote": {
|
| 236 |
+
"version": "1.8.3",
|
| 237 |
+
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
| 238 |
+
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
| 239 |
+
"dev": true,
|
| 240 |
+
"license": "MIT",
|
| 241 |
+
"engines": {
|
| 242 |
+
"node": ">= 0.4"
|
| 243 |
+
},
|
| 244 |
+
"funding": {
|
| 245 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 246 |
+
}
|
| 247 |
+
},
|
| 248 |
+
"node_modules/spawn-command": {
|
| 249 |
+
"version": "0.0.2",
|
| 250 |
+
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
| 251 |
+
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
| 252 |
+
"dev": true
|
| 253 |
+
},
|
| 254 |
+
"node_modules/string-width": {
|
| 255 |
+
"version": "4.2.3",
|
| 256 |
+
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
| 257 |
+
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
| 258 |
+
"dev": true,
|
| 259 |
+
"license": "MIT",
|
| 260 |
+
"dependencies": {
|
| 261 |
+
"emoji-regex": "^8.0.0",
|
| 262 |
+
"is-fullwidth-code-point": "^3.0.0",
|
| 263 |
+
"strip-ansi": "^6.0.1"
|
| 264 |
+
},
|
| 265 |
+
"engines": {
|
| 266 |
+
"node": ">=8"
|
| 267 |
+
}
|
| 268 |
+
},
|
| 269 |
+
"node_modules/strip-ansi": {
|
| 270 |
+
"version": "6.0.1",
|
| 271 |
+
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
| 272 |
+
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
| 273 |
+
"dev": true,
|
| 274 |
+
"license": "MIT",
|
| 275 |
+
"dependencies": {
|
| 276 |
+
"ansi-regex": "^5.0.1"
|
| 277 |
+
},
|
| 278 |
+
"engines": {
|
| 279 |
+
"node": ">=8"
|
| 280 |
+
}
|
| 281 |
+
},
|
| 282 |
+
"node_modules/supports-color": {
|
| 283 |
+
"version": "8.1.1",
|
| 284 |
+
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
| 285 |
+
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
| 286 |
+
"dev": true,
|
| 287 |
+
"license": "MIT",
|
| 288 |
+
"dependencies": {
|
| 289 |
+
"has-flag": "^4.0.0"
|
| 290 |
+
},
|
| 291 |
+
"engines": {
|
| 292 |
+
"node": ">=10"
|
| 293 |
+
},
|
| 294 |
+
"funding": {
|
| 295 |
+
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
| 296 |
+
}
|
| 297 |
+
},
|
| 298 |
+
"node_modules/tree-kill": {
|
| 299 |
+
"version": "1.2.2",
|
| 300 |
+
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
| 301 |
+
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
| 302 |
+
"dev": true,
|
| 303 |
+
"license": "MIT",
|
| 304 |
+
"bin": {
|
| 305 |
+
"tree-kill": "cli.js"
|
| 306 |
+
}
|
| 307 |
+
},
|
| 308 |
+
"node_modules/tslib": {
|
| 309 |
+
"version": "2.8.1",
|
| 310 |
+
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
| 311 |
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
| 312 |
+
"dev": true,
|
| 313 |
+
"license": "0BSD"
|
| 314 |
+
},
|
| 315 |
+
"node_modules/wrap-ansi": {
|
| 316 |
+
"version": "7.0.0",
|
| 317 |
+
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
| 318 |
+
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
| 319 |
+
"dev": true,
|
| 320 |
+
"license": "MIT",
|
| 321 |
+
"dependencies": {
|
| 322 |
+
"ansi-styles": "^4.0.0",
|
| 323 |
+
"string-width": "^4.1.0",
|
| 324 |
+
"strip-ansi": "^6.0.0"
|
| 325 |
+
},
|
| 326 |
+
"engines": {
|
| 327 |
+
"node": ">=10"
|
| 328 |
+
},
|
| 329 |
+
"funding": {
|
| 330 |
+
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
| 331 |
+
}
|
| 332 |
+
},
|
| 333 |
+
"node_modules/y18n": {
|
| 334 |
+
"version": "5.0.8",
|
| 335 |
+
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
| 336 |
+
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
| 337 |
+
"dev": true,
|
| 338 |
+
"license": "ISC",
|
| 339 |
+
"engines": {
|
| 340 |
+
"node": ">=10"
|
| 341 |
+
}
|
| 342 |
+
},
|
| 343 |
+
"node_modules/yargs": {
|
| 344 |
+
"version": "17.7.2",
|
| 345 |
+
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
| 346 |
+
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
| 347 |
+
"dev": true,
|
| 348 |
+
"license": "MIT",
|
| 349 |
+
"dependencies": {
|
| 350 |
+
"cliui": "^8.0.1",
|
| 351 |
+
"escalade": "^3.1.1",
|
| 352 |
+
"get-caller-file": "^2.0.5",
|
| 353 |
+
"require-directory": "^2.1.1",
|
| 354 |
+
"string-width": "^4.2.3",
|
| 355 |
+
"y18n": "^5.0.5",
|
| 356 |
+
"yargs-parser": "^21.1.1"
|
| 357 |
+
},
|
| 358 |
+
"engines": {
|
| 359 |
+
"node": ">=12"
|
| 360 |
+
}
|
| 361 |
+
},
|
| 362 |
+
"node_modules/yargs-parser": {
|
| 363 |
+
"version": "21.1.1",
|
| 364 |
+
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
| 365 |
+
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
| 366 |
+
"dev": true,
|
| 367 |
+
"license": "ISC",
|
| 368 |
+
"engines": {
|
| 369 |
+
"node": ">=12"
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "graph-rag",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Agentic Graph RAG Application",
|
| 5 |
+
"private": true,
|
| 6 |
+
"scripts": {
|
| 7 |
+
"rag": "concurrently -k -p \"[{name}]\" -n \"SERVER,WORKER,FRONTEND\" -c \"magenta,blue,cyan\" \"npm run run:server\" \"npm run run:worker\" \"npm run run:frontend\"",
|
| 8 |
+
"run:server": "uv run python main.py",
|
| 9 |
+
"run:worker": "uv run celery -A src.graph_rag_service.workers.celery_worker worker --loglevel=info --concurrency=4 --pool=threads",
|
| 10 |
+
"run:frontend": "npm --prefix frontend-react run dev",
|
| 11 |
+
"postinstall": "npm --prefix frontend-react install && uv sync && uv run playwright install chromium"
|
| 12 |
+
},
|
| 13 |
+
"devDependencies": {
|
| 14 |
+
"concurrently": "^8.2.2"
|
| 15 |
+
}
|
| 16 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "graph-rag"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Agentic Graph RAG as a Service - Production-grade knowledge graph platform"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.12"
|
| 7 |
+
authors = [
|
| 8 |
+
{name = "Your Name", email = "your.email@example.com"}
|
| 9 |
+
]
|
| 10 |
+
keywords = ["graph", "rag", "llm", "knowledge-graph", "neo4j", "langgraph", "agents"]
|
| 11 |
+
classifiers = [
|
| 12 |
+
"Development Status :: 4 - Beta",
|
| 13 |
+
"Intended Audience :: Developers",
|
| 14 |
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
| 15 |
+
"Programming Language :: Python :: 3.12",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
dependencies = [
|
| 19 |
+
"fastapi[standard]>=0.129.0",
|
| 20 |
+
"uvicorn[standard]>=0.41.0",
|
| 21 |
+
"llama-index-core>=0.14.14",
|
| 22 |
+
"langgraph>=1.0.8",
|
| 23 |
+
"langchain-core>=1.2.13",
|
| 24 |
+
"neo4j>=5.28.0,<6.0.0",
|
| 25 |
+
"redis>=7.2.0",
|
| 26 |
+
"celery>=5.6.2",
|
| 27 |
+
"pydantic>=2.12.5",
|
| 28 |
+
"pydantic-settings>=2.13.0",
|
| 29 |
+
"python-multipart>=0.0.22",
|
| 30 |
+
"httpx>=0.28.1",
|
| 31 |
+
"llama-index-llms-openai>=0.6.18",
|
| 32 |
+
"llama-index-llms-anthropic>=0.10.8",
|
| 33 |
+
"llama-index-llms-gemini>=0.6.2",
|
| 34 |
+
"llama-index-embeddings-gemini>=0.1.0",
|
| 35 |
+
"llama-index-llms-ollama>=0.9.1",
|
| 36 |
+
"llama-index-embeddings-ollama>=0.8.6",
|
| 37 |
+
"llama-index-graph-stores-neo4j>=0.5.1",
|
| 38 |
+
"llama-parse>=0.6.10",
|
| 39 |
+
"opentelemetry-api>=1.39.1",
|
| 40 |
+
"opentelemetry-sdk>=1.39.1",
|
| 41 |
+
"opentelemetry-instrumentation-fastapi>=0.60b1",
|
| 42 |
+
"python-jose[cryptography]>=3.5.0",
|
| 43 |
+
"passlib[bcrypt]>=1.7.4",
|
| 44 |
+
"aiofiles>=25.1.0",
|
| 45 |
+
"pypdf>=6.7.1",
|
| 46 |
+
"python-magic-bin>=0.4.14",
|
| 47 |
+
"openpyxl>=3.1.2",
|
| 48 |
+
"python-pptx>=0.6.23",
|
| 49 |
+
"beautifulsoup4>=4.12.3",
|
| 50 |
+
"crawl4ai>=0.4.0",
|
| 51 |
+
"markdownify>=1.2.2",
|
| 52 |
+
"flower>=2.0.1",
|
| 53 |
+
"bcrypt>=4.0.0", # used directly in auth.py (not via passlib)
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
[project.optional-dependencies]
|
| 57 |
+
dev = [
|
| 58 |
+
"pytest>=9.0.2",
|
| 59 |
+
"pytest-asyncio>=1.3.0",
|
| 60 |
+
"black>=24.0.0",
|
| 61 |
+
"ruff>=0.1.0",
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
[project.scripts]
|
| 65 |
+
graph-rag = "graph_rag_service.main:main"
|
| 66 |
+
|
| 67 |
+
[build-system]
|
| 68 |
+
requires = ["hatchling"]
|
| 69 |
+
build-backend = "hatchling.build"
|
| 70 |
+
|
| 71 |
+
[tool.hatch.build.targets.wheel]
|
| 72 |
+
packages = ["src/graph_rag_service"]
|
| 73 |
+
|
| 74 |
+
[tool.pytest.ini_options]
|
| 75 |
+
asyncio_mode = "auto"
|
| 76 |
+
testpaths = ["tests"]
|
| 77 |
+
|
| 78 |
+
[tool.black]
|
| 79 |
+
line-length = 100
|
| 80 |
+
target-version = ['py312']
|
| 81 |
+
|
| 82 |
+
[tool.ruff]
|
| 83 |
+
line-length = 100
|
| 84 |
+
target-version = "py312"
|
refactor_di.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
target_dir = r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\api\routers"
|
| 5 |
+
|
| 6 |
+
import_statement = "from ..dependencies import get_graph_store, get_retrieval_agent, get_ingestion_pipeline, get_redis_client\n"
|
| 7 |
+
|
| 8 |
+
for f in os.listdir(target_dir):
|
| 9 |
+
if not f.endswith(".py"): continue
|
| 10 |
+
p = os.path.join(target_dir, f)
|
| 11 |
+
with open(p, "r", encoding="utf-8") as file:
|
| 12 |
+
content = file.read()
|
| 13 |
+
|
| 14 |
+
# Remove all definitions
|
| 15 |
+
content = re.sub(r'def get_graph_store\(.*?\).*?return.*?\n+', '', content, flags=re.DOTALL)
|
| 16 |
+
content = re.sub(r'def get_retrieval_agent\(.*?\).*?return.*?\n+', '', content, flags=re.DOTALL)
|
| 17 |
+
content = re.sub(r'def get_ingestion_pipeline\(.*?\).*?return.*?\n+', '', content, flags=re.DOTALL)
|
| 18 |
+
content = re.sub(r'def get_redis_client\(.*?\).*?return.*?\n+', '', content, flags=re.DOTALL)
|
| 19 |
+
content = re.sub(r'# Dependency injection for global state\n+', '', content)
|
| 20 |
+
|
| 21 |
+
# Add import near the top if not present
|
| 22 |
+
if "from ..dependencies import" not in content and ("Depends(" in content or "Request" in content):
|
| 23 |
+
# find the last import
|
| 24 |
+
last_import = max((content.find("\nimport "), content.find("\nfrom ")))
|
| 25 |
+
if last_import != -1:
|
| 26 |
+
end_of_line = content.find("\n", last_import + 1)
|
| 27 |
+
content = content[:end_of_line+1] + import_statement + content[end_of_line+1:]
|
| 28 |
+
else:
|
| 29 |
+
content = import_statement + content
|
| 30 |
+
|
| 31 |
+
with open(p, "w", encoding="utf-8") as file:
|
| 32 |
+
file.write(content)
|
| 33 |
+
print(f"Refactored {f}")
|
src/graph_rag_service/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Graph RAG as a Service - Agentic Knowledge Graph Platform
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
__version__ = "0.1.0"
|
src/graph_rag_service/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API module initialization"""
|