Spaces:
Running
Running
Komalpreet Kaur commited on
feat: Implement secure Authentication system with JWT and AuthScreen UI
Browse files- app/api/auth_router.py +66 -0
- app/api/endpoints.py +59 -109
- app/auth/auth.py +56 -0
- app/core/config.py +5 -0
- app/db/session.py +33 -1
- app/main.py +3 -1
- frontend/src/App.jsx +62 -29
- frontend/src/api.js +16 -0
- frontend/src/components/AuthScreen.css +184 -0
- frontend/src/components/AuthScreen.jsx +117 -0
- requirements.txt +2 -0
app/api/auth_router.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, status, Depends
|
| 2 |
+
from pydantic import BaseModel, field_validator
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from app.auth.auth import hash_password, verify_password, create_token, get_current_user
|
| 6 |
+
from app.db.session import create_user, get_user
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AuthRequest(BaseModel):
|
| 12 |
+
username: str
|
| 13 |
+
password: str
|
| 14 |
+
|
| 15 |
+
@field_validator("username")
|
| 16 |
+
@classmethod
|
| 17 |
+
def validate_username(cls, v: str) -> str:
|
| 18 |
+
v = v.strip()
|
| 19 |
+
if not 3 <= len(v) <= 30:
|
| 20 |
+
raise ValueError("Username must be 3–30 characters")
|
| 21 |
+
if not re.match(r"^[a-zA-Z0-9_]+$", v):
|
| 22 |
+
raise ValueError("Username can only contain letters, numbers, and underscores")
|
| 23 |
+
return v
|
| 24 |
+
|
| 25 |
+
@field_validator("password")
|
| 26 |
+
@classmethod
|
| 27 |
+
def validate_password(cls, v: str) -> str:
|
| 28 |
+
if len(v) < 6:
|
| 29 |
+
raise ValueError("Password must be at least 6 characters")
|
| 30 |
+
return v
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TokenResponse(BaseModel):
|
| 34 |
+
access_token: str
|
| 35 |
+
token_type: str = "bearer"
|
| 36 |
+
username: str
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.post("/register", response_model=TokenResponse)
|
| 40 |
+
async def register(req: AuthRequest):
|
| 41 |
+
hashed = hash_password(req.password)
|
| 42 |
+
created = create_user(req.username, hashed)
|
| 43 |
+
if not created:
|
| 44 |
+
raise HTTPException(
|
| 45 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 46 |
+
detail="Username already taken"
|
| 47 |
+
)
|
| 48 |
+
token = create_token(req.username)
|
| 49 |
+
return TokenResponse(access_token=token, username=req.username)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.post("/login", response_model=TokenResponse)
|
| 53 |
+
async def login(req: AuthRequest):
|
| 54 |
+
row = get_user(req.username)
|
| 55 |
+
if not row or not verify_password(req.password, row[1]):
|
| 56 |
+
raise HTTPException(
|
| 57 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 58 |
+
detail="Incorrect username or password"
|
| 59 |
+
)
|
| 60 |
+
token = create_token(req.username)
|
| 61 |
+
return TokenResponse(access_token=token, username=req.username)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/me")
|
| 65 |
+
async def me(current_user: str = Depends(get_current_user)):
|
| 66 |
+
return {"username": current_user}
|
app/api/endpoints.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException,
|
| 2 |
from fastapi.responses import StreamingResponse
|
| 3 |
import json
|
| 4 |
import asyncio
|
|
@@ -11,33 +11,32 @@ from app.services.hippocampus import consolidate_memory
|
|
| 11 |
from app.services.neocortex import extract_and_store_knowledge
|
| 12 |
from app.services.sleep_cycle import run_sleep_cycle
|
| 13 |
from app.db.neo4j_driver import neo4j_db
|
| 14 |
-
|
| 15 |
from app.services.vitals import get_brain_vitals
|
|
|
|
| 16 |
|
| 17 |
router = APIRouter()
|
| 18 |
|
|
|
|
|
|
|
|
|
|
| 19 |
@router.get("/brain/vitals")
|
| 20 |
-
async def fetch_brain_vitals(
|
| 21 |
-
|
| 22 |
-
|
| 23 |
|
| 24 |
@router.get("/brain/sparks")
|
| 25 |
-
async def fetch_neural_sparks(
|
| 26 |
-
|
| 27 |
-
return get_recent_sparks(user_id=user_id, limit=limit)
|
| 28 |
|
| 29 |
|
| 30 |
-
# ── Knowledge Graph
|
| 31 |
|
| 32 |
@router.get("/graph")
|
| 33 |
-
async def get_knowledge_graph(
|
| 34 |
-
"""Return all nodes and edges from the Knowledge Graph for visualization."""
|
| 35 |
if not neo4j_db.driver:
|
| 36 |
return {"nodes": [], "edges": [], "status": "offline"}
|
| 37 |
|
| 38 |
try:
|
| 39 |
-
# Fetch all nodes with their connection counts
|
| 40 |
-
# Include legacy nodes (no user_id) alongside user-specific ones
|
| 41 |
node_query = """
|
| 42 |
MATCH (n:Entity)
|
| 43 |
WHERE n.user_id = $user_id OR n.user_id IS NULL
|
|
@@ -45,25 +44,18 @@ async def get_knowledge_graph(user_id: str = "default_user"):
|
|
| 45 |
RETURN n.name AS id, count(r) AS connections
|
| 46 |
ORDER BY connections DESC
|
| 47 |
"""
|
| 48 |
-
node_results = neo4j_db.query(node_query, {"user_id":
|
| 49 |
|
| 50 |
-
# Fetch all edges
|
| 51 |
edge_query = """
|
| 52 |
MATCH (s:Entity)-[r]->(t:Entity)
|
| 53 |
WHERE (s.user_id = $user_id OR s.user_id IS NULL)
|
| 54 |
AND (t.user_id = $user_id OR t.user_id IS NULL)
|
| 55 |
RETURN s.name AS source, type(r) AS label, t.name AS target
|
| 56 |
"""
|
| 57 |
-
edge_results = neo4j_db.query(edge_query, {"user_id":
|
| 58 |
|
| 59 |
-
nodes = [
|
| 60 |
-
|
| 61 |
-
for r in node_results
|
| 62 |
-
]
|
| 63 |
-
edges = [
|
| 64 |
-
{"source": r["source"], "target": r["target"], "label": r["label"]}
|
| 65 |
-
for r in edge_results
|
| 66 |
-
]
|
| 67 |
|
| 68 |
return {"nodes": nodes, "edges": edges, "status": "online"}
|
| 69 |
except Exception as e:
|
|
@@ -71,8 +63,7 @@ async def get_knowledge_graph(user_id: str = "default_user"):
|
|
| 71 |
|
| 72 |
|
| 73 |
@router.get("/graph/stats")
|
| 74 |
-
async def get_graph_stats(
|
| 75 |
-
"""Return aggregate stats about the Knowledge Graph."""
|
| 76 |
if not neo4j_db.driver:
|
| 77 |
return {"node_count": 0, "edge_count": 0, "top_entities": [], "status": "offline"}
|
| 78 |
|
|
@@ -83,7 +74,7 @@ async def get_graph_stats(user_id: str = "default_user"):
|
|
| 83 |
OPTIONAL MATCH (n)-[r]->()
|
| 84 |
RETURN count(DISTINCT n) AS nodes, count(DISTINCT r) AS edges
|
| 85 |
"""
|
| 86 |
-
counts = neo4j_db.query(count_query, {"user_id":
|
| 87 |
node_count = counts[0]["nodes"] if counts else 0
|
| 88 |
edge_count = counts[0]["edges"] if counts else 0
|
| 89 |
|
|
@@ -94,21 +85,18 @@ async def get_graph_stats(user_id: str = "default_user"):
|
|
| 94 |
ORDER BY connections DESC
|
| 95 |
LIMIT 5
|
| 96 |
"""
|
| 97 |
-
top_results = neo4j_db.query(top_query, {"user_id":
|
| 98 |
top_entities = [{"entity": r["entity"], "connections": r["connections"]} for r in top_results]
|
| 99 |
|
| 100 |
-
return {
|
| 101 |
-
"node_count": node_count,
|
| 102 |
-
"edge_count": edge_count,
|
| 103 |
-
"top_entities": top_entities,
|
| 104 |
-
"status": "online"
|
| 105 |
-
}
|
| 106 |
except Exception as e:
|
| 107 |
return {"node_count": 0, "edge_count": 0, "top_entities": [], "status": "error", "detail": str(e)}
|
| 108 |
|
|
|
|
|
|
|
|
|
|
| 109 |
class QueryRequest(BaseModel):
|
| 110 |
text: str
|
| 111 |
-
user_id: str = "default_user"
|
| 112 |
|
| 113 |
class QueryResponse(BaseModel):
|
| 114 |
response: str
|
|
@@ -117,47 +105,49 @@ class QueryResponse(BaseModel):
|
|
| 117 |
class IngestRequest(BaseModel):
|
| 118 |
text: str
|
| 119 |
metadata: Optional[Dict] = None
|
| 120 |
-
user_id: str = "default_user"
|
| 121 |
|
| 122 |
class IngestResponse(BaseModel):
|
| 123 |
message: str
|
| 124 |
chunks: int
|
| 125 |
|
| 126 |
class ConsolidateRequest(BaseModel):
|
| 127 |
-
user_id
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
@router.post("/consolidate", response_model=IngestResponse)
|
| 130 |
-
async def process_consolidation(
|
| 131 |
try:
|
| 132 |
-
chunks, msg = consolidate_memory(
|
| 133 |
-
# Assuming consolidate_memory returns chunks > 0 if there was memory
|
| 134 |
if chunks > 0:
|
| 135 |
-
history = get_recent_messages(
|
| 136 |
doc = "\n".join([f"{m['role']}: {m['content']}" for m in history])
|
| 137 |
-
triples = extract_and_store_knowledge(doc,
|
| 138 |
msg += f" Extracted {triples} graph relations."
|
| 139 |
-
|
| 140 |
-
return IngestResponse(
|
| 141 |
-
message=msg,
|
| 142 |
-
chunks=chunks
|
| 143 |
-
)
|
| 144 |
except Exception as e:
|
| 145 |
raise HTTPException(status_code=500, detail=str(e))
|
| 146 |
|
|
|
|
|
|
|
|
|
|
| 147 |
@router.post("/sleep")
|
| 148 |
-
async def process_sleep_cycle():
|
| 149 |
-
"""Trigger one full Sleep Cycle — summarize, store, and prune."""
|
| 150 |
try:
|
| 151 |
report = run_sleep_cycle(keep_recent=10)
|
| 152 |
return report
|
| 153 |
except Exception as e:
|
| 154 |
raise HTTPException(status_code=500, detail=str(e))
|
| 155 |
|
|
|
|
|
|
|
|
|
|
| 156 |
@router.post("/ingest", response_model=IngestResponse)
|
| 157 |
-
async def process_ingest(request: IngestRequest):
|
| 158 |
try:
|
| 159 |
-
num_chunks = ingest_text(request.text, request.metadata,
|
| 160 |
-
triples = extract_and_store_knowledge(request.text,
|
| 161 |
return IngestResponse(
|
| 162 |
message=f"Sensory data ingested. Extracted {triples} graph relations.",
|
| 163 |
chunks=num_chunks
|
|
@@ -165,18 +155,17 @@ async def process_ingest(request: IngestRequest):
|
|
| 165 |
except Exception as e:
|
| 166 |
raise HTTPException(status_code=500, detail=str(e))
|
| 167 |
|
|
|
|
|
|
|
|
|
|
| 168 |
@router.post("/query/stream")
|
| 169 |
-
async def process_query_stream(request: QueryRequest):
|
| 170 |
-
"""
|
| 171 |
-
Stream the cognitive process and finally the response as Server-Sent Events.
|
| 172 |
-
"""
|
| 173 |
async def event_generator():
|
| 174 |
try:
|
| 175 |
-
|
| 176 |
-
history = get_recent_messages(request.user_id, exchanges=5)
|
| 177 |
state_input = {
|
| 178 |
"input": request.text,
|
| 179 |
-
"user_id":
|
| 180 |
"chat_history": history,
|
| 181 |
"context": [],
|
| 182 |
"graph_context": [],
|
|
@@ -184,44 +173,35 @@ async def process_query_stream(request: QueryRequest):
|
|
| 184 |
"response": ""
|
| 185 |
}
|
| 186 |
|
| 187 |
-
# Send Initial Perception Trace
|
| 188 |
perception_msg = f"Processing query: {request.text[:50]}..."
|
| 189 |
yield f"event: trace\ndata: {json.dumps({'phase': 'perception', 'message': perception_msg})}\n\n"
|
| 190 |
await asyncio.sleep(0.1)
|
| 191 |
|
| 192 |
-
# Step 2: Stream LangGraph Execution
|
| 193 |
-
# orchestrator.stream is a sync iterator, so we use it in a thread or just run it if it's fast enough
|
| 194 |
-
# For simplicity in this demo, we use the stream directly
|
| 195 |
for output in orchestrator.stream(state_input):
|
| 196 |
for node_name, node_output in output.items():
|
| 197 |
if node_name == "reflect":
|
| 198 |
-
# Send Internal Reflection Trace
|
| 199 |
reflection = node_output.get("reflection", "")
|
| 200 |
yield f"event: reflection\ndata: {json.dumps({'message': reflection})}\n\n"
|
| 201 |
-
await asyncio.sleep(0.3)
|
| 202 |
-
|
| 203 |
elif node_name == "retrieve":
|
| 204 |
-
# Send Recall & Association Trace
|
| 205 |
trace_data = node_output.get("trace_data", {})
|
| 206 |
recall_msg = f"Found {trace_data.get('sensory_count')} sensory memories."
|
| 207 |
assoc_msg = f"Extracted {trace_data.get('graph_count')} graph relations."
|
| 208 |
-
|
| 209 |
yield f"event: trace\ndata: {json.dumps({'phase': 'recall', 'message': recall_msg, 'data': node_output.get('context')})}\n\n"
|
| 210 |
await asyncio.sleep(0.2)
|
| 211 |
yield f"event: trace\ndata: {json.dumps({'phase': 'association', 'message': assoc_msg, 'data': node_output.get('graph_context'), 'touched': trace_data.get('touched')})}\n\n"
|
| 212 |
await asyncio.sleep(0.2)
|
| 213 |
-
|
| 214 |
elif node_name == "call_model":
|
| 215 |
-
# Send Reasoning & Final Trace
|
| 216 |
reason_msg = "Synthesizing final response via Cortex Node..."
|
| 217 |
yield f"event: trace\ndata: {json.dumps({'phase': 'reasoning', 'message': reason_msg})}\n\n"
|
| 218 |
await asyncio.sleep(0.1)
|
| 219 |
-
|
| 220 |
final_response = node_output.get("response", "")
|
| 221 |
-
|
| 222 |
-
add_message(
|
| 223 |
-
|
| 224 |
-
|
| 225 |
yield f"event: final_result\ndata: {json.dumps({'response': final_response})}\n\n"
|
| 226 |
|
| 227 |
except Exception as e:
|
|
@@ -229,43 +209,13 @@ async def process_query_stream(request: QueryRequest):
|
|
| 229 |
|
| 230 |
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
try:
|
| 235 |
-
# Step 3: Working Memory - Load the last 5 exchanges
|
| 236 |
-
history = get_recent_messages(request.user_id, exchanges=5)
|
| 237 |
-
|
| 238 |
-
# Step 2: Sensory Memory Logic - invoke the retrieval-augmented graph
|
| 239 |
-
state_input = {
|
| 240 |
-
"input": request.text,
|
| 241 |
-
"user_id": request.user_id,
|
| 242 |
-
"chat_history": history,
|
| 243 |
-
"context": [],
|
| 244 |
-
"graph_context": [],
|
| 245 |
-
"response": ""
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
# Run the graph
|
| 249 |
-
result = orchestrator.invoke(state_input)
|
| 250 |
-
final_response = result.get("response", "No response generated.")
|
| 251 |
-
|
| 252 |
-
# Step 3 (cont): Save the new exchange to Working Memory
|
| 253 |
-
add_message(request.user_id, "user", request.text)
|
| 254 |
-
add_message(request.user_id, "assistant", final_response)
|
| 255 |
-
|
| 256 |
-
return QueryResponse(
|
| 257 |
-
response=final_response,
|
| 258 |
-
sources=["Step 2: Sensory Memory (ChromaDB)", "Step 3: Working Memory (SQLite)"]
|
| 259 |
-
)
|
| 260 |
-
except Exception as e:
|
| 261 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 262 |
|
| 263 |
@router.get("/history")
|
| 264 |
-
async def fetch_chat_history(
|
| 265 |
-
"""Fetch the recent SQLite conversation when switching personas."""
|
| 266 |
try:
|
| 267 |
-
history = get_recent_messages(
|
| 268 |
return {"messages": history}
|
| 269 |
except Exception as e:
|
| 270 |
raise HTTPException(status_code=500, detail=str(e))
|
| 271 |
-
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
from fastapi.responses import StreamingResponse
|
| 3 |
import json
|
| 4 |
import asyncio
|
|
|
|
| 11 |
from app.services.neocortex import extract_and_store_knowledge
|
| 12 |
from app.services.sleep_cycle import run_sleep_cycle
|
| 13 |
from app.db.neo4j_driver import neo4j_db
|
|
|
|
| 14 |
from app.services.vitals import get_brain_vitals
|
| 15 |
+
from app.auth.auth import get_current_user
|
| 16 |
|
| 17 |
router = APIRouter()
|
| 18 |
|
| 19 |
+
|
| 20 |
+
# ── Brain Vitals ─────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
@router.get("/brain/vitals")
|
| 23 |
+
async def fetch_brain_vitals(current_user: str = Depends(get_current_user)):
|
| 24 |
+
return get_brain_vitals(current_user)
|
| 25 |
+
|
| 26 |
|
| 27 |
@router.get("/brain/sparks")
|
| 28 |
+
async def fetch_neural_sparks(limit: int = 5, current_user: str = Depends(get_current_user)):
|
| 29 |
+
return get_recent_sparks(user_id=current_user, limit=limit)
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
+
# ── Knowledge Graph ───────────────────────────────────────────────
|
| 33 |
|
| 34 |
@router.get("/graph")
|
| 35 |
+
async def get_knowledge_graph(current_user: str = Depends(get_current_user)):
|
|
|
|
| 36 |
if not neo4j_db.driver:
|
| 37 |
return {"nodes": [], "edges": [], "status": "offline"}
|
| 38 |
|
| 39 |
try:
|
|
|
|
|
|
|
| 40 |
node_query = """
|
| 41 |
MATCH (n:Entity)
|
| 42 |
WHERE n.user_id = $user_id OR n.user_id IS NULL
|
|
|
|
| 44 |
RETURN n.name AS id, count(r) AS connections
|
| 45 |
ORDER BY connections DESC
|
| 46 |
"""
|
| 47 |
+
node_results = neo4j_db.query(node_query, {"user_id": current_user}) or []
|
| 48 |
|
|
|
|
| 49 |
edge_query = """
|
| 50 |
MATCH (s:Entity)-[r]->(t:Entity)
|
| 51 |
WHERE (s.user_id = $user_id OR s.user_id IS NULL)
|
| 52 |
AND (t.user_id = $user_id OR t.user_id IS NULL)
|
| 53 |
RETURN s.name AS source, type(r) AS label, t.name AS target
|
| 54 |
"""
|
| 55 |
+
edge_results = neo4j_db.query(edge_query, {"user_id": current_user}) or []
|
| 56 |
|
| 57 |
+
nodes = [{"id": r["id"], "label": r["id"], "connections": r["connections"]} for r in node_results]
|
| 58 |
+
edges = [{"source": r["source"], "target": r["target"], "label": r["label"]} for r in edge_results]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
return {"nodes": nodes, "edges": edges, "status": "online"}
|
| 61 |
except Exception as e:
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
@router.get("/graph/stats")
|
| 66 |
+
async def get_graph_stats(current_user: str = Depends(get_current_user)):
|
|
|
|
| 67 |
if not neo4j_db.driver:
|
| 68 |
return {"node_count": 0, "edge_count": 0, "top_entities": [], "status": "offline"}
|
| 69 |
|
|
|
|
| 74 |
OPTIONAL MATCH (n)-[r]->()
|
| 75 |
RETURN count(DISTINCT n) AS nodes, count(DISTINCT r) AS edges
|
| 76 |
"""
|
| 77 |
+
counts = neo4j_db.query(count_query, {"user_id": current_user})
|
| 78 |
node_count = counts[0]["nodes"] if counts else 0
|
| 79 |
edge_count = counts[0]["edges"] if counts else 0
|
| 80 |
|
|
|
|
| 85 |
ORDER BY connections DESC
|
| 86 |
LIMIT 5
|
| 87 |
"""
|
| 88 |
+
top_results = neo4j_db.query(top_query, {"user_id": current_user}) or []
|
| 89 |
top_entities = [{"entity": r["entity"], "connections": r["connections"]} for r in top_results]
|
| 90 |
|
| 91 |
+
return {"node_count": node_count, "edge_count": edge_count, "top_entities": top_entities, "status": "online"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
except Exception as e:
|
| 93 |
return {"node_count": 0, "edge_count": 0, "top_entities": [], "status": "error", "detail": str(e)}
|
| 94 |
|
| 95 |
+
|
| 96 |
+
# ── Request / Response Models ─────────────────────────────────────
|
| 97 |
+
|
| 98 |
class QueryRequest(BaseModel):
|
| 99 |
text: str
|
|
|
|
| 100 |
|
| 101 |
class QueryResponse(BaseModel):
|
| 102 |
response: str
|
|
|
|
| 105 |
class IngestRequest(BaseModel):
|
| 106 |
text: str
|
| 107 |
metadata: Optional[Dict] = None
|
|
|
|
| 108 |
|
| 109 |
class IngestResponse(BaseModel):
|
| 110 |
message: str
|
| 111 |
chunks: int
|
| 112 |
|
| 113 |
class ConsolidateRequest(BaseModel):
|
| 114 |
+
pass # user_id now comes from token
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ── Consolidate ───────────────────────────────────────────────────
|
| 118 |
|
| 119 |
@router.post("/consolidate", response_model=IngestResponse)
|
| 120 |
+
async def process_consolidation(current_user: str = Depends(get_current_user)):
|
| 121 |
try:
|
| 122 |
+
chunks, msg = consolidate_memory(current_user)
|
|
|
|
| 123 |
if chunks > 0:
|
| 124 |
+
history = get_recent_messages(current_user, exchanges=50)
|
| 125 |
doc = "\n".join([f"{m['role']}: {m['content']}" for m in history])
|
| 126 |
+
triples = extract_and_store_knowledge(doc, current_user)
|
| 127 |
msg += f" Extracted {triples} graph relations."
|
| 128 |
+
return IngestResponse(message=msg, chunks=chunks)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
except Exception as e:
|
| 130 |
raise HTTPException(status_code=500, detail=str(e))
|
| 131 |
|
| 132 |
+
|
| 133 |
+
# ── Sleep ─────────────────────────────────────────────────────────
|
| 134 |
+
|
| 135 |
@router.post("/sleep")
|
| 136 |
+
async def process_sleep_cycle(current_user: str = Depends(get_current_user)):
|
|
|
|
| 137 |
try:
|
| 138 |
report = run_sleep_cycle(keep_recent=10)
|
| 139 |
return report
|
| 140 |
except Exception as e:
|
| 141 |
raise HTTPException(status_code=500, detail=str(e))
|
| 142 |
|
| 143 |
+
|
| 144 |
+
# ── Ingest ────────────────────────────────────────────────────────
|
| 145 |
+
|
| 146 |
@router.post("/ingest", response_model=IngestResponse)
|
| 147 |
+
async def process_ingest(request: IngestRequest, current_user: str = Depends(get_current_user)):
|
| 148 |
try:
|
| 149 |
+
num_chunks = ingest_text(request.text, request.metadata, current_user)
|
| 150 |
+
triples = extract_and_store_knowledge(request.text, current_user)
|
| 151 |
return IngestResponse(
|
| 152 |
message=f"Sensory data ingested. Extracted {triples} graph relations.",
|
| 153 |
chunks=num_chunks
|
|
|
|
| 155 |
except Exception as e:
|
| 156 |
raise HTTPException(status_code=500, detail=str(e))
|
| 157 |
|
| 158 |
+
|
| 159 |
+
# ── Stream Query ──────────────────────────────────────────────────
|
| 160 |
+
|
| 161 |
@router.post("/query/stream")
|
| 162 |
+
async def process_query_stream(request: QueryRequest, current_user: str = Depends(get_current_user)):
|
|
|
|
|
|
|
|
|
|
| 163 |
async def event_generator():
|
| 164 |
try:
|
| 165 |
+
history = get_recent_messages(current_user, exchanges=5)
|
|
|
|
| 166 |
state_input = {
|
| 167 |
"input": request.text,
|
| 168 |
+
"user_id": current_user,
|
| 169 |
"chat_history": history,
|
| 170 |
"context": [],
|
| 171 |
"graph_context": [],
|
|
|
|
| 173 |
"response": ""
|
| 174 |
}
|
| 175 |
|
|
|
|
| 176 |
perception_msg = f"Processing query: {request.text[:50]}..."
|
| 177 |
yield f"event: trace\ndata: {json.dumps({'phase': 'perception', 'message': perception_msg})}\n\n"
|
| 178 |
await asyncio.sleep(0.1)
|
| 179 |
|
|
|
|
|
|
|
|
|
|
| 180 |
for output in orchestrator.stream(state_input):
|
| 181 |
for node_name, node_output in output.items():
|
| 182 |
if node_name == "reflect":
|
|
|
|
| 183 |
reflection = node_output.get("reflection", "")
|
| 184 |
yield f"event: reflection\ndata: {json.dumps({'message': reflection})}\n\n"
|
| 185 |
+
await asyncio.sleep(0.3)
|
| 186 |
+
|
| 187 |
elif node_name == "retrieve":
|
|
|
|
| 188 |
trace_data = node_output.get("trace_data", {})
|
| 189 |
recall_msg = f"Found {trace_data.get('sensory_count')} sensory memories."
|
| 190 |
assoc_msg = f"Extracted {trace_data.get('graph_count')} graph relations."
|
|
|
|
| 191 |
yield f"event: trace\ndata: {json.dumps({'phase': 'recall', 'message': recall_msg, 'data': node_output.get('context')})}\n\n"
|
| 192 |
await asyncio.sleep(0.2)
|
| 193 |
yield f"event: trace\ndata: {json.dumps({'phase': 'association', 'message': assoc_msg, 'data': node_output.get('graph_context'), 'touched': trace_data.get('touched')})}\n\n"
|
| 194 |
await asyncio.sleep(0.2)
|
| 195 |
+
|
| 196 |
elif node_name == "call_model":
|
|
|
|
| 197 |
reason_msg = "Synthesizing final response via Cortex Node..."
|
| 198 |
yield f"event: trace\ndata: {json.dumps({'phase': 'reasoning', 'message': reason_msg})}\n\n"
|
| 199 |
await asyncio.sleep(0.1)
|
| 200 |
+
|
| 201 |
final_response = node_output.get("response", "")
|
| 202 |
+
add_message(current_user, "user", request.text)
|
| 203 |
+
add_message(current_user, "assistant", final_response)
|
| 204 |
+
|
|
|
|
| 205 |
yield f"event: final_result\ndata: {json.dumps({'response': final_response})}\n\n"
|
| 206 |
|
| 207 |
except Exception as e:
|
|
|
|
| 209 |
|
| 210 |
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 211 |
|
| 212 |
+
|
| 213 |
+
# ── History ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
@router.get("/history")
|
| 216 |
+
async def fetch_chat_history(current_user: str = Depends(get_current_user)):
|
|
|
|
| 217 |
try:
|
| 218 |
+
history = get_recent_messages(current_user, exchanges=20)
|
| 219 |
return {"messages": history}
|
| 220 |
except Exception as e:
|
| 221 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
app/auth/auth.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from fastapi import Depends, HTTPException, status
|
| 5 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 6 |
+
from jose import JWTError, jwt
|
| 7 |
+
from passlib.context import CryptContext
|
| 8 |
+
|
| 9 |
+
from app.core.config import settings
|
| 10 |
+
|
| 11 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 12 |
+
bearer_scheme = HTTPBearer()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ── Password helpers ──────────────────────────────────────────────
|
| 16 |
+
|
| 17 |
+
def hash_password(plain: str) -> str:
|
| 18 |
+
return pwd_context.hash(plain)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def verify_password(plain: str, hashed: str) -> bool:
|
| 22 |
+
return pwd_context.verify(plain, hashed)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ── JWT helpers ───────────────────────────────────────────────────
|
| 26 |
+
|
| 27 |
+
def create_token(username: str) -> str:
|
| 28 |
+
expire = datetime.utcnow() + timedelta(days=settings.JWT_EXPIRE_DAYS)
|
| 29 |
+
payload = {"sub": username, "exp": expire}
|
| 30 |
+
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def decode_token(token: str) -> Optional[str]:
|
| 34 |
+
"""Return username from a valid token, or None."""
|
| 35 |
+
try:
|
| 36 |
+
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
| 37 |
+
return payload.get("sub")
|
| 38 |
+
except JWTError:
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ── FastAPI dependency ────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> str:
|
| 45 |
+
"""
|
| 46 |
+
Validates the Bearer token and returns the username (used as user_id).
|
| 47 |
+
Raises 401 if the token is missing, expired, or invalid.
|
| 48 |
+
"""
|
| 49 |
+
username = decode_token(credentials.credentials)
|
| 50 |
+
if not username:
|
| 51 |
+
raise HTTPException(
|
| 52 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 53 |
+
detail="Invalid or expired token",
|
| 54 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 55 |
+
)
|
| 56 |
+
return username
|
app/core/config.py
CHANGED
|
@@ -32,6 +32,11 @@ class Settings(BaseSettings):
|
|
| 32 |
# SQLite Path
|
| 33 |
SQLITE_DB_PATH: str = os.getenv("SQLITE_DB_PATH", os.path.join(os.getcwd(), "data", "soma_sessions.db"))
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
model_config = SettingsConfigDict(case_sensitive=True)
|
| 36 |
|
| 37 |
@lru_cache()
|
|
|
|
| 32 |
# SQLite Path
|
| 33 |
SQLITE_DB_PATH: str = os.getenv("SQLITE_DB_PATH", os.path.join(os.getcwd(), "data", "soma_sessions.db"))
|
| 34 |
|
| 35 |
+
# Auth
|
| 36 |
+
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "change-this-in-production-please")
|
| 37 |
+
JWT_ALGORITHM: str = "HS256"
|
| 38 |
+
JWT_EXPIRE_DAYS: int = 30
|
| 39 |
+
|
| 40 |
model_config = SettingsConfigDict(case_sensitive=True)
|
| 41 |
|
| 42 |
@lru_cache()
|
app/db/session.py
CHANGED
|
@@ -5,6 +5,14 @@ DB_PATH = settings.SQLITE_DB_PATH
|
|
| 5 |
|
| 6 |
def init_session_db():
|
| 7 |
with sqlite3.connect(DB_PATH) as db:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
db.execute('''
|
| 9 |
CREATE TABLE IF NOT EXISTS messages (
|
| 10 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -23,13 +31,37 @@ def init_session_db():
|
|
| 23 |
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 24 |
)
|
| 25 |
''')
|
| 26 |
-
# Migration: add user_id to existing
|
| 27 |
try:
|
| 28 |
db.execute("ALTER TABLE neural_sparks ADD COLUMN user_id TEXT DEFAULT 'default_user'")
|
| 29 |
except sqlite3.OperationalError:
|
| 30 |
pass
|
| 31 |
db.commit()
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def add_message(session_id: str, role: str, content: str):
|
| 34 |
"""Save a single message to the Working Memory."""
|
| 35 |
with sqlite3.connect(DB_PATH) as db:
|
|
|
|
| 5 |
|
| 6 |
def init_session_db():
|
| 7 |
with sqlite3.connect(DB_PATH) as db:
|
| 8 |
+
db.execute('''
|
| 9 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 10 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 11 |
+
username TEXT UNIQUE NOT NULL,
|
| 12 |
+
hashed_password TEXT NOT NULL,
|
| 13 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 14 |
+
)
|
| 15 |
+
''')
|
| 16 |
db.execute('''
|
| 17 |
CREATE TABLE IF NOT EXISTS messages (
|
| 18 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 31 |
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 32 |
)
|
| 33 |
''')
|
| 34 |
+
# Migration: add user_id to existing databases gracefully
|
| 35 |
try:
|
| 36 |
db.execute("ALTER TABLE neural_sparks ADD COLUMN user_id TEXT DEFAULT 'default_user'")
|
| 37 |
except sqlite3.OperationalError:
|
| 38 |
pass
|
| 39 |
db.commit()
|
| 40 |
|
| 41 |
+
# ── User account helpers ──────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
def create_user(username: str, hashed_password: str) -> bool:
|
| 44 |
+
"""Insert a new user. Returns False if username already taken."""
|
| 45 |
+
try:
|
| 46 |
+
with sqlite3.connect(DB_PATH) as db:
|
| 47 |
+
db.execute(
|
| 48 |
+
'INSERT INTO users (username, hashed_password) VALUES (?, ?)',
|
| 49 |
+
(username, hashed_password)
|
| 50 |
+
)
|
| 51 |
+
db.commit()
|
| 52 |
+
return True
|
| 53 |
+
except sqlite3.IntegrityError:
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
def get_user(username: str):
|
| 57 |
+
"""Return (username, hashed_password) row or None."""
|
| 58 |
+
with sqlite3.connect(DB_PATH) as db:
|
| 59 |
+
cursor = db.execute(
|
| 60 |
+
'SELECT username, hashed_password FROM users WHERE username = ?',
|
| 61 |
+
(username,)
|
| 62 |
+
)
|
| 63 |
+
return cursor.fetchone()
|
| 64 |
+
|
| 65 |
def add_message(session_id: str, role: str, content: str):
|
| 66 |
"""Save a single message to the Working Memory."""
|
| 67 |
with sqlite3.connect(DB_PATH) as db:
|
app/main.py
CHANGED
|
@@ -2,6 +2,7 @@ from fastapi import FastAPI
|
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from app.core.config import settings
|
| 4 |
from app.api.endpoints import router as api_router
|
|
|
|
| 5 |
from app.db.session import init_session_db
|
| 6 |
from app.services.dreaming import idle_brain_cycle
|
| 7 |
import asyncio
|
|
@@ -27,7 +28,8 @@ app.add_middleware(
|
|
| 27 |
allow_headers=["*"],
|
| 28 |
)
|
| 29 |
|
| 30 |
-
# Include
|
|
|
|
| 31 |
app.include_router(api_router, prefix=settings.API_V1_STR)
|
| 32 |
|
| 33 |
# Serve React frontend if built
|
|
|
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from app.core.config import settings
|
| 4 |
from app.api.endpoints import router as api_router
|
| 5 |
+
from app.api.auth_router import router as auth_router
|
| 6 |
from app.db.session import init_session_db
|
| 7 |
from app.services.dreaming import idle_brain_cycle
|
| 8 |
import asyncio
|
|
|
|
| 28 |
allow_headers=["*"],
|
| 29 |
)
|
| 30 |
|
| 31 |
+
# Include routers
|
| 32 |
+
app.include_router(auth_router, prefix=settings.API_V1_STR)
|
| 33 |
app.include_router(api_router, prefix=settings.API_V1_STR)
|
| 34 |
|
| 35 |
# Serve React frontend if built
|
frontend/src/App.jsx
CHANGED
|
@@ -4,11 +4,14 @@ import BrainProcess from './components/BrainProcess'
|
|
| 4 |
import KnowledgeGraph from './components/KnowledgeGraph'
|
| 5 |
import DreamSequence from './components/DreamSequence'
|
| 6 |
import Onboarding from './components/Onboarding'
|
|
|
|
|
|
|
| 7 |
import './App.css'
|
| 8 |
|
| 9 |
function App() {
|
| 10 |
-
const [
|
| 11 |
-
const [
|
|
|
|
| 12 |
sensoryDocuments: 0,
|
| 13 |
graphRelations: 0,
|
| 14 |
workingMemory: 0,
|
|
@@ -20,17 +23,16 @@ function App() {
|
|
| 20 |
cognitiveState: 'IDLE',
|
| 21 |
traces: []
|
| 22 |
})
|
| 23 |
-
const [rightPanel,
|
| 24 |
-
const [
|
| 25 |
-
const [
|
| 26 |
-
const [
|
| 27 |
-
const [
|
| 28 |
-
const [sleepModal, setSleepModal] = useState(null) // null | { pruned, relations }
|
| 29 |
|
| 30 |
// ── Resizable columns ──
|
| 31 |
const [colWidths, setColWidths] = useState([30, 32, 38])
|
| 32 |
-
const dragRef
|
| 33 |
-
const layoutRef
|
| 34 |
|
| 35 |
const startDrag = useCallback((dividerIndex, e) => {
|
| 36 |
e.preventDefault()
|
|
@@ -68,19 +70,22 @@ function App() {
|
|
| 68 |
localStorage.setItem('soma_theme', theme)
|
| 69 |
}, [theme])
|
| 70 |
|
| 71 |
-
// ── First visit ──
|
| 72 |
useEffect(() => {
|
| 73 |
-
if (!localStorage.getItem('soma_visited')) {
|
| 74 |
setShowOnboarding(true)
|
| 75 |
localStorage.setItem('soma_visited', 'true')
|
| 76 |
}
|
| 77 |
-
}, [])
|
| 78 |
|
| 79 |
-
// ── Poll vitals & sparks ──
|
| 80 |
useEffect(() => {
|
|
|
|
|
|
|
| 81 |
const fetchVitals = async () => {
|
| 82 |
try {
|
| 83 |
-
const res = await
|
|
|
|
| 84 |
const data = await res.json()
|
| 85 |
setBrainState(prev => ({
|
| 86 |
...prev,
|
|
@@ -91,35 +96,40 @@ function App() {
|
|
| 91 |
}))
|
| 92 |
} catch { /* backend offline */ }
|
| 93 |
}
|
|
|
|
| 94 |
const fetchSparks = async () => {
|
| 95 |
try {
|
| 96 |
-
const res = await
|
|
|
|
| 97 |
const data = await res.json()
|
| 98 |
setBrainState(prev => ({ ...prev, sparks: data }))
|
| 99 |
} catch { /* silent */ }
|
| 100 |
}
|
|
|
|
| 101 |
fetchVitals(); fetchSparks()
|
| 102 |
const id = setInterval(() => { fetchVitals(); fetchSparks() }, 20000)
|
| 103 |
return () => clearInterval(id)
|
| 104 |
-
}, [
|
| 105 |
|
| 106 |
-
// ── Chat history on
|
| 107 |
useEffect(() => {
|
|
|
|
| 108 |
const load = async () => {
|
| 109 |
try {
|
| 110 |
-
const res = await
|
|
|
|
| 111 |
const data = await res.json()
|
| 112 |
setMessages(data.messages || [])
|
| 113 |
} catch { /* silent */ }
|
| 114 |
}
|
| 115 |
load()
|
| 116 |
-
}, [
|
| 117 |
|
| 118 |
// ── Sleep ──
|
| 119 |
const handleSleep = async () => {
|
| 120 |
setBrainState(prev => ({ ...prev, isLoading: true, cognitiveState: 'SLEEPING' }))
|
| 121 |
try {
|
| 122 |
-
const res = await
|
| 123 |
const data = await res.json()
|
| 124 |
setBrainState(prev => ({ ...prev, isLoading: false, cognitiveState: 'IDLE', statusMessage: 'Memory consolidated.' }))
|
| 125 |
setSleepModal({ pruned: data.messages_pruned ?? 0, relations: data.graph_relations_extracted ?? 0 })
|
|
@@ -128,11 +138,34 @@ function App() {
|
|
| 128 |
}
|
| 129 |
}
|
| 130 |
|
| 131 |
-
// ──
|
| 132 |
const handleChatComplete = useCallback(() => {
|
| 133 |
setGraphRefreshTick(t => t + 1)
|
| 134 |
}, [])
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
return (
|
| 137 |
<div className={`app-root state-${brainState.cognitiveState.toLowerCase()}`}>
|
| 138 |
|
|
@@ -188,11 +221,11 @@ function App() {
|
|
| 188 |
Sleep
|
| 189 |
</button>
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
<
|
| 194 |
-
<
|
| 195 |
-
</
|
| 196 |
|
| 197 |
<button className="theme-toggle" onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} title="Toggle theme">
|
| 198 |
{theme === 'dark' ? (
|
|
@@ -224,7 +257,7 @@ function App() {
|
|
| 224 |
brainState={brainState}
|
| 225 |
setBrainState={setBrainState}
|
| 226 |
isLoading={brainState.isLoading}
|
| 227 |
-
|
| 228 |
onChatComplete={handleChatComplete}
|
| 229 |
/>
|
| 230 |
</div>
|
|
@@ -244,7 +277,7 @@ function App() {
|
|
| 244 |
{rightPanel === 'graph'
|
| 245 |
? <KnowledgeGraph
|
| 246 |
highlightedNodes={brainState.highlightedNodes}
|
| 247 |
-
|
| 248 |
refreshTick={graphRefreshTick}
|
| 249 |
/>
|
| 250 |
: <DreamSequence sparks={brainState.sparks} />
|
|
|
|
| 4 |
import KnowledgeGraph from './components/KnowledgeGraph'
|
| 5 |
import DreamSequence from './components/DreamSequence'
|
| 6 |
import Onboarding from './components/Onboarding'
|
| 7 |
+
import AuthScreen from './components/AuthScreen'
|
| 8 |
+
import { apiFetch } from './api'
|
| 9 |
import './App.css'
|
| 10 |
|
| 11 |
function App() {
|
| 12 |
+
const [currentUser, setCurrentUser] = useState(() => localStorage.getItem('soma_username') || null)
|
| 13 |
+
const [messages, setMessages] = useState([])
|
| 14 |
+
const [brainState, setBrainState] = useState({
|
| 15 |
sensoryDocuments: 0,
|
| 16 |
graphRelations: 0,
|
| 17 |
workingMemory: 0,
|
|
|
|
| 23 |
cognitiveState: 'IDLE',
|
| 24 |
traces: []
|
| 25 |
})
|
| 26 |
+
const [rightPanel, setRightPanel] = useState('graph')
|
| 27 |
+
const [showOnboarding, setShowOnboarding] = useState(false)
|
| 28 |
+
const [theme, setTheme] = useState(() => localStorage.getItem('soma_theme') || 'dark')
|
| 29 |
+
const [graphRefreshTick, setGraphRefreshTick] = useState(0)
|
| 30 |
+
const [sleepModal, setSleepModal] = useState(null)
|
|
|
|
| 31 |
|
| 32 |
// ── Resizable columns ──
|
| 33 |
const [colWidths, setColWidths] = useState([30, 32, 38])
|
| 34 |
+
const dragRef = useRef(null)
|
| 35 |
+
const layoutRef = useRef(null)
|
| 36 |
|
| 37 |
const startDrag = useCallback((dividerIndex, e) => {
|
| 38 |
e.preventDefault()
|
|
|
|
| 70 |
localStorage.setItem('soma_theme', theme)
|
| 71 |
}, [theme])
|
| 72 |
|
| 73 |
+
// ── First visit onboarding ──
|
| 74 |
useEffect(() => {
|
| 75 |
+
if (currentUser && !localStorage.getItem('soma_visited')) {
|
| 76 |
setShowOnboarding(true)
|
| 77 |
localStorage.setItem('soma_visited', 'true')
|
| 78 |
}
|
| 79 |
+
}, [currentUser])
|
| 80 |
|
| 81 |
+
// ── Poll vitals & sparks (only when logged in) ──
|
| 82 |
useEffect(() => {
|
| 83 |
+
if (!currentUser) return
|
| 84 |
+
|
| 85 |
const fetchVitals = async () => {
|
| 86 |
try {
|
| 87 |
+
const res = await apiFetch(`/api/v1/brain/vitals`)
|
| 88 |
+
if (!res.ok) return
|
| 89 |
const data = await res.json()
|
| 90 |
setBrainState(prev => ({
|
| 91 |
...prev,
|
|
|
|
| 96 |
}))
|
| 97 |
} catch { /* backend offline */ }
|
| 98 |
}
|
| 99 |
+
|
| 100 |
const fetchSparks = async () => {
|
| 101 |
try {
|
| 102 |
+
const res = await apiFetch(`/api/v1/brain/sparks`)
|
| 103 |
+
if (!res.ok) return
|
| 104 |
const data = await res.json()
|
| 105 |
setBrainState(prev => ({ ...prev, sparks: data }))
|
| 106 |
} catch { /* silent */ }
|
| 107 |
}
|
| 108 |
+
|
| 109 |
fetchVitals(); fetchSparks()
|
| 110 |
const id = setInterval(() => { fetchVitals(); fetchSparks() }, 20000)
|
| 111 |
return () => clearInterval(id)
|
| 112 |
+
}, [currentUser])
|
| 113 |
|
| 114 |
+
// ── Chat history on login ──
|
| 115 |
useEffect(() => {
|
| 116 |
+
if (!currentUser) return
|
| 117 |
const load = async () => {
|
| 118 |
try {
|
| 119 |
+
const res = await apiFetch(`/api/v1/history`)
|
| 120 |
+
if (!res.ok) return
|
| 121 |
const data = await res.json()
|
| 122 |
setMessages(data.messages || [])
|
| 123 |
} catch { /* silent */ }
|
| 124 |
}
|
| 125 |
load()
|
| 126 |
+
}, [currentUser])
|
| 127 |
|
| 128 |
// ── Sleep ──
|
| 129 |
const handleSleep = async () => {
|
| 130 |
setBrainState(prev => ({ ...prev, isLoading: true, cognitiveState: 'SLEEPING' }))
|
| 131 |
try {
|
| 132 |
+
const res = await apiFetch('/api/v1/sleep', { method: 'POST' })
|
| 133 |
const data = await res.json()
|
| 134 |
setBrainState(prev => ({ ...prev, isLoading: false, cognitiveState: 'IDLE', statusMessage: 'Memory consolidated.' }))
|
| 135 |
setSleepModal({ pruned: data.messages_pruned ?? 0, relations: data.graph_relations_extracted ?? 0 })
|
|
|
|
| 138 |
}
|
| 139 |
}
|
| 140 |
|
| 141 |
+
// ── Graph refresh after chat ──
|
| 142 |
const handleChatComplete = useCallback(() => {
|
| 143 |
setGraphRefreshTick(t => t + 1)
|
| 144 |
}, [])
|
| 145 |
|
| 146 |
+
// ── Auth ──
|
| 147 |
+
const handleAuth = (username) => {
|
| 148 |
+
setCurrentUser(username)
|
| 149 |
+
setMessages([])
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
const handleLogout = () => {
|
| 153 |
+
localStorage.removeItem('soma_token')
|
| 154 |
+
localStorage.removeItem('soma_username')
|
| 155 |
+
setCurrentUser(null)
|
| 156 |
+
setMessages([])
|
| 157 |
+
setBrainState(prev => ({ ...prev, sensoryDocuments: 0, graphRelations: 0, workingMemory: 0, sparks: [] }))
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// ── Show auth screen if not logged in ──
|
| 161 |
+
if (!currentUser) {
|
| 162 |
+
return (
|
| 163 |
+
<div data-theme={theme}>
|
| 164 |
+
<AuthScreen onAuth={handleAuth} />
|
| 165 |
+
</div>
|
| 166 |
+
)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
return (
|
| 170 |
<div className={`app-root state-${brainState.cognitiveState.toLowerCase()}`}>
|
| 171 |
|
|
|
|
| 221 |
Sleep
|
| 222 |
</button>
|
| 223 |
|
| 224 |
+
{/* Logged-in user + logout */}
|
| 225 |
+
<div className="user-chip">
|
| 226 |
+
<span className="user-chip-name t-label">{currentUser}</span>
|
| 227 |
+
<button className="user-logout-btn t-label" onClick={handleLogout} title="Sign out">↩</button>
|
| 228 |
+
</div>
|
| 229 |
|
| 230 |
<button className="theme-toggle" onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} title="Toggle theme">
|
| 231 |
{theme === 'dark' ? (
|
|
|
|
| 257 |
brainState={brainState}
|
| 258 |
setBrainState={setBrainState}
|
| 259 |
isLoading={brainState.isLoading}
|
| 260 |
+
currentUser={currentUser}
|
| 261 |
onChatComplete={handleChatComplete}
|
| 262 |
/>
|
| 263 |
</div>
|
|
|
|
| 277 |
{rightPanel === 'graph'
|
| 278 |
? <KnowledgeGraph
|
| 279 |
highlightedNodes={brainState.highlightedNodes}
|
| 280 |
+
currentUser={currentUser}
|
| 281 |
refreshTick={graphRefreshTick}
|
| 282 |
/>
|
| 283 |
: <DreamSequence sparks={brainState.sparks} />
|
frontend/src/api.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Authenticated fetch wrapper.
|
| 3 |
+
* Reads the JWT from localStorage and attaches it as a Bearer token
|
| 4 |
+
* on every request. Drop-in replacement for fetch().
|
| 5 |
+
*/
|
| 6 |
+
export function apiFetch(url, options = {}) {
|
| 7 |
+
const token = localStorage.getItem('soma_token');
|
| 8 |
+
return fetch(url, {
|
| 9 |
+
...options,
|
| 10 |
+
headers: {
|
| 11 |
+
'Content-Type': 'application/json',
|
| 12 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 13 |
+
...options.headers,
|
| 14 |
+
},
|
| 15 |
+
});
|
| 16 |
+
}
|
frontend/src/components/AuthScreen.css
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.auth-overlay {
|
| 2 |
+
position: fixed;
|
| 3 |
+
inset: 0;
|
| 4 |
+
background: var(--bg-0);
|
| 5 |
+
display: flex;
|
| 6 |
+
align-items: center;
|
| 7 |
+
justify-content: center;
|
| 8 |
+
z-index: 1000;
|
| 9 |
+
padding: 24px;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.auth-card {
|
| 13 |
+
width: 100%;
|
| 14 |
+
max-width: 380px;
|
| 15 |
+
display: flex;
|
| 16 |
+
flex-direction: column;
|
| 17 |
+
align-items: center;
|
| 18 |
+
gap: 20px;
|
| 19 |
+
animation: soma-rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/* ── Logo ── */
|
| 23 |
+
.auth-logo {
|
| 24 |
+
display: flex;
|
| 25 |
+
align-items: center;
|
| 26 |
+
gap: 14px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.auth-orb {
|
| 30 |
+
width: 36px;
|
| 31 |
+
height: 36px;
|
| 32 |
+
position: relative;
|
| 33 |
+
transform-style: preserve-3d;
|
| 34 |
+
animation: orb-spin 10s linear infinite;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
@keyframes orb-spin {
|
| 38 |
+
from { transform: perspective(80px) rotateY(0deg) rotateX(15deg); }
|
| 39 |
+
to { transform: perspective(80px) rotateY(360deg) rotateX(15deg); }
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.auth-orb-ring {
|
| 43 |
+
position: absolute;
|
| 44 |
+
inset: 2px;
|
| 45 |
+
border-radius: 50%;
|
| 46 |
+
border: 1.5px solid var(--pulse);
|
| 47 |
+
opacity: 0.6;
|
| 48 |
+
}
|
| 49 |
+
.auth-orb-ring.r2 { transform: rotateX(60deg); }
|
| 50 |
+
.auth-orb-ring.r3 { transform: rotateX(-60deg); }
|
| 51 |
+
|
| 52 |
+
.auth-orb-core {
|
| 53 |
+
position: absolute;
|
| 54 |
+
inset: 11px;
|
| 55 |
+
background: var(--pulse);
|
| 56 |
+
border-radius: 50%;
|
| 57 |
+
opacity: 0.75;
|
| 58 |
+
filter: blur(1px);
|
| 59 |
+
box-shadow: 0 0 10px var(--pulse);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.auth-logo-name {
|
| 63 |
+
font-family: var(--font-mono);
|
| 64 |
+
font-size: 1.4rem;
|
| 65 |
+
font-weight: 700;
|
| 66 |
+
letter-spacing: 0.45em;
|
| 67 |
+
color: var(--text-0);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* ── Headings ── */
|
| 71 |
+
.auth-title {
|
| 72 |
+
font-family: var(--font-display);
|
| 73 |
+
font-style: italic;
|
| 74 |
+
font-size: 1.5rem;
|
| 75 |
+
font-weight: 400;
|
| 76 |
+
color: var(--text-0);
|
| 77 |
+
margin: 0;
|
| 78 |
+
text-align: center;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.auth-sub {
|
| 82 |
+
font-size: 0.78rem;
|
| 83 |
+
color: var(--text-2);
|
| 84 |
+
text-align: center;
|
| 85 |
+
line-height: 1.7;
|
| 86 |
+
max-width: 300px;
|
| 87 |
+
margin: -8px 0 0;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* ── Form ── */
|
| 91 |
+
.auth-form {
|
| 92 |
+
width: 100%;
|
| 93 |
+
display: flex;
|
| 94 |
+
flex-direction: column;
|
| 95 |
+
gap: 14px;
|
| 96 |
+
background: var(--bg-2);
|
| 97 |
+
border: 1px solid var(--border-1);
|
| 98 |
+
border-radius: 16px;
|
| 99 |
+
padding: 24px;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.auth-field {
|
| 103 |
+
display: flex;
|
| 104 |
+
flex-direction: column;
|
| 105 |
+
gap: 6px;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.auth-label {
|
| 109 |
+
font-family: var(--font-mono);
|
| 110 |
+
font-size: 0.52rem;
|
| 111 |
+
letter-spacing: 0.18em;
|
| 112 |
+
text-transform: uppercase;
|
| 113 |
+
color: var(--text-2);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.auth-input {
|
| 117 |
+
background: var(--bg-1);
|
| 118 |
+
border: 1px solid var(--border-0);
|
| 119 |
+
border-radius: 8px;
|
| 120 |
+
padding: 10px 14px;
|
| 121 |
+
color: var(--text-0);
|
| 122 |
+
font-family: var(--font-body);
|
| 123 |
+
font-size: 0.9rem;
|
| 124 |
+
outline: none;
|
| 125 |
+
transition: border-color 0.2s ease;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.auth-input:focus {
|
| 129 |
+
border-color: var(--pulse);
|
| 130 |
+
box-shadow: 0 0 0 2px var(--border-0);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.auth-input::placeholder { color: var(--text-2); }
|
| 134 |
+
|
| 135 |
+
/* ── Error ── */
|
| 136 |
+
.auth-error {
|
| 137 |
+
font-size: 0.75rem;
|
| 138 |
+
color: var(--error, #e05252);
|
| 139 |
+
background: rgba(224, 82, 82, 0.08);
|
| 140 |
+
border: 1px solid rgba(224, 82, 82, 0.2);
|
| 141 |
+
border-radius: 6px;
|
| 142 |
+
padding: 8px 12px;
|
| 143 |
+
margin: 0;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/* ── Submit ── */
|
| 147 |
+
.auth-submit {
|
| 148 |
+
padding: 11px;
|
| 149 |
+
border-radius: 9px;
|
| 150 |
+
background: var(--pulse);
|
| 151 |
+
color: var(--bg-0);
|
| 152 |
+
border: none;
|
| 153 |
+
font-family: var(--font-mono);
|
| 154 |
+
font-size: 0.7rem;
|
| 155 |
+
font-weight: 700;
|
| 156 |
+
letter-spacing: 0.12em;
|
| 157 |
+
text-transform: uppercase;
|
| 158 |
+
cursor: pointer;
|
| 159 |
+
transition: opacity 0.2s ease;
|
| 160 |
+
margin-top: 4px;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.auth-submit:hover:not(:disabled) { opacity: 0.88; }
|
| 164 |
+
.auth-submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 165 |
+
|
| 166 |
+
/* ── Toggle ── */
|
| 167 |
+
.auth-toggle {
|
| 168 |
+
font-size: 0.75rem;
|
| 169 |
+
color: var(--text-2);
|
| 170 |
+
margin: 0;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.auth-toggle-btn {
|
| 174 |
+
background: none;
|
| 175 |
+
border: none;
|
| 176 |
+
color: var(--pulse);
|
| 177 |
+
font-size: 0.75rem;
|
| 178 |
+
cursor: pointer;
|
| 179 |
+
padding: 0;
|
| 180 |
+
text-decoration: underline;
|
| 181 |
+
text-underline-offset: 2px;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.auth-toggle-btn:hover { opacity: 0.8; }
|
frontend/src/components/AuthScreen.jsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import './AuthScreen.css';
|
| 3 |
+
|
| 4 |
+
function AuthScreen({ onAuth }) {
|
| 5 |
+
const [mode, setMode] = useState('login'); // 'login' | 'register'
|
| 6 |
+
const [username, setUsername] = useState('');
|
| 7 |
+
const [password, setPassword] = useState('');
|
| 8 |
+
const [error, setError] = useState('');
|
| 9 |
+
const [loading, setLoading] = useState(false);
|
| 10 |
+
|
| 11 |
+
const handleSubmit = async (e) => {
|
| 12 |
+
e.preventDefault();
|
| 13 |
+
setError('');
|
| 14 |
+
setLoading(true);
|
| 15 |
+
|
| 16 |
+
try {
|
| 17 |
+
const res = await fetch(`/api/v1/auth/${mode}`, {
|
| 18 |
+
method: 'POST',
|
| 19 |
+
headers: { 'Content-Type': 'application/json' },
|
| 20 |
+
body: JSON.stringify({ username: username.trim(), password }),
|
| 21 |
+
});
|
| 22 |
+
const data = await res.json();
|
| 23 |
+
|
| 24 |
+
if (!res.ok) {
|
| 25 |
+
// FastAPI validation errors come back as { detail: [...] }
|
| 26 |
+
const msg = Array.isArray(data.detail)
|
| 27 |
+
? data.detail[0]?.msg ?? 'Invalid input'
|
| 28 |
+
: data.detail ?? 'Something went wrong';
|
| 29 |
+
setError(msg);
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
localStorage.setItem('soma_token', data.access_token);
|
| 34 |
+
localStorage.setItem('soma_username', data.username);
|
| 35 |
+
onAuth(data.username);
|
| 36 |
+
} catch {
|
| 37 |
+
setError('Could not reach the server. Is it running?');
|
| 38 |
+
} finally {
|
| 39 |
+
setLoading(false);
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div className="auth-overlay">
|
| 45 |
+
<div className="auth-card">
|
| 46 |
+
|
| 47 |
+
{/* Logo */}
|
| 48 |
+
<div className="auth-logo">
|
| 49 |
+
<div className="auth-orb">
|
| 50 |
+
<div className="auth-orb-ring r1" />
|
| 51 |
+
<div className="auth-orb-ring r2" />
|
| 52 |
+
<div className="auth-orb-ring r3" />
|
| 53 |
+
<div className="auth-orb-core" />
|
| 54 |
+
</div>
|
| 55 |
+
<span className="auth-logo-name">SOMA</span>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<h1 className="auth-title">
|
| 59 |
+
{mode === 'login' ? 'Welcome back' : 'Create your brain'}
|
| 60 |
+
</h1>
|
| 61 |
+
<p className="auth-sub">
|
| 62 |
+
{mode === 'login'
|
| 63 |
+
? 'Sign in to access your personal neural space.'
|
| 64 |
+
: 'Your conversations, memories, and knowledge graph — private to you.'}
|
| 65 |
+
</p>
|
| 66 |
+
|
| 67 |
+
<form className="auth-form" onSubmit={handleSubmit}>
|
| 68 |
+
<div className="auth-field">
|
| 69 |
+
<label className="auth-label">Username</label>
|
| 70 |
+
<input
|
| 71 |
+
className="auth-input"
|
| 72 |
+
type="text"
|
| 73 |
+
value={username}
|
| 74 |
+
onChange={e => setUsername(e.target.value)}
|
| 75 |
+
placeholder="e.g. komal"
|
| 76 |
+
autoFocus
|
| 77 |
+
autoComplete="username"
|
| 78 |
+
required
|
| 79 |
+
/>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<div className="auth-field">
|
| 83 |
+
<label className="auth-label">Password</label>
|
| 84 |
+
<input
|
| 85 |
+
className="auth-input"
|
| 86 |
+
type="password"
|
| 87 |
+
value={password}
|
| 88 |
+
onChange={e => setPassword(e.target.value)}
|
| 89 |
+
placeholder="Min. 6 characters"
|
| 90 |
+
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
| 91 |
+
required
|
| 92 |
+
/>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{error && <p className="auth-error">{error}</p>}
|
| 96 |
+
|
| 97 |
+
<button className="auth-submit" type="submit" disabled={loading}>
|
| 98 |
+
{loading ? 'Please wait…' : mode === 'login' ? 'Sign In' : 'Create Account'}
|
| 99 |
+
</button>
|
| 100 |
+
</form>
|
| 101 |
+
|
| 102 |
+
<p className="auth-toggle">
|
| 103 |
+
{mode === 'login' ? "Don't have an account? " : 'Already have an account? '}
|
| 104 |
+
<button
|
| 105 |
+
className="auth-toggle-btn"
|
| 106 |
+
onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); }}
|
| 107 |
+
>
|
| 108 |
+
{mode === 'login' ? 'Sign Up' : 'Sign In'}
|
| 109 |
+
</button>
|
| 110 |
+
</p>
|
| 111 |
+
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
export default AuthScreen;
|
requirements.txt
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
fastapi
|
| 2 |
uvicorn
|
|
|
|
|
|
|
| 3 |
langgraph
|
| 4 |
langchain
|
| 5 |
langchain-groq
|
|
|
|
| 1 |
fastapi
|
| 2 |
uvicorn
|
| 3 |
+
python-jose[cryptography]
|
| 4 |
+
passlib[bcrypt]
|
| 5 |
langgraph
|
| 6 |
langchain
|
| 7 |
langchain-groq
|