feat: dashboard UI overhaul + auth flow + auto-company-load
Browse files- Glassmorphic design system (dark theme, teal accents)
- Sidebar + TopBar layout replacing legacy nav
- Dashboard auto-loads company from sessionStorage
- Login/Register pages with elevated auth UI
- Onboarding wizard with multi-step flow
- Compile pipeline viewer with progress tracking
- Skills explorer with grid + detail panel
- Query agent split-pane demo view
- Backend: Supabase auth, JWT, chunking pipeline
- Protected backend.env from git tracking
- .gitignore +2 -0
- backend/auth/__init__.py +0 -0
- backend/auth/jwt.py +46 -0
- backend/chunking/__init__.py +2 -0
- backend/chunking/chunkers.py +301 -0
- backend/chunking/registry.py +68 -0
- backend/db/schema.sql +3 -0
- backend/db/supabase.py +104 -0
- backend/graph/graph.py +7 -61
- backend/graph/nodes/chunk_documents.py +41 -0
- backend/graph/nodes/detect_contradictions.py +15 -1
- backend/graph/nodes/extract_decisions.py +15 -1
- backend/graph/nodes/extract_exceptions.py +15 -1
- backend/graph/nodes/extract_workflows.py +15 -1
- backend/graph/nodes/ingest_join.py +0 -29
- backend/graph/nodes/ingest_notion.py +0 -60
- backend/graph/nodes/ingest_slack.py +0 -50
- backend/graph/nodes/ingest_tickets.py +0 -59
- backend/graph/nodes/load_sources.py +3 -14
- backend/graph/nodes/write_brain.py +17 -13
- backend/graph/state.py +0 -4
- backend/llm.py +1 -1
- backend/main.py +259 -8
- backend/models/schemas.py +56 -0
- frontend/src/app/compile/[jobId]/page.tsx +172 -97
- frontend/src/app/demo/[companyId]/page.tsx +75 -227
- frontend/src/app/globals.css +504 -11
- frontend/src/app/layout.tsx +8 -3
- frontend/src/app/login/page.tsx +139 -0
- frontend/src/app/onboarding/page.tsx +224 -0
- frontend/src/app/page.tsx +311 -59
- frontend/src/app/register/page.tsx +155 -0
- frontend/src/app/skills/[companyId]/page.tsx +251 -84
- frontend/src/components/DashboardLayout.tsx +31 -0
- frontend/src/components/NavBar.tsx +50 -0
- frontend/src/components/Sidebar.tsx +135 -0
- frontend/src/components/TopBar.tsx +92 -0
- frontend/src/components/ui/ConfidenceBadge.tsx +50 -0
- frontend/src/components/ui/GlassCard.tsx +54 -0
- frontend/src/components/ui/StatCard.tsx +59 -0
- frontend/src/lib/api.ts +2 -0
- frontend/src/lib/auth.tsx +124 -0
.gitignore
CHANGED
|
@@ -56,3 +56,5 @@ backend/nul
|
|
| 56 |
backend_log.txt
|
| 57 |
*.log
|
| 58 |
|
|
|
|
|
|
|
|
|
| 56 |
backend_log.txt
|
| 57 |
*.log
|
| 58 |
|
| 59 |
+
|
| 60 |
+
backend.env
|
backend/auth/__init__.py
ADDED
|
File without changes
|
backend/auth/jwt.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import jwt
|
| 3 |
+
from fastapi import Header, HTTPException
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
|
| 9 |
+
SUPABASE_JWT_SECRET: str | None = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# Derive the JWT secret from the anon key (Supabase pattern)
|
| 13 |
+
# The JWT secret is base64-encoded and used to verify tokens signed by Supabase Auth
|
| 14 |
+
def _get_jwt_secret() -> str:
|
| 15 |
+
global SUPABASE_JWT_SECRET
|
| 16 |
+
if SUPABASE_JWT_SECRET:
|
| 17 |
+
return SUPABASE_JWT_SECRET
|
| 18 |
+
anon_key = os.getenv("SUPABASE_KEY", "")
|
| 19 |
+
if not anon_key:
|
| 20 |
+
raise RuntimeError("SUPABASE_KEY not configured")
|
| 21 |
+
SUPABASE_JWT_SECRET = anon_key
|
| 22 |
+
return SUPABASE_JWT_SECRET
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def verify_token(authorization: str = Header(None)) -> dict | None:
|
| 26 |
+
if not authorization or not authorization.startswith("Bearer "):
|
| 27 |
+
return None
|
| 28 |
+
token = authorization[7:]
|
| 29 |
+
try:
|
| 30 |
+
secret = _get_jwt_secret()
|
| 31 |
+
payload = jwt.decode(
|
| 32 |
+
token,
|
| 33 |
+
secret,
|
| 34 |
+
algorithms=["HS256"],
|
| 35 |
+
options={"verify_exp": True},
|
| 36 |
+
)
|
| 37 |
+
return payload
|
| 38 |
+
except jwt.PyJWTError:
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
async def require_auth(authorization: str = Header(None)) -> dict:
|
| 43 |
+
payload = await verify_token(authorization)
|
| 44 |
+
if not payload:
|
| 45 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 46 |
+
return payload
|
backend/chunking/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from backend.chunking.registry import detect_doc_type
|
| 2 |
+
from backend.chunking.chunkers import get_chunker, CHUNKERS
|
backend/chunking/chunkers.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import csv
|
| 3 |
+
import io
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
DEFAULT_CHUNK_SIZE = 2000
|
| 8 |
+
DEFAULT_OVERLAP = 200
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _estimate_tokens(text: str) -> int:
|
| 12 |
+
return len(text) // 4
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _recursive_split(
|
| 16 |
+
text: str, chunk_size: int = DEFAULT_CHUNK_SIZE, overlap: int = DEFAULT_OVERLAP
|
| 17 |
+
) -> list[str]:
|
| 18 |
+
separators = ["\n\n", "\n", ". ", " "]
|
| 19 |
+
chunks = []
|
| 20 |
+
start = 0
|
| 21 |
+
|
| 22 |
+
while start < len(text):
|
| 23 |
+
end = min(start + chunk_size * 4, len(text))
|
| 24 |
+
if end < len(text):
|
| 25 |
+
best_sep = -1
|
| 26 |
+
for sep in separators:
|
| 27 |
+
pos = text.rfind(sep, start, end)
|
| 28 |
+
if pos > best_sep:
|
| 29 |
+
best_sep = pos
|
| 30 |
+
if best_sep > start:
|
| 31 |
+
end = best_sep + len(sep) if best_sep >= 0 else end
|
| 32 |
+
|
| 33 |
+
chunk = text[start:end].strip()
|
| 34 |
+
if chunk:
|
| 35 |
+
chunks.append(chunk)
|
| 36 |
+
start = end - overlap * 4 if end < len(text) else len(text)
|
| 37 |
+
|
| 38 |
+
return chunks if chunks else [text.strip()]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def chunk_markdown(
|
| 42 |
+
content: str, filename: str, chunk_size: int = DEFAULT_CHUNK_SIZE
|
| 43 |
+
) -> list[dict]:
|
| 44 |
+
lines = content.split("\n")
|
| 45 |
+
sections = []
|
| 46 |
+
current_header = "Introduction"
|
| 47 |
+
current_body = []
|
| 48 |
+
current_level = 0
|
| 49 |
+
|
| 50 |
+
for line in lines:
|
| 51 |
+
header_match = re.match(r"^(#{1,6})\s+(.+)$", line)
|
| 52 |
+
if header_match:
|
| 53 |
+
if current_body:
|
| 54 |
+
sections.append((current_header, "\n".join(current_body).strip()))
|
| 55 |
+
current_level = len(header_match.group(1))
|
| 56 |
+
current_header = header_match.group(2).strip()
|
| 57 |
+
current_body = []
|
| 58 |
+
else:
|
| 59 |
+
current_body.append(line)
|
| 60 |
+
|
| 61 |
+
if current_body:
|
| 62 |
+
sections.append((current_header, "\n".join(current_body).strip()))
|
| 63 |
+
|
| 64 |
+
chunks = []
|
| 65 |
+
for i, (header, body) in enumerate(sections):
|
| 66 |
+
if not body:
|
| 67 |
+
continue
|
| 68 |
+
text = f"[{header}] {body}"
|
| 69 |
+
if _estimate_tokens(text) > chunk_size:
|
| 70 |
+
sub_chunks = _recursive_split(body, chunk_size)
|
| 71 |
+
for j, sub in enumerate(sub_chunks):
|
| 72 |
+
chunks.append(
|
| 73 |
+
{
|
| 74 |
+
"text": f"[{header}] {sub}",
|
| 75 |
+
"source_file": filename,
|
| 76 |
+
"chunk_index": i * 1000 + j,
|
| 77 |
+
"doc_type": "markdown",
|
| 78 |
+
"section_header": header,
|
| 79 |
+
}
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
chunks.append(
|
| 83 |
+
{
|
| 84 |
+
"text": text,
|
| 85 |
+
"source_file": filename,
|
| 86 |
+
"chunk_index": i,
|
| 87 |
+
"doc_type": "markdown",
|
| 88 |
+
"section_header": header,
|
| 89 |
+
}
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
return chunks
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def chunk_json_array(
|
| 96 |
+
content: str, filename: str, chunk_size: int = DEFAULT_CHUNK_SIZE
|
| 97 |
+
) -> list[dict]:
|
| 98 |
+
try:
|
| 99 |
+
data = json.loads(content)
|
| 100 |
+
except json.JSONDecodeError:
|
| 101 |
+
return [
|
| 102 |
+
{
|
| 103 |
+
"text": content,
|
| 104 |
+
"source_file": filename,
|
| 105 |
+
"chunk_index": 0,
|
| 106 |
+
"doc_type": "json_array",
|
| 107 |
+
}
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
if not isinstance(data, list):
|
| 111 |
+
text = json.dumps(data, indent=2)
|
| 112 |
+
return [
|
| 113 |
+
{
|
| 114 |
+
"text": text,
|
| 115 |
+
"source_file": filename,
|
| 116 |
+
"chunk_index": 0,
|
| 117 |
+
"doc_type": "json_object",
|
| 118 |
+
}
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
chunks = []
|
| 122 |
+
for i, item in enumerate(data):
|
| 123 |
+
if isinstance(item, dict):
|
| 124 |
+
parts = []
|
| 125 |
+
for key in (
|
| 126 |
+
"text",
|
| 127 |
+
"message",
|
| 128 |
+
"content",
|
| 129 |
+
"subject",
|
| 130 |
+
"description",
|
| 131 |
+
"resolution",
|
| 132 |
+
"body",
|
| 133 |
+
):
|
| 134 |
+
if item.get(key):
|
| 135 |
+
parts.append(f"{key}: {item[key]}")
|
| 136 |
+
for key in (
|
| 137 |
+
"user",
|
| 138 |
+
"author",
|
| 139 |
+
"channel",
|
| 140 |
+
"priority",
|
| 141 |
+
"customer_plan",
|
| 142 |
+
"status",
|
| 143 |
+
):
|
| 144 |
+
if item.get(key):
|
| 145 |
+
parts.append(f"{key}: {item[key]}")
|
| 146 |
+
text = " | ".join(parts)
|
| 147 |
+
if not text:
|
| 148 |
+
text = json.dumps(item)
|
| 149 |
+
elif isinstance(item, str):
|
| 150 |
+
text = item
|
| 151 |
+
else:
|
| 152 |
+
text = json.dumps(item)
|
| 153 |
+
|
| 154 |
+
if text:
|
| 155 |
+
chunks.append(
|
| 156 |
+
{
|
| 157 |
+
"text": text,
|
| 158 |
+
"source_file": filename,
|
| 159 |
+
"chunk_index": i,
|
| 160 |
+
"doc_type": "json_array",
|
| 161 |
+
}
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
return chunks
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def chunk_csv(
|
| 168 |
+
content: str, filename: str, chunk_size: int = DEFAULT_CHUNK_SIZE
|
| 169 |
+
) -> list[dict]:
|
| 170 |
+
reader = csv.DictReader(io.StringIO(content))
|
| 171 |
+
if reader.fieldnames is None:
|
| 172 |
+
return [
|
| 173 |
+
{
|
| 174 |
+
"text": content,
|
| 175 |
+
"source_file": filename,
|
| 176 |
+
"chunk_index": 0,
|
| 177 |
+
"doc_type": "csv",
|
| 178 |
+
}
|
| 179 |
+
]
|
| 180 |
+
|
| 181 |
+
headers = reader.fieldnames
|
| 182 |
+
rows = list(reader)
|
| 183 |
+
if not rows:
|
| 184 |
+
return []
|
| 185 |
+
|
| 186 |
+
chunks = []
|
| 187 |
+
batch = []
|
| 188 |
+
batch_text = ""
|
| 189 |
+
|
| 190 |
+
for i, row in enumerate(rows):
|
| 191 |
+
row_parts = [f"{k}: {v}" for k, v in row.items() if v]
|
| 192 |
+
row_str = " | ".join(row_parts)
|
| 193 |
+
if _estimate_tokens(batch_text + "\n" + row_str) > chunk_size and batch:
|
| 194 |
+
chunks.append(
|
| 195 |
+
{
|
| 196 |
+
"text": batch_text,
|
| 197 |
+
"source_file": filename,
|
| 198 |
+
"chunk_index": len(chunks),
|
| 199 |
+
"doc_type": "csv",
|
| 200 |
+
}
|
| 201 |
+
)
|
| 202 |
+
batch = [row]
|
| 203 |
+
batch_text = row_str
|
| 204 |
+
else:
|
| 205 |
+
if batch_text:
|
| 206 |
+
batch_text += "\n"
|
| 207 |
+
batch_text += row_str
|
| 208 |
+
batch.append(row)
|
| 209 |
+
|
| 210 |
+
if batch:
|
| 211 |
+
chunks.append(
|
| 212 |
+
{
|
| 213 |
+
"text": batch_text,
|
| 214 |
+
"source_file": filename,
|
| 215 |
+
"chunk_index": len(chunks),
|
| 216 |
+
"doc_type": "csv",
|
| 217 |
+
}
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
return chunks
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def chunk_html(
|
| 224 |
+
content: str, filename: str, chunk_size: int = DEFAULT_CHUNK_SIZE
|
| 225 |
+
) -> list[dict]:
|
| 226 |
+
text = re.sub(r"<style[^>]*>.*?</style>", "", content, flags=re.DOTALL)
|
| 227 |
+
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
|
| 228 |
+
text = re.sub(r"<[^>]+>", " ", text)
|
| 229 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 230 |
+
|
| 231 |
+
sections = re.split(r"\n\s*(?=(?:##|###|####|h[1-6]))", text)
|
| 232 |
+
chunks = []
|
| 233 |
+
for i, section in enumerate(sections):
|
| 234 |
+
section = section.strip()
|
| 235 |
+
if not section:
|
| 236 |
+
continue
|
| 237 |
+
if _estimate_tokens(section) > chunk_size:
|
| 238 |
+
subs = _recursive_split(section, chunk_size)
|
| 239 |
+
for j, sub in enumerate(subs):
|
| 240 |
+
chunks.append(
|
| 241 |
+
{
|
| 242 |
+
"text": sub,
|
| 243 |
+
"source_file": filename,
|
| 244 |
+
"chunk_index": i * 1000 + j,
|
| 245 |
+
"doc_type": "html",
|
| 246 |
+
}
|
| 247 |
+
)
|
| 248 |
+
else:
|
| 249 |
+
chunks.append(
|
| 250 |
+
{
|
| 251 |
+
"text": section,
|
| 252 |
+
"source_file": filename,
|
| 253 |
+
"chunk_index": i,
|
| 254 |
+
"doc_type": "html",
|
| 255 |
+
}
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
return (
|
| 259 |
+
chunks
|
| 260 |
+
if chunks
|
| 261 |
+
else [
|
| 262 |
+
{
|
| 263 |
+
"text": text[: chunk_size * 4],
|
| 264 |
+
"source_file": filename,
|
| 265 |
+
"chunk_index": 0,
|
| 266 |
+
"doc_type": "html",
|
| 267 |
+
}
|
| 268 |
+
]
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def chunk_plain_text(
|
| 273 |
+
content: str,
|
| 274 |
+
filename: str,
|
| 275 |
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
| 276 |
+
overlap: int = DEFAULT_OVERLAP,
|
| 277 |
+
) -> list[dict]:
|
| 278 |
+
parts = _recursive_split(content, chunk_size, overlap)
|
| 279 |
+
return [
|
| 280 |
+
{
|
| 281 |
+
"text": part,
|
| 282 |
+
"source_file": filename,
|
| 283 |
+
"chunk_index": i,
|
| 284 |
+
"doc_type": "plain_text",
|
| 285 |
+
}
|
| 286 |
+
for i, part in enumerate(parts)
|
| 287 |
+
]
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
CHUNKERS = {
|
| 291 |
+
"markdown": chunk_markdown,
|
| 292 |
+
"json_array": chunk_json_array,
|
| 293 |
+
"json_object": chunk_json_array,
|
| 294 |
+
"csv": chunk_csv,
|
| 295 |
+
"html": chunk_html,
|
| 296 |
+
"plain_text": chunk_plain_text,
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def get_chunker(doc_type: str):
|
| 301 |
+
return CHUNKERS.get(doc_type, chunk_plain_text)
|
backend/chunking/registry.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def _detect_by_content(content: str) -> str | None:
|
| 5 |
+
stripped = content.strip()
|
| 6 |
+
if not stripped:
|
| 7 |
+
return None
|
| 8 |
+
|
| 9 |
+
if stripped.startswith("<!DOCTYPE html") or stripped.startswith("<html"):
|
| 10 |
+
return "html"
|
| 11 |
+
|
| 12 |
+
if stripped.startswith("|") or stripped.startswith("|---"):
|
| 13 |
+
return "markdown"
|
| 14 |
+
|
| 15 |
+
lines = [l for l in stripped.split("\n") if l.strip()]
|
| 16 |
+
if lines:
|
| 17 |
+
header_count = sum(1 for l in lines[:20] if l.startswith("#"))
|
| 18 |
+
if header_count >= 2 or (lines and lines[0].startswith("#")):
|
| 19 |
+
return "markdown"
|
| 20 |
+
|
| 21 |
+
if stripped.startswith("{") or stripped.startswith("["):
|
| 22 |
+
try:
|
| 23 |
+
parsed = json.loads(stripped)
|
| 24 |
+
if isinstance(parsed, list):
|
| 25 |
+
return "json_array"
|
| 26 |
+
if isinstance(parsed, dict):
|
| 27 |
+
return "json_object"
|
| 28 |
+
except json.JSONDecodeError:
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
if "," in stripped and "\n" in stripped[:500]:
|
| 32 |
+
first_line = stripped.split("\n")[0]
|
| 33 |
+
if "," in first_line and len(first_line.split(",")) >= 2:
|
| 34 |
+
return "csv"
|
| 35 |
+
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _detect_by_extension(filename: str) -> str | None:
|
| 40 |
+
fn = filename.lower()
|
| 41 |
+
ext_map = {
|
| 42 |
+
".md": "markdown",
|
| 43 |
+
".markdown": "markdown",
|
| 44 |
+
".json": "json_array",
|
| 45 |
+
".csv": "csv",
|
| 46 |
+
".tsv": "csv",
|
| 47 |
+
".html": "html",
|
| 48 |
+
".htm": "html",
|
| 49 |
+
".txt": "plain_text",
|
| 50 |
+
".log": "plain_text",
|
| 51 |
+
".yaml": "plain_text",
|
| 52 |
+
".yml": "plain_text",
|
| 53 |
+
".xml": "plain_text",
|
| 54 |
+
}
|
| 55 |
+
for ext, dtype in ext_map.items():
|
| 56 |
+
if fn.endswith(ext):
|
| 57 |
+
return dtype
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def detect_doc_type(filename: str, content: str) -> str:
|
| 62 |
+
detected = _detect_by_content(content)
|
| 63 |
+
if detected:
|
| 64 |
+
return detected
|
| 65 |
+
detected = _detect_by_extension(filename)
|
| 66 |
+
if detected:
|
| 67 |
+
return detected
|
| 68 |
+
return "plain_text"
|
backend/db/schema.sql
CHANGED
|
@@ -3,6 +3,9 @@
|
|
| 3 |
CREATE TABLE companies (
|
| 4 |
id TEXT PRIMARY KEY,
|
| 5 |
name TEXT NOT NULL,
|
|
|
|
|
|
|
|
|
|
| 6 |
created_at TIMESTAMPTZ DEFAULT now()
|
| 7 |
);
|
| 8 |
|
|
|
|
| 3 |
CREATE TABLE companies (
|
| 4 |
id TEXT PRIMARY KEY,
|
| 5 |
name TEXT NOT NULL,
|
| 6 |
+
industry TEXT,
|
| 7 |
+
company_size TEXT,
|
| 8 |
+
description TEXT,
|
| 9 |
created_at TIMESTAMPTZ DEFAULT now()
|
| 10 |
);
|
| 11 |
|
backend/db/supabase.py
CHANGED
|
@@ -99,3 +99,107 @@ def get_brain_by_version(company_id: str, version: str):
|
|
| 99 |
if res.data:
|
| 100 |
return res.data[0]
|
| 101 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
if res.data:
|
| 100 |
return res.data[0]
|
| 101 |
return None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ─────────────────────────────────────────────
|
| 105 |
+
# Phase 3 — Company CRUD
|
| 106 |
+
# ─────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def get_company(company_id: str):
|
| 110 |
+
if not supabase:
|
| 111 |
+
return None
|
| 112 |
+
res = supabase.table("companies").select("*").eq("id", company_id).execute()
|
| 113 |
+
return res.data[0] if res.data else None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def upsert_company(company_id: str, data: dict):
|
| 117 |
+
if not supabase:
|
| 118 |
+
return None
|
| 119 |
+
existing = get_company(company_id)
|
| 120 |
+
if existing:
|
| 121 |
+
data.pop("id", None)
|
| 122 |
+
data.pop("created_at", None)
|
| 123 |
+
res = supabase.table("companies").update(data).eq("id", company_id).execute()
|
| 124 |
+
else:
|
| 125 |
+
data.setdefault("id", company_id)
|
| 126 |
+
res = supabase.table("companies").insert(data).execute()
|
| 127 |
+
return res.data[0] if res.data else None
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def get_company_stats(company_id: str):
|
| 131 |
+
if not supabase:
|
| 132 |
+
return {"skill_count": 0, "source_count": 0, "last_compile": None}
|
| 133 |
+
skill_res = (
|
| 134 |
+
supabase.table("skills")
|
| 135 |
+
.select("id", count="exact")
|
| 136 |
+
.eq("company_id", company_id)
|
| 137 |
+
.eq("stale", False)
|
| 138 |
+
.execute()
|
| 139 |
+
)
|
| 140 |
+
source_res = (
|
| 141 |
+
supabase.table("source_files")
|
| 142 |
+
.select("id", count="exact")
|
| 143 |
+
.eq("company_id", company_id)
|
| 144 |
+
.execute()
|
| 145 |
+
)
|
| 146 |
+
compile_res = (
|
| 147 |
+
supabase.table("compile_runs")
|
| 148 |
+
.select("completed_at, result_version")
|
| 149 |
+
.eq("company_id", company_id)
|
| 150 |
+
.eq("status", "complete")
|
| 151 |
+
.order("completed_at", desc=True)
|
| 152 |
+
.limit(1)
|
| 153 |
+
.execute()
|
| 154 |
+
)
|
| 155 |
+
return {
|
| 156 |
+
"skill_count": getattr(skill_res, "count", 0) or len(skill_res.data or []),
|
| 157 |
+
"source_count": getattr(source_res, "count", 0) or len(source_res.data or []),
|
| 158 |
+
"last_compile": compile_res.data[0] if compile_res.data else None,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ─────────────────────────────────────────────
|
| 163 |
+
# Phase 4 — Skills Marketplace
|
| 164 |
+
# ─────────────────────────────────────────────
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def import_skills_file(
|
| 168 |
+
company_id: str, skills: list, version: str, source_label: str
|
| 169 |
+
) -> dict | None:
|
| 170 |
+
if not supabase:
|
| 171 |
+
return None
|
| 172 |
+
supabase.table("skills_files").update({"is_current": False}).eq(
|
| 173 |
+
"company_id", company_id
|
| 174 |
+
).eq("is_current", True).execute()
|
| 175 |
+
brain_json = {
|
| 176 |
+
"skills": skills,
|
| 177 |
+
"meta": {"imported": True, "source": source_label, "version": version},
|
| 178 |
+
}
|
| 179 |
+
skills_file = {
|
| 180 |
+
"company_id": company_id,
|
| 181 |
+
"version": version,
|
| 182 |
+
"brain_json": brain_json,
|
| 183 |
+
"source_hashes": {},
|
| 184 |
+
"is_current": True,
|
| 185 |
+
}
|
| 186 |
+
skills_file_res = supabase.table("skills_files").insert(skills_file).execute()
|
| 187 |
+
if not skills_file_res.data:
|
| 188 |
+
return None
|
| 189 |
+
sf = skills_file_res.data[0]
|
| 190 |
+
skill_rows = [
|
| 191 |
+
{
|
| 192 |
+
"id": s.get("id", f"imported_{i}"),
|
| 193 |
+
"company_id": company_id,
|
| 194 |
+
"skills_file_id": sf["id"],
|
| 195 |
+
"name": s.get("rule", "")[:255],
|
| 196 |
+
"domain": s.get("category", "Unknown"),
|
| 197 |
+
"version": version,
|
| 198 |
+
"confidence": s.get("confidence", 0.5),
|
| 199 |
+
"skill_json": s,
|
| 200 |
+
}
|
| 201 |
+
for i, s in enumerate(skills)
|
| 202 |
+
]
|
| 203 |
+
if skill_rows:
|
| 204 |
+
supabase.table("skills").insert(skill_rows).execute()
|
| 205 |
+
return sf
|
backend/graph/graph.py
CHANGED
|
@@ -2,10 +2,7 @@ from langgraph.graph import StateGraph, END
|
|
| 2 |
from langgraph.types import Send
|
| 3 |
from backend.graph.state import BrainState
|
| 4 |
from backend.graph.nodes.load_sources import load_sources
|
| 5 |
-
from backend.graph.nodes.
|
| 6 |
-
from backend.graph.nodes.ingest_slack import ingest_slack
|
| 7 |
-
from backend.graph.nodes.ingest_tickets import ingest_tickets
|
| 8 |
-
from backend.graph.nodes.ingest_join import ingest_join
|
| 9 |
from backend.graph.nodes.extract_decisions import extract_decisions
|
| 10 |
from backend.graph.nodes.extract_workflows import extract_workflows
|
| 11 |
from backend.graph.nodes.extract_exceptions import extract_exceptions
|
|
@@ -16,27 +13,7 @@ from backend.graph.nodes.score_confidence import score_confidence
|
|
| 16 |
from backend.graph.nodes.write_brain import write_brain
|
| 17 |
|
| 18 |
|
| 19 |
-
def route_to_ingestion(state: BrainState) -> list[Send]:
|
| 20 |
-
"""Fan-out: dispatch source files to type-specific ingestion nodes."""
|
| 21 |
-
sends = []
|
| 22 |
-
for f in state.get("source_files", []):
|
| 23 |
-
dt = f.get("doc_type", "unknown")
|
| 24 |
-
payload = {
|
| 25 |
-
"company_id": state["company_id"],
|
| 26 |
-
"job_id": state["job_id"],
|
| 27 |
-
"source_files": [f],
|
| 28 |
-
}
|
| 29 |
-
if dt == "notion_md":
|
| 30 |
-
sends.append(Send("ingest_notion", payload))
|
| 31 |
-
elif dt == "slack_json":
|
| 32 |
-
sends.append(Send("ingest_slack", payload))
|
| 33 |
-
elif dt == "tickets_json":
|
| 34 |
-
sends.append(Send("ingest_tickets", payload))
|
| 35 |
-
return sends
|
| 36 |
-
|
| 37 |
-
|
| 38 |
def route_to_extraction(state: BrainState) -> list[Send]:
|
| 39 |
-
"""Fan-out: dispatch all chunks to 4 parallel extraction agents."""
|
| 40 |
return [
|
| 41 |
Send("extract_decisions", dict(state)),
|
| 42 |
Send("extract_workflows", dict(state)),
|
|
@@ -47,59 +24,30 @@ def route_to_extraction(state: BrainState) -> list[Send]:
|
|
| 47 |
|
| 48 |
def build_compilation_graph() -> StateGraph:
|
| 49 |
"""
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
→ route_to_ingestion (Send fan-out)
|
| 54 |
-
→ [ingest_notion, ingest_slack, ingest_tickets] (parallel)
|
| 55 |
-
→ ingest_join (barrier)
|
| 56 |
-
→ route_to_extraction (Send fan-out)
|
| 57 |
-
→ [extract_decisions, extract_workflows, extract_exceptions, detect_contradictions] (parallel)
|
| 58 |
-
→ synthesize_skills → link_evidence → score_confidence → write_brain
|
| 59 |
"""
|
| 60 |
workflow = StateGraph(BrainState)
|
| 61 |
|
| 62 |
-
# --- Ingestion layer ---
|
| 63 |
workflow.add_node("load_sources", load_sources)
|
| 64 |
-
workflow.add_node("
|
| 65 |
-
workflow.add_node("ingest_slack", ingest_slack)
|
| 66 |
-
workflow.add_node("ingest_tickets", ingest_tickets)
|
| 67 |
-
workflow.add_node("ingest_join", ingest_join)
|
| 68 |
|
| 69 |
-
# --- Extraction layer ---
|
| 70 |
workflow.add_node("extract_decisions", extract_decisions)
|
| 71 |
workflow.add_node("extract_workflows", extract_workflows)
|
| 72 |
workflow.add_node("extract_exceptions", extract_exceptions)
|
| 73 |
workflow.add_node("detect_contradictions", detect_contradictions)
|
| 74 |
|
| 75 |
-
# --- Compilation layer ---
|
| 76 |
workflow.add_node("synthesize_skills", synthesize_skills)
|
| 77 |
workflow.add_node("link_evidence", link_evidence)
|
| 78 |
workflow.add_node("score_confidence", score_confidence)
|
| 79 |
workflow.add_node("write_brain", write_brain)
|
| 80 |
|
| 81 |
-
# --- Edges ---
|
| 82 |
workflow.set_entry_point("load_sources")
|
|
|
|
| 83 |
|
| 84 |
-
# load_sources fans out to 3 parallel ingest nodes
|
| 85 |
-
workflow.add_conditional_edges(
|
| 86 |
-
"load_sources",
|
| 87 |
-
route_to_ingestion,
|
| 88 |
-
[
|
| 89 |
-
"ingest_notion",
|
| 90 |
-
"ingest_slack",
|
| 91 |
-
"ingest_tickets",
|
| 92 |
-
],
|
| 93 |
-
)
|
| 94 |
-
|
| 95 |
-
# All 3 ingest nodes converge at the barrier join
|
| 96 |
-
workflow.add_edge("ingest_notion", "ingest_join")
|
| 97 |
-
workflow.add_edge("ingest_slack", "ingest_join")
|
| 98 |
-
workflow.add_edge("ingest_tickets", "ingest_join")
|
| 99 |
-
|
| 100 |
-
# ingest_join fans out to 4 parallel extraction agents
|
| 101 |
workflow.add_conditional_edges(
|
| 102 |
-
"
|
| 103 |
route_to_extraction,
|
| 104 |
[
|
| 105 |
"extract_decisions",
|
|
@@ -109,13 +57,11 @@ def build_compilation_graph() -> StateGraph:
|
|
| 109 |
],
|
| 110 |
)
|
| 111 |
|
| 112 |
-
# All 4 extraction agents converge at synthesize_skills
|
| 113 |
workflow.add_edge("extract_decisions", "synthesize_skills")
|
| 114 |
workflow.add_edge("extract_workflows", "synthesize_skills")
|
| 115 |
workflow.add_edge("extract_exceptions", "synthesize_skills")
|
| 116 |
workflow.add_edge("detect_contradictions", "synthesize_skills")
|
| 117 |
|
| 118 |
-
# Sequential compilation pipeline
|
| 119 |
workflow.add_edge("synthesize_skills", "link_evidence")
|
| 120 |
workflow.add_edge("link_evidence", "score_confidence")
|
| 121 |
workflow.add_edge("score_confidence", "write_brain")
|
|
|
|
| 2 |
from langgraph.types import Send
|
| 3 |
from backend.graph.state import BrainState
|
| 4 |
from backend.graph.nodes.load_sources import load_sources
|
| 5 |
+
from backend.graph.nodes.chunk_documents import chunk_documents
|
|
|
|
|
|
|
|
|
|
| 6 |
from backend.graph.nodes.extract_decisions import extract_decisions
|
| 7 |
from backend.graph.nodes.extract_workflows import extract_workflows
|
| 8 |
from backend.graph.nodes.extract_exceptions import extract_exceptions
|
|
|
|
| 13 |
from backend.graph.nodes.write_brain import write_brain
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def route_to_extraction(state: BrainState) -> list[Send]:
|
|
|
|
| 17 |
return [
|
| 18 |
Send("extract_decisions", dict(state)),
|
| 19 |
Send("extract_workflows", dict(state)),
|
|
|
|
| 24 |
|
| 25 |
def build_compilation_graph() -> StateGraph:
|
| 26 |
"""
|
| 27 |
+
load_sources → chunk_documents → route_to_extraction (Send fan-out)
|
| 28 |
+
→ [extract_decisions, extract_workflows, extract_exceptions, detect_contradictions] (parallel)
|
| 29 |
+
→ synthesize_skills → link_evidence → score_confidence → write_brain
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
"""
|
| 31 |
workflow = StateGraph(BrainState)
|
| 32 |
|
|
|
|
| 33 |
workflow.add_node("load_sources", load_sources)
|
| 34 |
+
workflow.add_node("chunk_documents", chunk_documents)
|
|
|
|
|
|
|
|
|
|
| 35 |
|
|
|
|
| 36 |
workflow.add_node("extract_decisions", extract_decisions)
|
| 37 |
workflow.add_node("extract_workflows", extract_workflows)
|
| 38 |
workflow.add_node("extract_exceptions", extract_exceptions)
|
| 39 |
workflow.add_node("detect_contradictions", detect_contradictions)
|
| 40 |
|
|
|
|
| 41 |
workflow.add_node("synthesize_skills", synthesize_skills)
|
| 42 |
workflow.add_node("link_evidence", link_evidence)
|
| 43 |
workflow.add_node("score_confidence", score_confidence)
|
| 44 |
workflow.add_node("write_brain", write_brain)
|
| 45 |
|
|
|
|
| 46 |
workflow.set_entry_point("load_sources")
|
| 47 |
+
workflow.add_edge("load_sources", "chunk_documents")
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
workflow.add_conditional_edges(
|
| 50 |
+
"chunk_documents",
|
| 51 |
route_to_extraction,
|
| 52 |
[
|
| 53 |
"extract_decisions",
|
|
|
|
| 57 |
],
|
| 58 |
)
|
| 59 |
|
|
|
|
| 60 |
workflow.add_edge("extract_decisions", "synthesize_skills")
|
| 61 |
workflow.add_edge("extract_workflows", "synthesize_skills")
|
| 62 |
workflow.add_edge("extract_exceptions", "synthesize_skills")
|
| 63 |
workflow.add_edge("detect_contradictions", "synthesize_skills")
|
| 64 |
|
|
|
|
| 65 |
workflow.add_edge("synthesize_skills", "link_evidence")
|
| 66 |
workflow.add_edge("link_evidence", "score_confidence")
|
| 67 |
workflow.add_edge("score_confidence", "write_brain")
|
backend/graph/nodes/chunk_documents.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from backend.graph.state import BrainState
|
| 2 |
+
from backend.chunking import get_chunker
|
| 3 |
+
from backend.sse import emit
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def chunk_documents(state: BrainState) -> dict:
|
| 7 |
+
job_id = state["job_id"]
|
| 8 |
+
source_files = state.get("source_files", [])
|
| 9 |
+
|
| 10 |
+
print(f"[{job_id}] Node chunk_documents: processing {len(source_files)} files")
|
| 11 |
+
await emit(
|
| 12 |
+
job_id,
|
| 13 |
+
"stage",
|
| 14 |
+
{
|
| 15 |
+
"name": "CHUNKING",
|
| 16 |
+
"detail": f"Chunking {len(source_files)} source files",
|
| 17 |
+
},
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
all_chunks = []
|
| 21 |
+
for sf in source_files:
|
| 22 |
+
doc_type = sf.get("doc_type", "plain_text")
|
| 23 |
+
filename = sf.get("filename", "unknown")
|
| 24 |
+
content = sf.get("content", "")
|
| 25 |
+
chunker = get_chunker(doc_type)
|
| 26 |
+
chunks = chunker(content, filename)
|
| 27 |
+
all_chunks.extend(chunks)
|
| 28 |
+
|
| 29 |
+
print(
|
| 30 |
+
f"[{job_id}] chunk_documents: produced {len(all_chunks)} chunks from {len(source_files)} files"
|
| 31 |
+
)
|
| 32 |
+
await emit(
|
| 33 |
+
job_id,
|
| 34 |
+
"stage",
|
| 35 |
+
{
|
| 36 |
+
"name": "CHUNKING_DONE",
|
| 37 |
+
"detail": f"Produced {len(all_chunks)} chunks from {len(source_files)} files",
|
| 38 |
+
},
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
return {"all_chunks": all_chunks}
|
backend/graph/nodes/detect_contradictions.py
CHANGED
|
@@ -2,6 +2,20 @@ from backend.graph.state import BrainState
|
|
| 2 |
from backend.llm import safe_llm_json_call
|
| 3 |
from backend.sse import emit
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
SYSTEM = """You are a contradiction detection specialist. Your ONLY job is to find CONTRADICTIONS, CONFLICTS, and INCONSISTENCIES across company communications.
|
| 7 |
|
|
@@ -34,7 +48,7 @@ async def detect_contradictions(state: BrainState) -> dict:
|
|
| 34 |
},
|
| 35 |
)
|
| 36 |
|
| 37 |
-
chunk_text =
|
| 38 |
user = f"Detect contradictions and conflicting instructions across this company data:\n\n{chunk_text}"
|
| 39 |
|
| 40 |
results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
|
|
|
|
| 2 |
from backend.llm import safe_llm_json_call
|
| 3 |
from backend.sse import emit
|
| 4 |
|
| 5 |
+
MAX_CHUNK_CHARS = 12000
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _cap_chunks(chunks: list[dict]) -> str:
|
| 9 |
+
parts = []
|
| 10 |
+
chars = 0
|
| 11 |
+
for c in chunks:
|
| 12 |
+
text = c.get("text", "")
|
| 13 |
+
if chars + len(text) > MAX_CHUNK_CHARS:
|
| 14 |
+
break
|
| 15 |
+
parts.append(text)
|
| 16 |
+
chars += len(text)
|
| 17 |
+
return "\n\n---\n\n".join(parts)
|
| 18 |
+
|
| 19 |
|
| 20 |
SYSTEM = """You are a contradiction detection specialist. Your ONLY job is to find CONTRADICTIONS, CONFLICTS, and INCONSISTENCIES across company communications.
|
| 21 |
|
|
|
|
| 48 |
},
|
| 49 |
)
|
| 50 |
|
| 51 |
+
chunk_text = _cap_chunks(chunks)
|
| 52 |
user = f"Detect contradictions and conflicting instructions across this company data:\n\n{chunk_text}"
|
| 53 |
|
| 54 |
results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
|
backend/graph/nodes/extract_decisions.py
CHANGED
|
@@ -2,6 +2,20 @@ from backend.graph.state import BrainState
|
|
| 2 |
from backend.llm import safe_llm_json_call
|
| 3 |
from backend.sse import emit
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
SYSTEM = """You are a policy extraction specialist. Your ONLY job is to extract DECISIONS, RULES, and POLICIES from company communications.
|
| 7 |
|
|
@@ -29,7 +43,7 @@ async def extract_decisions(state: BrainState) -> dict:
|
|
| 29 |
{"name": "EXTRACT_DECISIONS", "detail": "Extracting rules and policies..."},
|
| 30 |
)
|
| 31 |
|
| 32 |
-
chunk_text =
|
| 33 |
user = f"Extract all decisions, rules, and policies from this company data:\n\n{chunk_text}"
|
| 34 |
|
| 35 |
results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
|
|
|
|
| 2 |
from backend.llm import safe_llm_json_call
|
| 3 |
from backend.sse import emit
|
| 4 |
|
| 5 |
+
MAX_CHUNK_CHARS = 12000
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _cap_chunks(chunks: list[dict]) -> str:
|
| 9 |
+
parts = []
|
| 10 |
+
chars = 0
|
| 11 |
+
for c in chunks:
|
| 12 |
+
text = c.get("text", "")
|
| 13 |
+
if chars + len(text) > MAX_CHUNK_CHARS:
|
| 14 |
+
break
|
| 15 |
+
parts.append(text)
|
| 16 |
+
chars += len(text)
|
| 17 |
+
return "\n\n---\n\n".join(parts)
|
| 18 |
+
|
| 19 |
|
| 20 |
SYSTEM = """You are a policy extraction specialist. Your ONLY job is to extract DECISIONS, RULES, and POLICIES from company communications.
|
| 21 |
|
|
|
|
| 43 |
{"name": "EXTRACT_DECISIONS", "detail": "Extracting rules and policies..."},
|
| 44 |
)
|
| 45 |
|
| 46 |
+
chunk_text = _cap_chunks(chunks)
|
| 47 |
user = f"Extract all decisions, rules, and policies from this company data:\n\n{chunk_text}"
|
| 48 |
|
| 49 |
results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
|
backend/graph/nodes/extract_exceptions.py
CHANGED
|
@@ -2,6 +2,20 @@ from backend.graph.state import BrainState
|
|
| 2 |
from backend.llm import safe_llm_json_call
|
| 3 |
from backend.sse import emit
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
SYSTEM = """You are an exception extraction specialist. Your ONLY job is to extract EXCEPTIONS, EDGE CASES, CONSTRAINTS, CONDITIONAL RULES, and FORBIDDEN ACTIONS from company communications.
|
| 7 |
|
|
@@ -32,7 +46,7 @@ async def extract_exceptions(state: BrainState) -> dict:
|
|
| 32 |
},
|
| 33 |
)
|
| 34 |
|
| 35 |
-
chunk_text =
|
| 36 |
user = f"Extract all exceptions, edge cases, constraints, and forbidden actions from this company data:\n\n{chunk_text}"
|
| 37 |
|
| 38 |
results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
|
|
|
|
| 2 |
from backend.llm import safe_llm_json_call
|
| 3 |
from backend.sse import emit
|
| 4 |
|
| 5 |
+
MAX_CHUNK_CHARS = 12000
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _cap_chunks(chunks: list[dict]) -> str:
|
| 9 |
+
parts = []
|
| 10 |
+
chars = 0
|
| 11 |
+
for c in chunks:
|
| 12 |
+
text = c.get("text", "")
|
| 13 |
+
if chars + len(text) > MAX_CHUNK_CHARS:
|
| 14 |
+
break
|
| 15 |
+
parts.append(text)
|
| 16 |
+
chars += len(text)
|
| 17 |
+
return "\n\n---\n\n".join(parts)
|
| 18 |
+
|
| 19 |
|
| 20 |
SYSTEM = """You are an exception extraction specialist. Your ONLY job is to extract EXCEPTIONS, EDGE CASES, CONSTRAINTS, CONDITIONAL RULES, and FORBIDDEN ACTIONS from company communications.
|
| 21 |
|
|
|
|
| 46 |
},
|
| 47 |
)
|
| 48 |
|
| 49 |
+
chunk_text = _cap_chunks(chunks)
|
| 50 |
user = f"Extract all exceptions, edge cases, constraints, and forbidden actions from this company data:\n\n{chunk_text}"
|
| 51 |
|
| 52 |
results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
|
backend/graph/nodes/extract_workflows.py
CHANGED
|
@@ -2,6 +2,20 @@ from backend.graph.state import BrainState
|
|
| 2 |
from backend.llm import safe_llm_json_call
|
| 3 |
from backend.sse import emit
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
SYSTEM = """You are a workflow extraction specialist. Your ONLY job is to extract WORKFLOWS, PROCESSES, and SEQUENTIAL STEPS from company communications.
|
| 7 |
|
|
@@ -32,7 +46,7 @@ async def extract_workflows(state: BrainState) -> dict:
|
|
| 32 |
},
|
| 33 |
)
|
| 34 |
|
| 35 |
-
chunk_text =
|
| 36 |
user = f"Extract all workflows, processes, and step-by-step procedures from this company data:\n\n{chunk_text}"
|
| 37 |
|
| 38 |
results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
|
|
|
|
| 2 |
from backend.llm import safe_llm_json_call
|
| 3 |
from backend.sse import emit
|
| 4 |
|
| 5 |
+
MAX_CHUNK_CHARS = 12000
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _cap_chunks(chunks: list[dict]) -> str:
|
| 9 |
+
parts = []
|
| 10 |
+
chars = 0
|
| 11 |
+
for c in chunks:
|
| 12 |
+
text = c.get("text", "")
|
| 13 |
+
if chars + len(text) > MAX_CHUNK_CHARS:
|
| 14 |
+
break
|
| 15 |
+
parts.append(text)
|
| 16 |
+
chars += len(text)
|
| 17 |
+
return "\n\n---\n\n".join(parts)
|
| 18 |
+
|
| 19 |
|
| 20 |
SYSTEM = """You are a workflow extraction specialist. Your ONLY job is to extract WORKFLOWS, PROCESSES, and SEQUENTIAL STEPS from company communications.
|
| 21 |
|
|
|
|
| 46 |
},
|
| 47 |
)
|
| 48 |
|
| 49 |
+
chunk_text = _cap_chunks(chunks)
|
| 50 |
user = f"Extract all workflows, processes, and step-by-step procedures from this company data:\n\n{chunk_text}"
|
| 51 |
|
| 52 |
results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
|
backend/graph/nodes/ingest_join.py
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
from backend.graph.state import BrainState
|
| 2 |
-
from backend.sse import emit
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
async def ingest_join(state: BrainState) -> dict:
|
| 6 |
-
job_id = state["job_id"]
|
| 7 |
-
|
| 8 |
-
structured_sops = state.get("structured_sops", [])
|
| 9 |
-
normalized_events = state.get("normalized_events", [])
|
| 10 |
-
resolved_cases = state.get("resolved_cases", [])
|
| 11 |
-
|
| 12 |
-
all_chunks = []
|
| 13 |
-
all_chunks.extend(structured_sops)
|
| 14 |
-
all_chunks.extend(normalized_events)
|
| 15 |
-
all_chunks.extend(resolved_cases)
|
| 16 |
-
|
| 17 |
-
print(
|
| 18 |
-
f"[{job_id}] Node ingest_join: merged {len(structured_sops)} SOPs + {len(normalized_events)} events + {len(resolved_cases)} tickets = {len(all_chunks)} chunks"
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
-
await emit(
|
| 22 |
-
job_id,
|
| 23 |
-
"stage",
|
| 24 |
-
{
|
| 25 |
-
"name": "INGEST_JOIN",
|
| 26 |
-
"detail": f"Merged {len(all_chunks)} total chunks from all sources",
|
| 27 |
-
},
|
| 28 |
-
)
|
| 29 |
-
return {"all_chunks": all_chunks}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/graph/nodes/ingest_notion.py
DELETED
|
@@ -1,60 +0,0 @@
|
|
| 1 |
-
from backend.graph.state import BrainState
|
| 2 |
-
from backend.sse import emit
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
async def ingest_notion(state: BrainState) -> dict:
|
| 6 |
-
job_id = state["job_id"]
|
| 7 |
-
source_files = state.get("source_files", [])
|
| 8 |
-
|
| 9 |
-
notion_files = [f for f in source_files if f.get("doc_type") == "notion_md"]
|
| 10 |
-
print(f"[{job_id}] Node ingest_notion: {len(notion_files)} notion files")
|
| 11 |
-
|
| 12 |
-
structured_sops = []
|
| 13 |
-
for sf in notion_files:
|
| 14 |
-
chunks = _chunk_markdown(sf)
|
| 15 |
-
structured_sops.extend(chunks)
|
| 16 |
-
|
| 17 |
-
await emit(
|
| 18 |
-
job_id,
|
| 19 |
-
"stage",
|
| 20 |
-
{
|
| 21 |
-
"name": "INGEST_NOTION",
|
| 22 |
-
"detail": f"Processed {len(notion_files)} SOP files into {len(structured_sops)} chunks",
|
| 23 |
-
},
|
| 24 |
-
)
|
| 25 |
-
print(f"[{job_id}] ingest_notion finished: {len(structured_sops)} chunks")
|
| 26 |
-
return {"structured_sops": structured_sops}
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _chunk_markdown(sf: dict) -> list:
|
| 30 |
-
content = sf["content"]
|
| 31 |
-
sections = []
|
| 32 |
-
current_header = "Introduction"
|
| 33 |
-
current_body = []
|
| 34 |
-
|
| 35 |
-
for line in content.split("\n"):
|
| 36 |
-
if line.startswith("## "):
|
| 37 |
-
if current_body:
|
| 38 |
-
sections.append((current_header, "\n".join(current_body).strip()))
|
| 39 |
-
current_header = line.lstrip("# ").strip()
|
| 40 |
-
current_body = []
|
| 41 |
-
else:
|
| 42 |
-
current_body.append(line)
|
| 43 |
-
|
| 44 |
-
if current_body:
|
| 45 |
-
sections.append((current_header, "\n".join(current_body).strip()))
|
| 46 |
-
|
| 47 |
-
chunks = []
|
| 48 |
-
for i, (header, body) in enumerate(sections):
|
| 49 |
-
if not body:
|
| 50 |
-
continue
|
| 51 |
-
chunks.append(
|
| 52 |
-
{
|
| 53 |
-
"text": f"[{header}] {body}",
|
| 54 |
-
"source_file": sf["filename"],
|
| 55 |
-
"chunk_index": i,
|
| 56 |
-
"doc_type": "notion_md",
|
| 57 |
-
"section_header": header,
|
| 58 |
-
}
|
| 59 |
-
)
|
| 60 |
-
return chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/graph/nodes/ingest_slack.py
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
from backend.graph.state import BrainState
|
| 3 |
-
from backend.sse import emit
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
async def ingest_slack(state: BrainState) -> dict:
|
| 7 |
-
job_id = state["job_id"]
|
| 8 |
-
source_files = state.get("source_files", [])
|
| 9 |
-
|
| 10 |
-
slack_files = [f for f in source_files if f.get("doc_type") == "slack_json"]
|
| 11 |
-
print(f"[{job_id}] Node ingest_slack: {len(slack_files)} slack files")
|
| 12 |
-
|
| 13 |
-
normalized_events = []
|
| 14 |
-
for sf in slack_files:
|
| 15 |
-
chunks = _chunk_slack(sf)
|
| 16 |
-
normalized_events.extend(chunks)
|
| 17 |
-
|
| 18 |
-
await emit(
|
| 19 |
-
job_id,
|
| 20 |
-
"stage",
|
| 21 |
-
{
|
| 22 |
-
"name": "INGEST_SLACK",
|
| 23 |
-
"detail": f"Processed {len(slack_files)} Slack exports into {len(normalized_events)} messages",
|
| 24 |
-
},
|
| 25 |
-
)
|
| 26 |
-
print(f"[{job_id}] ingest_slack finished: {len(normalized_events)} messages")
|
| 27 |
-
return {"normalized_events": normalized_events}
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def _chunk_slack(sf: dict) -> list:
|
| 31 |
-
try:
|
| 32 |
-
messages = json.loads(sf["content"])
|
| 33 |
-
except json.JSONDecodeError:
|
| 34 |
-
return []
|
| 35 |
-
chunks = []
|
| 36 |
-
for i, msg in enumerate(messages):
|
| 37 |
-
text = msg.get("text", "")
|
| 38 |
-
if not text:
|
| 39 |
-
continue
|
| 40 |
-
user = msg.get("user", "unknown")
|
| 41 |
-
channel = msg.get("channel", "unknown")
|
| 42 |
-
chunks.append(
|
| 43 |
-
{
|
| 44 |
-
"text": f"[Slack #{channel} @{user}] {text}",
|
| 45 |
-
"source_file": sf["filename"],
|
| 46 |
-
"chunk_index": i,
|
| 47 |
-
"doc_type": "slack_json",
|
| 48 |
-
}
|
| 49 |
-
)
|
| 50 |
-
return chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/graph/nodes/ingest_tickets.py
DELETED
|
@@ -1,59 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
from backend.graph.state import BrainState
|
| 3 |
-
from backend.sse import emit
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
async def ingest_tickets(state: BrainState) -> dict:
|
| 7 |
-
job_id = state["job_id"]
|
| 8 |
-
source_files = state.get("source_files", [])
|
| 9 |
-
|
| 10 |
-
ticket_files = [f for f in source_files if f.get("doc_type") == "tickets_json"]
|
| 11 |
-
print(f"[{job_id}] Node ingest_tickets: {len(ticket_files)} ticket files")
|
| 12 |
-
|
| 13 |
-
resolved_cases = []
|
| 14 |
-
for sf in ticket_files:
|
| 15 |
-
chunks = _chunk_tickets(sf)
|
| 16 |
-
resolved_cases.extend(chunks)
|
| 17 |
-
|
| 18 |
-
await emit(
|
| 19 |
-
job_id,
|
| 20 |
-
"stage",
|
| 21 |
-
{
|
| 22 |
-
"name": "INGEST_TICKETS",
|
| 23 |
-
"detail": f"Processed {len(ticket_files)} ticket files into {len(resolved_cases)} cases",
|
| 24 |
-
},
|
| 25 |
-
)
|
| 26 |
-
print(f"[{job_id}] ingest_tickets finished: {len(resolved_cases)} tickets")
|
| 27 |
-
return {"resolved_cases": resolved_cases}
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def _chunk_tickets(sf: dict) -> list:
|
| 31 |
-
try:
|
| 32 |
-
tickets = json.loads(sf["content"])
|
| 33 |
-
except json.JSONDecodeError:
|
| 34 |
-
return []
|
| 35 |
-
chunks = []
|
| 36 |
-
for i, tkt in enumerate(tickets):
|
| 37 |
-
parts = []
|
| 38 |
-
if tkt.get("subject"):
|
| 39 |
-
parts.append(f"Subject: {tkt['subject']}")
|
| 40 |
-
if tkt.get("description"):
|
| 41 |
-
parts.append(f"Description: {tkt['description']}")
|
| 42 |
-
if tkt.get("resolution"):
|
| 43 |
-
parts.append(f"Resolution: {tkt['resolution']}")
|
| 44 |
-
if tkt.get("priority"):
|
| 45 |
-
parts.append(f"Priority: {tkt['priority']}")
|
| 46 |
-
if tkt.get("customer_plan"):
|
| 47 |
-
parts.append(f"Plan: {tkt['customer_plan']}")
|
| 48 |
-
text = " | ".join(parts)
|
| 49 |
-
if not text:
|
| 50 |
-
continue
|
| 51 |
-
chunks.append(
|
| 52 |
-
{
|
| 53 |
-
"text": f"[Zendesk Ticket] {text}",
|
| 54 |
-
"source_file": sf["filename"],
|
| 55 |
-
"chunk_index": i,
|
| 56 |
-
"doc_type": "tickets_json",
|
| 57 |
-
}
|
| 58 |
-
)
|
| 59 |
-
return chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/graph/nodes/load_sources.py
CHANGED
|
@@ -1,22 +1,10 @@
|
|
| 1 |
import os
|
| 2 |
import hashlib
|
| 3 |
from backend.graph.state import BrainState
|
|
|
|
| 4 |
from backend.sse import emit
|
| 5 |
|
| 6 |
|
| 7 |
-
def _detect_type(filename: str) -> str:
|
| 8 |
-
fn = filename.lower()
|
| 9 |
-
if fn.endswith(".json"):
|
| 10 |
-
if "slack" in fn:
|
| 11 |
-
return "slack_json"
|
| 12 |
-
if "ticket" in fn or "zendesk" in fn:
|
| 13 |
-
return "tickets_json"
|
| 14 |
-
return "json"
|
| 15 |
-
if fn.endswith(".md"):
|
| 16 |
-
return "notion_md"
|
| 17 |
-
return "unknown"
|
| 18 |
-
|
| 19 |
-
|
| 20 |
async def load_sources(state: BrainState) -> dict:
|
| 21 |
company_id = state["company_id"]
|
| 22 |
job_id = state["job_id"]
|
|
@@ -49,12 +37,13 @@ async def load_sources(state: BrainState) -> dict:
|
|
| 49 |
continue
|
| 50 |
with open(filepath, "r", encoding="utf-8") as f:
|
| 51 |
content = f.read()
|
|
|
|
| 52 |
source_files.append(
|
| 53 |
{
|
| 54 |
"filename": filename,
|
| 55 |
"content": content,
|
| 56 |
"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
| 57 |
-
"doc_type":
|
| 58 |
}
|
| 59 |
)
|
| 60 |
|
|
|
|
| 1 |
import os
|
| 2 |
import hashlib
|
| 3 |
from backend.graph.state import BrainState
|
| 4 |
+
from backend.chunking import detect_doc_type
|
| 5 |
from backend.sse import emit
|
| 6 |
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
async def load_sources(state: BrainState) -> dict:
|
| 9 |
company_id = state["company_id"]
|
| 10 |
job_id = state["job_id"]
|
|
|
|
| 37 |
continue
|
| 38 |
with open(filepath, "r", encoding="utf-8") as f:
|
| 39 |
content = f.read()
|
| 40 |
+
doc_type = detect_doc_type(filename, content)
|
| 41 |
source_files.append(
|
| 42 |
{
|
| 43 |
"filename": filename,
|
| 44 |
"content": content,
|
| 45 |
"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
| 46 |
+
"doc_type": doc_type,
|
| 47 |
}
|
| 48 |
)
|
| 49 |
|
backend/graph/nodes/write_brain.py
CHANGED
|
@@ -4,7 +4,7 @@ import uuid
|
|
| 4 |
import datetime
|
| 5 |
from backend.graph.state import BrainState
|
| 6 |
from backend.db.supabase import get_client
|
| 7 |
-
from backend.llm import
|
| 8 |
from backend.sse import emit
|
| 9 |
|
| 10 |
|
|
@@ -27,19 +27,20 @@ async def write_brain(state: BrainState) -> dict:
|
|
| 27 |
},
|
| 28 |
)
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
skill["embedding_vector"] = emb
|
| 35 |
-
skills_with_embeddings.append(skill)
|
| 36 |
|
| 37 |
skills_file = {
|
| 38 |
-
"skills":
|
| 39 |
"meta": {
|
| 40 |
"company_id": company_id,
|
| 41 |
"compiled_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
| 42 |
-
"total_skills": len(
|
| 43 |
"duration_ms": duration_ms,
|
| 44 |
},
|
| 45 |
}
|
|
@@ -82,9 +83,10 @@ async def write_brain(state: BrainState) -> dict:
|
|
| 82 |
|
| 83 |
sf_id = sf_res.data[0]["id"]
|
| 84 |
|
| 85 |
-
|
|
|
|
| 86 |
skill_copy = {k: v for k, v in skill.items() if k != "embedding_vector"}
|
| 87 |
-
|
| 88 |
{
|
| 89 |
"id": skill.get("id", str(uuid.uuid4())[:8]),
|
| 90 |
"company_id": company_id,
|
|
@@ -95,7 +97,9 @@ async def write_brain(state: BrainState) -> dict:
|
|
| 95 |
"confidence": float(skill.get("confidence", 0.5)),
|
| 96 |
"skill_json": skill_copy,
|
| 97 |
}
|
| 98 |
-
)
|
|
|
|
|
|
|
| 99 |
|
| 100 |
db.table("compile_runs").update(
|
| 101 |
{
|
|
@@ -116,7 +120,7 @@ async def write_brain(state: BrainState) -> dict:
|
|
| 116 |
"stage",
|
| 117 |
{
|
| 118 |
"name": "DONE",
|
| 119 |
-
"detail": f"Brain {version_str} written: {len(
|
| 120 |
},
|
| 121 |
)
|
| 122 |
await emit(
|
|
@@ -125,7 +129,7 @@ async def write_brain(state: BrainState) -> dict:
|
|
| 125 |
{
|
| 126 |
"status": "success",
|
| 127 |
"version": version_str,
|
| 128 |
-
"skills_count": len(
|
| 129 |
"source_count": len(source_hashes),
|
| 130 |
"duration_ms": duration_ms,
|
| 131 |
},
|
|
|
|
| 4 |
import datetime
|
| 5 |
from backend.graph.state import BrainState
|
| 6 |
from backend.db.supabase import get_client
|
| 7 |
+
from backend.llm import get_embeddings
|
| 8 |
from backend.sse import emit
|
| 9 |
|
| 10 |
|
|
|
|
| 27 |
},
|
| 28 |
)
|
| 29 |
|
| 30 |
+
skill_texts = [
|
| 31 |
+
f"{s.get('category', '')} {s.get('rule', '')} {s.get('rationale', '')}"
|
| 32 |
+
for s in final_skills
|
| 33 |
+
]
|
| 34 |
+
embeddings = get_embeddings(skill_texts)
|
| 35 |
+
for skill, emb in zip(final_skills, embeddings):
|
| 36 |
skill["embedding_vector"] = emb
|
|
|
|
| 37 |
|
| 38 |
skills_file = {
|
| 39 |
+
"skills": final_skills,
|
| 40 |
"meta": {
|
| 41 |
"company_id": company_id,
|
| 42 |
"compiled_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
| 43 |
+
"total_skills": len(final_skills),
|
| 44 |
"duration_ms": duration_ms,
|
| 45 |
},
|
| 46 |
}
|
|
|
|
| 83 |
|
| 84 |
sf_id = sf_res.data[0]["id"]
|
| 85 |
|
| 86 |
+
skill_rows = []
|
| 87 |
+
for skill in final_skills:
|
| 88 |
skill_copy = {k: v for k, v in skill.items() if k != "embedding_vector"}
|
| 89 |
+
skill_rows.append(
|
| 90 |
{
|
| 91 |
"id": skill.get("id", str(uuid.uuid4())[:8]),
|
| 92 |
"company_id": company_id,
|
|
|
|
| 97 |
"confidence": float(skill.get("confidence", 0.5)),
|
| 98 |
"skill_json": skill_copy,
|
| 99 |
}
|
| 100 |
+
)
|
| 101 |
+
if skill_rows:
|
| 102 |
+
db.table("skills").insert(skill_rows).execute()
|
| 103 |
|
| 104 |
db.table("compile_runs").update(
|
| 105 |
{
|
|
|
|
| 120 |
"stage",
|
| 121 |
{
|
| 122 |
"name": "DONE",
|
| 123 |
+
"detail": f"Brain {version_str} written: {len(final_skills)} skills, {len(source_hashes)} sources, {duration_ms}ms",
|
| 124 |
},
|
| 125 |
)
|
| 126 |
await emit(
|
|
|
|
| 129 |
{
|
| 130 |
"status": "success",
|
| 131 |
"version": version_str,
|
| 132 |
+
"skills_count": len(final_skills),
|
| 133 |
"source_count": len(source_hashes),
|
| 134 |
"duration_ms": duration_ms,
|
| 135 |
},
|
backend/graph/state.py
CHANGED
|
@@ -7,10 +7,6 @@ class BrainState(TypedDict):
|
|
| 7 |
job_id: str
|
| 8 |
source_files: Annotated[List[Dict[str, Any]], operator.add]
|
| 9 |
|
| 10 |
-
structured_sops: Annotated[List[Dict[str, Any]], operator.add]
|
| 11 |
-
normalized_events: Annotated[List[Dict[str, Any]], operator.add]
|
| 12 |
-
resolved_cases: Annotated[List[Dict[str, Any]], operator.add]
|
| 13 |
-
|
| 14 |
all_chunks: List[Dict[str, Any]]
|
| 15 |
|
| 16 |
raw_decisions: Annotated[List[Dict[str, Any]], operator.add]
|
|
|
|
| 7 |
job_id: str
|
| 8 |
source_files: Annotated[List[Dict[str, Any]], operator.add]
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
all_chunks: List[Dict[str, Any]]
|
| 11 |
|
| 12 |
raw_decisions: Annotated[List[Dict[str, Any]], operator.add]
|
backend/llm.py
CHANGED
|
@@ -14,7 +14,7 @@ MODEL_NAME = "RedHatAI/Qwen2.5-72B-Instruct-FP8-dynamic"
|
|
| 14 |
llm = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="not-needed", timeout=120.0)
|
| 15 |
|
| 16 |
# --- Concurrency throttle for parallel extraction ---
|
| 17 |
-
_semaphore = asyncio.Semaphore(
|
| 18 |
|
| 19 |
# --- Embedding model (local, fast, centralized here) ---
|
| 20 |
_embedding_model = None
|
|
|
|
| 14 |
llm = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="not-needed", timeout=120.0)
|
| 15 |
|
| 16 |
# --- Concurrency throttle for parallel extraction ---
|
| 17 |
+
_semaphore = asyncio.Semaphore(8)
|
| 18 |
|
| 19 |
# --- Embedding model (local, fast, centralized here) ---
|
| 20 |
_embedding_model = None
|
backend/main.py
CHANGED
|
@@ -1,4 +1,12 @@
|
|
| 1 |
-
from fastapi import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from fastapi.responses import StreamingResponse
|
| 4 |
import os
|
|
@@ -14,9 +22,26 @@ import datetime
|
|
| 14 |
from backend.graph.graph import build_compilation_graph
|
| 15 |
from backend.sse import event_bus, emit
|
| 16 |
from backend.agent.brain_agent import handle_agent_query
|
| 17 |
-
from backend.db.supabase import
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
app = FastAPI(title="Kernl API", version="2.1.0")
|
| 22 |
|
|
@@ -132,9 +157,6 @@ async def run_compilation_graph(job_id: str, company_id: str):
|
|
| 132 |
"job_id": job_id,
|
| 133 |
"company_id": company_id,
|
| 134 |
"source_files": [],
|
| 135 |
-
"structured_sops": [],
|
| 136 |
-
"normalized_events": [],
|
| 137 |
-
"resolved_cases": [],
|
| 138 |
"all_chunks": [],
|
| 139 |
"raw_decisions": [],
|
| 140 |
"workflow_steps": [],
|
|
@@ -334,11 +356,240 @@ async def list_brain_versions(company_id: str):
|
|
| 334 |
return {"versions": versions, "company_id": company_id}
|
| 335 |
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
# ─────────────────────────────────────────────
|
| 338 |
# Semantic Diff Engine
|
| 339 |
# ─────────────────────────────────────────────
|
| 340 |
@app.get("/diff/{v1}/{v2}")
|
| 341 |
-
async def semantic_diff(v1: str, v2: str, company_id: str
|
| 342 |
db = get_client()
|
| 343 |
if not db:
|
| 344 |
raise HTTPException(status_code=500, detail="Database not connected")
|
|
|
|
| 1 |
+
from fastapi import (
|
| 2 |
+
FastAPI,
|
| 3 |
+
BackgroundTasks,
|
| 4 |
+
HTTPException,
|
| 5 |
+
UploadFile,
|
| 6 |
+
File,
|
| 7 |
+
Form,
|
| 8 |
+
Depends,
|
| 9 |
+
)
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from fastapi.responses import StreamingResponse
|
| 12 |
import os
|
|
|
|
| 22 |
from backend.graph.graph import build_compilation_graph
|
| 23 |
from backend.sse import event_bus, emit
|
| 24 |
from backend.agent.brain_agent import handle_agent_query
|
| 25 |
+
from backend.db.supabase import (
|
| 26 |
+
get_client,
|
| 27 |
+
get_brain_by_version,
|
| 28 |
+
get_company,
|
| 29 |
+
get_company_stats,
|
| 30 |
+
upsert_company,
|
| 31 |
+
import_skills_file,
|
| 32 |
+
)
|
| 33 |
+
from backend.llm import check_vllm_health, llm_call, safe_llm_json_call
|
| 34 |
+
from backend.models.schemas import (
|
| 35 |
+
CompileRequest,
|
| 36 |
+
AgentHandleRequest,
|
| 37 |
+
AgentQueryRequest,
|
| 38 |
+
OnboardingAnalysisRequest,
|
| 39 |
+
CompanyUpdate,
|
| 40 |
+
SkillsImportRequest,
|
| 41 |
+
AuthRegisterRequest,
|
| 42 |
+
AuthLoginRequest,
|
| 43 |
+
)
|
| 44 |
+
from backend.auth.jwt import verify_token, require_auth
|
| 45 |
|
| 46 |
app = FastAPI(title="Kernl API", version="2.1.0")
|
| 47 |
|
|
|
|
| 157 |
"job_id": job_id,
|
| 158 |
"company_id": company_id,
|
| 159 |
"source_files": [],
|
|
|
|
|
|
|
|
|
|
| 160 |
"all_chunks": [],
|
| 161 |
"raw_decisions": [],
|
| 162 |
"workflow_steps": [],
|
|
|
|
| 356 |
return {"versions": versions, "company_id": company_id}
|
| 357 |
|
| 358 |
|
| 359 |
+
# ─────────────────────────────────────────────
|
| 360 |
+
# Phase 3 — Multi-Company & Onboarding
|
| 361 |
+
# ─────────────────────────────────────────────
|
| 362 |
+
ONBOARDING_SYSTEM_PROMPT = """You are an organizational analyst. Analyze the provided company documents and suggest:
|
| 363 |
+
1. Industry — what sector this company operates in
|
| 364 |
+
2. Departments — which departments are present or implied (e.g., Support, Engineering, HR, Finance, Sales, Marketing, Operations)
|
| 365 |
+
3. Company size — estimate employee count range: "1-10", "11-50", "51-200", "201+"
|
| 366 |
+
|
| 367 |
+
Output ONLY a JSON object with these exact fields:
|
| 368 |
+
{
|
| 369 |
+
"industry": "string",
|
| 370 |
+
"departments": ["string"],
|
| 371 |
+
"size": "string",
|
| 372 |
+
"rationale": "string"
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
No preamble. No explanation. No markdown."""
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
@app.post("/onboarding/analyze")
|
| 379 |
+
async def onboarding_analyze(req: OnboardingAnalysisRequest):
|
| 380 |
+
src_dir = _company_sources_dir(req.company_id)
|
| 381 |
+
if not os.path.isdir(src_dir):
|
| 382 |
+
raise HTTPException(
|
| 383 |
+
status_code=404, detail=f"No sources found for {req.company_id}"
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
samples = []
|
| 387 |
+
for fn in sorted(os.listdir(src_dir)):
|
| 388 |
+
fp = os.path.join(src_dir, fn)
|
| 389 |
+
if not os.path.isfile(fp):
|
| 390 |
+
continue
|
| 391 |
+
with open(fp, "r", encoding="utf-8", errors="ignore") as f:
|
| 392 |
+
content = f.read(4000)
|
| 393 |
+
samples.append(f"--- {fn} ---\n{content}")
|
| 394 |
+
|
| 395 |
+
user_content = "Analyze these company documents:\n\n" + "\n\n".join(samples[:8])
|
| 396 |
+
|
| 397 |
+
try:
|
| 398 |
+
result = await safe_llm_json_call(
|
| 399 |
+
ONBOARDING_SYSTEM_PROMPT, user_content, max_tokens=1024
|
| 400 |
+
)
|
| 401 |
+
except Exception:
|
| 402 |
+
raise HTTPException(status_code=500, detail="Analysis failed — LLM unavailable")
|
| 403 |
+
|
| 404 |
+
if not isinstance(result, dict):
|
| 405 |
+
result = result[0] if isinstance(result, list) and result else {}
|
| 406 |
+
|
| 407 |
+
return {
|
| 408 |
+
"company_id": req.company_id,
|
| 409 |
+
"suggested_industry": result.get("industry", "Unknown"),
|
| 410 |
+
"suggested_departments": result.get("departments", []),
|
| 411 |
+
"suggested_size": result.get("size", "Unknown"),
|
| 412 |
+
"rationale": result.get("rationale", ""),
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
@app.get("/companies/{company_id}")
|
| 417 |
+
async def get_company_detail(company_id: str):
|
| 418 |
+
db = get_client()
|
| 419 |
+
company = get_company(company_id) if db else None
|
| 420 |
+
if not company:
|
| 421 |
+
raise HTTPException(status_code=404, detail="Company not found")
|
| 422 |
+
stats = get_company_stats(company_id) if db else {}
|
| 423 |
+
return {**company, **stats}
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
@app.patch("/companies/{company_id}")
|
| 427 |
+
async def update_company(company_id: str, update: CompanyUpdate):
|
| 428 |
+
db = get_client()
|
| 429 |
+
if not db:
|
| 430 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 431 |
+
payload = update.model_dump(exclude_none=True)
|
| 432 |
+
if not payload:
|
| 433 |
+
raise HTTPException(status_code=400, detail="No fields to update")
|
| 434 |
+
try:
|
| 435 |
+
result = upsert_company(company_id, payload)
|
| 436 |
+
except Exception as e:
|
| 437 |
+
err_msg = str(e)
|
| 438 |
+
if "Could not find" in err_msg or "does not exist" in err_msg:
|
| 439 |
+
raise HTTPException(
|
| 440 |
+
status_code=400,
|
| 441 |
+
detail=f"Database schema needs migration. GET /migrations/pending for SQL to run in Supabase dashboard. Error: {err_msg}",
|
| 442 |
+
)
|
| 443 |
+
raise HTTPException(
|
| 444 |
+
status_code=500, detail=f"Failed to update company: {err_msg}"
|
| 445 |
+
)
|
| 446 |
+
if not result:
|
| 447 |
+
raise HTTPException(status_code=500, detail="Failed to update company")
|
| 448 |
+
stats = get_company_stats(company_id)
|
| 449 |
+
return {**result, **stats}
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
@app.post("/companies/{company_id}/load-samples")
|
| 453 |
+
async def load_sample_sources(company_id: str):
|
| 454 |
+
"""Clone template playbooks from rivanly-inc to a new company directory."""
|
| 455 |
+
template_dir = _company_sources_dir("rivanly-inc")
|
| 456 |
+
if not os.path.isdir(template_dir):
|
| 457 |
+
raise HTTPException(
|
| 458 |
+
status_code=404,
|
| 459 |
+
detail="Template sources not found. Ensure data/sources/rivanly-inc/ exists.",
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
target_dir = _company_sources_dir(company_id)
|
| 463 |
+
os.makedirs(target_dir, exist_ok=True)
|
| 464 |
+
|
| 465 |
+
copied = []
|
| 466 |
+
db = get_client()
|
| 467 |
+
for fn in sorted(os.listdir(template_dir)):
|
| 468 |
+
src = os.path.join(template_dir, fn)
|
| 469 |
+
if not os.path.isfile(src):
|
| 470 |
+
continue
|
| 471 |
+
dst = os.path.join(target_dir, fn)
|
| 472 |
+
shutil.copy2(src, dst)
|
| 473 |
+
copied.append(fn)
|
| 474 |
+
|
| 475 |
+
# Record in DB
|
| 476 |
+
if db:
|
| 477 |
+
try:
|
| 478 |
+
with open(dst, "rb") as f:
|
| 479 |
+
file_hash = hashlib.sha256(f.read()).hexdigest()
|
| 480 |
+
db.table("source_files").insert(
|
| 481 |
+
{
|
| 482 |
+
"company_id": company_id,
|
| 483 |
+
"filename": fn,
|
| 484 |
+
"sha256": file_hash,
|
| 485 |
+
"storage_path": f"data/sources/{company_id}/{fn}",
|
| 486 |
+
}
|
| 487 |
+
).execute()
|
| 488 |
+
except Exception as e:
|
| 489 |
+
print(f"[load-samples] DB record error for {fn}: {e}")
|
| 490 |
+
|
| 491 |
+
# Ensure company exists in DB
|
| 492 |
+
if db:
|
| 493 |
+
try:
|
| 494 |
+
upsert_company(
|
| 495 |
+
company_id, {"name": company_id.replace("-", " ").title()}
|
| 496 |
+
)
|
| 497 |
+
except Exception as e:
|
| 498 |
+
print(f"[load-samples] Company upsert error: {e}")
|
| 499 |
+
|
| 500 |
+
return {"status": "loaded", "files": copied, "count": len(copied)}
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
# ─────────────────────────────────────────────
|
| 505 |
+
# Phase 4 — Skills Marketplace
|
| 506 |
+
# ─────────────────────────────────────────────
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
@app.get("/skills/{company_id}/download")
|
| 510 |
+
async def download_skills(company_id: str):
|
| 511 |
+
db = get_client()
|
| 512 |
+
if not db:
|
| 513 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 514 |
+
res = (
|
| 515 |
+
db.table("skills_files")
|
| 516 |
+
.select("*")
|
| 517 |
+
.eq("company_id", company_id)
|
| 518 |
+
.eq("is_current", True)
|
| 519 |
+
.execute()
|
| 520 |
+
)
|
| 521 |
+
if not res.data:
|
| 522 |
+
raise HTTPException(
|
| 523 |
+
status_code=404, detail="No skills file found for this company"
|
| 524 |
+
)
|
| 525 |
+
brain = res.data[0]
|
| 526 |
+
return StreamingResponse(
|
| 527 |
+
iter([json.dumps(brain["brain_json"], indent=2)]),
|
| 528 |
+
media_type="application/json",
|
| 529 |
+
headers={
|
| 530 |
+
"Content-Disposition": f'attachment; filename="skills_{company_id}_{brain["version"]}.json"'
|
| 531 |
+
},
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
@app.post("/skills/import")
|
| 536 |
+
async def import_skills(req: SkillsImportRequest):
|
| 537 |
+
db = get_client()
|
| 538 |
+
if not db:
|
| 539 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 540 |
+
if not req.skills:
|
| 541 |
+
raise HTTPException(status_code=400, detail="No skills provided in payload")
|
| 542 |
+
skills_file = import_skills_file(
|
| 543 |
+
req.company_id, req.skills, req.version, req.source_label
|
| 544 |
+
)
|
| 545 |
+
if not skills_file:
|
| 546 |
+
raise HTTPException(status_code=500, detail="Failed to import skills")
|
| 547 |
+
return {
|
| 548 |
+
"status": "imported",
|
| 549 |
+
"company_id": req.company_id,
|
| 550 |
+
"version": req.version,
|
| 551 |
+
"skill_count": len(req.skills),
|
| 552 |
+
"skills_file_id": skills_file["id"],
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
# ─────────────────────────────────────────────
|
| 557 |
+
# Phase 6 — Auth
|
| 558 |
+
# ─────────────────────────────────────────────
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
@app.get("/auth/config")
|
| 562 |
+
async def auth_config():
|
| 563 |
+
return {
|
| 564 |
+
"supabase_url": os.getenv("SUPABASE_URL", ""),
|
| 565 |
+
"supabase_anon_key": os.getenv("SUPABASE_KEY", ""),
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
@app.get("/auth/me")
|
| 570 |
+
async def auth_me(user: dict = Depends(require_auth)):
|
| 571 |
+
return {"user": user}
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
@app.get("/migrations/pending")
|
| 575 |
+
async def migrations_pending():
|
| 576 |
+
"""Return SQL statements that need to be run in Supabase dashboard."""
|
| 577 |
+
return {
|
| 578 |
+
"database": os.getenv("SUPABASE_URL", ""),
|
| 579 |
+
"sql": [
|
| 580 |
+
"ALTER TABLE companies ADD COLUMN IF NOT EXISTS industry TEXT;",
|
| 581 |
+
"ALTER TABLE companies ADD COLUMN IF NOT EXISTS company_size TEXT;",
|
| 582 |
+
"ALTER TABLE companies ADD COLUMN IF NOT EXISTS description TEXT;",
|
| 583 |
+
],
|
| 584 |
+
"instructions": "Run these SQL statements in your Supabase dashboard SQL editor at https://supabase.com/dashboard/project/csxswinhxuziyssuuxzx/sql/new",
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
|
| 588 |
# ─────────────────────────────────────────────
|
| 589 |
# Semantic Diff Engine
|
| 590 |
# ─────────────────────────────────────────────
|
| 591 |
@app.get("/diff/{v1}/{v2}")
|
| 592 |
+
async def semantic_diff(v1: str, v2: str, company_id: str):
|
| 593 |
db = get_client()
|
| 594 |
if not db:
|
| 595 |
raise HTTPException(status_code=500, detail="Database not connected")
|
backend/models/schemas.py
CHANGED
|
@@ -57,3 +57,59 @@ class DiffResponse(BaseModel):
|
|
| 57 |
deleted: List[DiffItem] = []
|
| 58 |
modified: List[DiffModified] = []
|
| 59 |
confidence_shifts: List[DiffConfidenceShift] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
deleted: List[DiffItem] = []
|
| 58 |
modified: List[DiffModified] = []
|
| 59 |
confidence_shifts: List[DiffConfidenceShift] = []
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ─────────────────────────────────────────────
|
| 63 |
+
# Phase 3 — Multi-Company & Onboarding
|
| 64 |
+
# ─────────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class OnboardingAnalysisRequest(BaseModel):
|
| 68 |
+
company_id: str
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class OnboardingSuggestion(BaseModel):
|
| 72 |
+
company_id: str
|
| 73 |
+
suggested_industry: str
|
| 74 |
+
suggested_departments: List[str]
|
| 75 |
+
suggested_size: str
|
| 76 |
+
rationale: str
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class CompanyUpdate(BaseModel):
|
| 80 |
+
name: Optional[str] = None
|
| 81 |
+
industry: Optional[str] = None
|
| 82 |
+
company_size: Optional[str] = None
|
| 83 |
+
description: Optional[str] = None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ─────────────────────────────────────────────
|
| 87 |
+
# Phase 6 — Auth
|
| 88 |
+
# ─────────────────────────────────────────────
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class AuthRegisterRequest(BaseModel):
|
| 92 |
+
email: str
|
| 93 |
+
password: str
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class AuthLoginRequest(BaseModel):
|
| 97 |
+
email: str
|
| 98 |
+
password: str
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class AuthResponse(BaseModel):
|
| 102 |
+
access_token: str
|
| 103 |
+
user: Dict[str, Any]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ─────────────────────────────────────────────
|
| 107 |
+
# Phase 4 — Skills Marketplace
|
| 108 |
+
# ─────────────────────────────────────────────
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class SkillsImportRequest(BaseModel):
|
| 112 |
+
company_id: str
|
| 113 |
+
version: str = "imported"
|
| 114 |
+
skills: List[Dict[str, Any]]
|
| 115 |
+
source_label: str = "marketplace_import"
|
frontend/src/app/compile/[jobId]/page.tsx
CHANGED
|
@@ -1,40 +1,37 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useEffect, useState, use } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
interface LogEvent {
|
| 7 |
-
timestamp: string;
|
| 8 |
-
type: string;
|
| 9 |
-
data: any;
|
| 10 |
-
}
|
| 11 |
|
| 12 |
-
const
|
| 13 |
-
pipeline_start:
|
| 14 |
-
LOADING_DOCS:
|
| 15 |
-
LOADING_DOCS_DONE:
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
pipeline_complete: "🎉 Compilation Finished",
|
| 37 |
-
pipeline_error: "❌ Pipeline Error",
|
| 38 |
};
|
| 39 |
|
| 40 |
export default function CompileViewer({ params }: { params: Promise<{ jobId: string }> }) {
|
|
@@ -42,86 +39,164 @@ export default function CompileViewer({ params }: { params: Promise<{ jobId: str
|
|
| 42 |
const jobId = resolvedParams.jobId;
|
| 43 |
const [logs, setLogs] = useState<LogEvent[]>([]);
|
| 44 |
const [status, setStatus] = useState("Connecting...");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
const router = useRouter();
|
| 46 |
|
| 47 |
useEffect(() => {
|
| 48 |
if (!jobId) return;
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
eventSource.onmessage = (event) => {
|
| 53 |
const parsed = JSON.parse(event.data);
|
| 54 |
-
const
|
| 55 |
-
const
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
setStatus(`${label}${detail ? ` — ${detail}` : ""}`);
|
| 68 |
-
} else if (eventType === "pipeline_start") {
|
| 69 |
-
setStatus(STAGE_LABELS.pipeline_start);
|
| 70 |
-
} else if (eventType === "pipeline_complete") {
|
| 71 |
-
setStatus(STAGE_LABELS.pipeline_complete);
|
| 72 |
-
eventSource.close();
|
| 73 |
-
} else if (eventType === "pipeline_error") {
|
| 74 |
-
setStatus(`❌ Error: ${eventData.error || "Unknown"}`);
|
| 75 |
-
eventSource.close();
|
| 76 |
-
}
|
| 77 |
};
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
-
eventSource.close();
|
| 81 |
-
};
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
|
| 86 |
return (
|
| 87 |
-
<
|
| 88 |
-
<div className="
|
| 89 |
-
<
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
className=
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
? "border-red-500 text-red-500"
|
| 97 |
-
: "border-primary text-primary animate-pulse"
|
| 98 |
-
}`}
|
| 99 |
-
>
|
| 100 |
-
{status}
|
| 101 |
</span>
|
| 102 |
-
<button onClick={() => router.push("/")} className="text-text-secondary hover:text-foreground">
|
| 103 |
-
Back
|
| 104 |
-
</button>
|
| 105 |
</div>
|
| 106 |
-
</div>
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
<span className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
</div>
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</div>
|
| 125 |
-
</
|
| 126 |
);
|
| 127 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useEffect, useState, use, useRef } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
+
import { API_BASE } from "@/lib/api";
|
| 6 |
+
import DashboardLayout from "@/components/DashboardLayout";
|
| 7 |
+
import GlassCard from "@/components/ui/GlassCard";
|
| 8 |
|
| 9 |
+
interface LogEvent { timestamp: string; type: string; data: Record<string, unknown>; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
const STAGES: Record<string, { label: string; icon: string }> = {
|
| 12 |
+
pipeline_start: { label: "Pipeline Started", icon: "▶" },
|
| 13 |
+
LOADING_DOCS: { label: "Loading Documents", icon: "◈" },
|
| 14 |
+
LOADING_DOCS_DONE: { label: "Sources Loaded", icon: "✓" },
|
| 15 |
+
CHUNKING: { label: "Chunking Documents", icon: "◈" },
|
| 16 |
+
CHUNKING_DONE: { label: "Documents Chunked", icon: "✓" },
|
| 17 |
+
EXTRACT_DECISIONS: { label: "Extracting Rules", icon: "◈" },
|
| 18 |
+
EXTRACT_DECISIONS_DONE: { label: "Rules Extracted", icon: "✓" },
|
| 19 |
+
EXTRACT_WORKFLOWS: { label: "Extracting Workflows", icon: "◈" },
|
| 20 |
+
EXTRACT_WORKFLOWS_DONE: { label: "Workflows Extracted", icon: "✓" },
|
| 21 |
+
EXTRACT_EXCEPTIONS: { label: "Extracting Exceptions", icon: "◈" },
|
| 22 |
+
EXTRACT_EXCEPTIONS_DONE: { label: "Exceptions Extracted", icon: "✓" },
|
| 23 |
+
DETECT_CONTRADICTIONS: { label: "Detecting Contradictions", icon: "◈" },
|
| 24 |
+
DETECT_CONTRADICTIONS_DONE: { label: "Contradictions Analyzed", icon: "✓" },
|
| 25 |
+
SYNTHESIZING_SKILLS: { label: "Synthesizing Skills", icon: "◈" },
|
| 26 |
+
SYNTHESIZING_DONE: { label: "Skills Synthesized", icon: "✓" },
|
| 27 |
+
LINKING_EVIDENCE: { label: "Linking Evidence", icon: "◈" },
|
| 28 |
+
LINKING_DONE: { label: "Evidence Linked", icon: "✓" },
|
| 29 |
+
SCORING_CONFIDENCE: { label: "Scoring Confidence", icon: "◈" },
|
| 30 |
+
SCORING_DONE: { label: "Confidence Scored", icon: "✓" },
|
| 31 |
+
WRITING_DB: { label: "Writing to Database", icon: "◈" },
|
| 32 |
+
DONE: { label: "Pipeline Complete", icon: "✓" },
|
| 33 |
+
pipeline_complete: { label: "Compilation Finished", icon: "✓" },
|
| 34 |
+
pipeline_error: { label: "Pipeline Error", icon: "✕" },
|
|
|
|
|
|
|
| 35 |
};
|
| 36 |
|
| 37 |
export default function CompileViewer({ params }: { params: Promise<{ jobId: string }> }) {
|
|
|
|
| 39 |
const jobId = resolvedParams.jobId;
|
| 40 |
const [logs, setLogs] = useState<LogEvent[]>([]);
|
| 41 |
const [status, setStatus] = useState("Connecting...");
|
| 42 |
+
const [companyId, setCompanyId] = useState<string | null>(null);
|
| 43 |
+
const [pipelineDone, setPipelineDone] = useState(false);
|
| 44 |
+
const [pipelineError, setPipelineError] = useState<string | null>(null);
|
| 45 |
+
const [currentStage, setCurrentStage] = useState<string | null>(null);
|
| 46 |
+
const logRef = useRef<HTMLDivElement>(null);
|
| 47 |
const router = useRouter();
|
| 48 |
|
| 49 |
useEffect(() => {
|
| 50 |
if (!jobId) return;
|
| 51 |
+
const es = new EventSource(`${API_BASE}/compile/${jobId}/stream`);
|
| 52 |
+
es.onmessage = (event) => {
|
|
|
|
|
|
|
| 53 |
const parsed = JSON.parse(event.data);
|
| 54 |
+
const et = parsed.event;
|
| 55 |
+
const ed = parsed.data;
|
| 56 |
+
setLogs((prev) => [...prev, { timestamp: new Date().toLocaleTimeString(), type: et, data: ed }]);
|
| 57 |
+
if (et === "pipeline_start" && ed?.company_id) setCompanyId(ed.company_id as string);
|
| 58 |
+
if (et === "stage") {
|
| 59 |
+
const n = (ed.name as string) || "";
|
| 60 |
+
const s = STAGES[n];
|
| 61 |
+
const d = (ed.detail as string) || "";
|
| 62 |
+
setCurrentStage(n);
|
| 63 |
+
setStatus(`${s?.label || n}${d ? ` — ${d}` : ""}`);
|
| 64 |
+
} else if (et === "pipeline_start") { setStatus("Pipeline Started"); }
|
| 65 |
+
else if (et === "pipeline_complete") { setStatus("Compilation Finished"); setPipelineDone(true); es.close(); }
|
| 66 |
+
else if (et === "pipeline_error") { setStatus(`Error: ${(ed.error as string) || "Unknown"}`); setPipelineError((ed.error as string) || "Unknown error"); es.close(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
};
|
| 68 |
+
es.onerror = () => es.close();
|
| 69 |
+
return () => es.close();
|
| 70 |
+
}, [jobId]);
|
| 71 |
|
| 72 |
+
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [logs]);
|
|
|
|
|
|
|
| 73 |
|
| 74 |
+
const stageKeys = ["LOADING_DOCS", "CHUNKING", "EXTRACT_DECISIONS", "EXTRACT_WORKFLOWS", "EXTRACT_EXCEPTIONS", "DETECT_CONTRADICTIONS", "SYNTHESIZING_SKILLS", "LINKING_EVIDENCE", "SCORING_CONFIDENCE", "WRITING_DB"];
|
| 75 |
+
const completedStages = new Set(logs.filter(l => l.type === "stage").map(l => l.data?.name as string));
|
| 76 |
+
const currentIdx = currentStage ? stageKeys.findIndex(k => k === currentStage || k + "_DONE" === currentStage) : -1;
|
| 77 |
+
const progress = pipelineDone ? 100 : pipelineError ? 0 : Math.max(0, Math.round(((currentIdx + 1) / stageKeys.length) * 100));
|
| 78 |
|
| 79 |
return (
|
| 80 |
+
<DashboardLayout>
|
| 81 |
+
<div className="p-6 lg:p-8 max-w-6xl mx-auto animate-fade-in">
|
| 82 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
| 83 |
+
<div>
|
| 84 |
+
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>Compile Pipeline</h1>
|
| 85 |
+
<p className="text-sm mt-1 font-mono" style={{ color: "var(--text-muted)" }}>Job: {jobId}</p>
|
| 86 |
+
</div>
|
| 87 |
+
<span className={`badge ${pipelineDone ? "badge--success" : pipelineError ? "badge--error" : "badge--primary"}`}>
|
| 88 |
+
{pipelineDone ? "✓ Complete" : pipelineError ? "✕ Failed" : "● Running"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</span>
|
|
|
|
|
|
|
|
|
|
| 90 |
</div>
|
|
|
|
| 91 |
|
| 92 |
+
{/* Progress Bar */}
|
| 93 |
+
<div className="mb-6">
|
| 94 |
+
<div className="flex justify-between items-center mb-2">
|
| 95 |
+
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{status}</span>
|
| 96 |
+
<span className="text-xs font-mono font-bold" style={{ color: "var(--primary)" }}>{progress}%</span>
|
| 97 |
+
</div>
|
| 98 |
+
<div className="progress-bar"><div className="progress-bar__fill" style={{ width: `${progress}%`, background: pipelineError ? "var(--error)" : "var(--primary)" }} /></div>
|
| 99 |
+
</div>
|
| 100 |
|
| 101 |
+
{/* Success Card */}
|
| 102 |
+
{pipelineDone && (
|
| 103 |
+
<GlassCard className="mb-6" style={{ borderColor: "rgba(52,211,153,0.3)" } as React.CSSProperties}>
|
| 104 |
+
<div className="flex items-center gap-3 mb-4">
|
| 105 |
+
<span className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ background: "var(--success-bg)", color: "var(--success)" }}>
|
| 106 |
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 10l4 4 8-8" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
| 107 |
+
</span>
|
| 108 |
+
<div>
|
| 109 |
+
<h2 className="text-lg font-bold" style={{ color: "var(--success)" }}>Brain Compiled Successfully!</h2>
|
| 110 |
+
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Your operational brain is ready.{companyId && <> Company: <span className="font-mono" style={{ color: "var(--primary)" }}>{companyId}</span></>}</p>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
<div className="flex gap-3 flex-wrap">
|
| 114 |
+
{companyId && (<><button onClick={() => router.push(`/demo/${companyId}`)} className="btn-primary">Query Demo</button><button onClick={() => router.push(`/skills/${companyId}`)} className="btn-secondary">View Skills</button></>)}
|
| 115 |
+
<button onClick={() => router.push("/")} className="btn-ghost">Dashboard</button>
|
| 116 |
+
</div>
|
| 117 |
+
</GlassCard>
|
| 118 |
+
)}
|
| 119 |
+
|
| 120 |
+
{/* Error Card */}
|
| 121 |
+
{pipelineError && (
|
| 122 |
+
<GlassCard className="mb-6" style={{ borderColor: "rgba(248,113,113,0.3)" } as React.CSSProperties}>
|
| 123 |
+
<div className="flex items-center gap-3 mb-4">
|
| 124 |
+
<span className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ background: "var(--error-bg)", color: "var(--error)" }}>
|
| 125 |
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 6l8 8M14 6l-8 8" strokeLinecap="round" /></svg>
|
| 126 |
+
</span>
|
| 127 |
+
<div>
|
| 128 |
+
<h2 className="text-lg font-bold" style={{ color: "var(--error)" }}>Compilation Failed</h2>
|
| 129 |
+
<p className="text-sm" style={{ color: "var(--text-muted)" }}>{pipelineError}</p>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
<div className="flex gap-3">
|
| 133 |
+
<button onClick={() => window.location.reload()} className="btn-secondary">Retry</button>
|
| 134 |
+
<button onClick={() => router.push("/")} className="btn-ghost">Dashboard</button>
|
| 135 |
</div>
|
| 136 |
+
</GlassCard>
|
| 137 |
+
)}
|
| 138 |
+
|
| 139 |
+
{/* Pipeline Stages + Terminal */}
|
| 140 |
+
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
| 141 |
+
{/* Pipeline Stages */}
|
| 142 |
+
<div className="lg:col-span-2">
|
| 143 |
+
<GlassCard elevated padding="lg">
|
| 144 |
+
<p className="input-label mb-4">Pipeline Stages</p>
|
| 145 |
+
<div className="space-y-0">
|
| 146 |
+
{stageKeys.map((key, i) => {
|
| 147 |
+
const s = STAGES[key];
|
| 148 |
+
const done = completedStages.has(key) || completedStages.has(key + "_DONE");
|
| 149 |
+
const active = currentStage === key;
|
| 150 |
+
const isPast = currentIdx > i || pipelineDone;
|
| 151 |
+
return (
|
| 152 |
+
<div key={key}>
|
| 153 |
+
<div className="flex items-center gap-3 py-2.5">
|
| 154 |
+
<span className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0" style={{
|
| 155 |
+
background: done || isPast ? "var(--primary)" : active ? "var(--primary-ghost)" : "rgba(255,255,255,0.04)",
|
| 156 |
+
color: done || isPast ? "var(--text-inverse)" : active ? "var(--primary)" : "var(--text-muted)",
|
| 157 |
+
border: active ? "1px solid var(--primary)" : "1px solid transparent",
|
| 158 |
+
}}>
|
| 159 |
+
{done || isPast ? "✓" : i + 1}
|
| 160 |
+
</span>
|
| 161 |
+
<span className="text-sm" style={{ color: done || isPast ? "var(--text-primary)" : active ? "var(--primary)" : "var(--text-muted)" }}>
|
| 162 |
+
{s?.label || key}
|
| 163 |
+
</span>
|
| 164 |
+
{active && !done && !isPast && <span className="w-1.5 h-1.5 rounded-full animate-pulse ml-auto" style={{ background: "var(--primary)" }} />}
|
| 165 |
+
</div>
|
| 166 |
+
{i < stageKeys.length - 1 && <div className="ml-3.5 w-px h-3" style={{ background: isPast ? "var(--primary-dim)" : "var(--border)" }} />}
|
| 167 |
+
</div>
|
| 168 |
+
);
|
| 169 |
+
})}
|
| 170 |
+
</div>
|
| 171 |
+
</GlassCard>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
{/* Terminal Log */}
|
| 175 |
+
<div className="lg:col-span-3">
|
| 176 |
+
<div className="terminal h-[500px]" ref={logRef}>
|
| 177 |
+
<div className="flex items-center gap-2 mb-3 pb-3" style={{ borderBottom: "1px solid var(--border)" }}>
|
| 178 |
+
<span className="w-2 h-2 rounded-full" style={{ background: pipelineDone ? "var(--success)" : pipelineError ? "var(--error)" : "var(--primary)" }} />
|
| 179 |
+
<span className="text-xs font-bold uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>Live Log</span>
|
| 180 |
+
</div>
|
| 181 |
+
{logs.map((log, i) => {
|
| 182 |
+
const isStage = log.type === "stage";
|
| 183 |
+
const sn = isStage ? (log.data?.name as string) : log.type;
|
| 184 |
+
const s = STAGES[sn];
|
| 185 |
+
const label = s?.label || sn;
|
| 186 |
+
const detail = isStage ? (log.data?.detail as string) || "" : JSON.stringify(log.data);
|
| 187 |
+
const isErr = sn?.includes("error");
|
| 188 |
+
return (
|
| 189 |
+
<div key={i} className="terminal-line mb-1">
|
| 190 |
+
<span className="terminal-time">{log.timestamp}</span>
|
| 191 |
+
<span className={isErr ? "terminal-error" : "terminal-event"}>{label}</span>
|
| 192 |
+
{detail && <span className="terminal-detail">{detail}</span>}
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
})}
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
</div>
|
| 200 |
+
</DashboardLayout>
|
| 201 |
);
|
| 202 |
}
|
frontend/src/app/demo/[companyId]/page.tsx
CHANGED
|
@@ -2,6 +2,10 @@
|
|
| 2 |
|
| 3 |
import { useState, use } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
type AgentResponse = {
|
| 7 |
recommended_action?: string;
|
|
@@ -14,256 +18,100 @@ type AgentResponse = {
|
|
| 14 |
error?: string;
|
| 15 |
};
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
export default function QueryDemo({ params }: { params: Promise<{ companyId: string }> }) {
|
| 18 |
const resolvedParams = use(params);
|
| 19 |
const companyId = resolvedParams.companyId;
|
| 20 |
const [scenario, setScenario] = useState("");
|
| 21 |
-
const [contextJson, setContextJson] = useState("
|
| 22 |
const [loading, setLoading] = useState(false);
|
| 23 |
-
|
| 24 |
-
const [
|
| 25 |
-
const [withoutBrainResponse, setWithoutBrainResponse] = useState<AgentResponse | null>(null);
|
| 26 |
-
|
| 27 |
const router = useRouter();
|
| 28 |
|
|
|
|
|
|
|
| 29 |
const handleQuery = async (e: React.FormEvent) => {
|
| 30 |
e.preventDefault();
|
| 31 |
if (!scenario) return;
|
| 32 |
-
setLoading(true);
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
let parsedContext = {};
|
| 37 |
-
try {
|
| 38 |
-
if (contextJson.trim()) {
|
| 39 |
-
parsedContext = JSON.parse(contextJson);
|
| 40 |
-
}
|
| 41 |
-
} catch {
|
| 42 |
-
alert("Invalid JSON in context field");
|
| 43 |
-
setLoading(false);
|
| 44 |
-
return;
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
try {
|
| 48 |
-
const [
|
| 49 |
-
fetch(
|
| 50 |
-
|
| 51 |
-
headers: { "Content-Type": "application/json" },
|
| 52 |
-
body: JSON.stringify({ company_id: companyId, scenario, context: parsedContext, with_brain: true }),
|
| 53 |
-
}),
|
| 54 |
-
fetch("http://localhost:8080/agent/handle", {
|
| 55 |
-
method: "POST",
|
| 56 |
-
headers: { "Content-Type": "application/json" },
|
| 57 |
-
body: JSON.stringify({ company_id: companyId, scenario, context: parsedContext, with_brain: false }),
|
| 58 |
-
}),
|
| 59 |
]);
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
setWithoutBrainResponse(await resWithoutBrain.json());
|
| 63 |
-
} catch (err) {
|
| 64 |
-
console.error(err);
|
| 65 |
-
alert("Query failed — is the backend running?");
|
| 66 |
-
} finally {
|
| 67 |
-
setLoading(false);
|
| 68 |
-
}
|
| 69 |
-
};
|
| 70 |
-
|
| 71 |
-
const confidenceColor = (c: number) => {
|
| 72 |
-
if (c >= 0.75) return "bg-green-500";
|
| 73 |
-
if (c >= 0.5) return "bg-yellow-500";
|
| 74 |
-
if (c >= 0.25) return "bg-orange-500";
|
| 75 |
-
return "bg-red-500";
|
| 76 |
};
|
| 77 |
|
| 78 |
return (
|
| 79 |
-
<
|
| 80 |
-
<div className="
|
| 81 |
-
<div className="flex
|
| 82 |
-
<
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
</
|
|
|
|
| 86 |
</div>
|
| 87 |
|
| 88 |
-
<
|
| 89 |
-
<
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
<textarea
|
| 93 |
-
className="w-full px-4 py-3 bg-background border border-gray-700 text-foreground focus:outline-none focus:border-primary min-h-[100px]"
|
| 94 |
-
placeholder="Enterprise customer, 18 months tenure, wants $1,200 refund"
|
| 95 |
-
value={scenario}
|
| 96 |
-
onChange={(e) => setScenario(e.target.value)}
|
| 97 |
-
/>
|
| 98 |
-
</div>
|
| 99 |
-
<div>
|
| 100 |
-
<label className="block text-text-secondary text-sm font-bold mb-2">Context (JSON)</label>
|
| 101 |
-
<textarea
|
| 102 |
-
className="w-full px-4 py-3 bg-background border border-gray-700 text-foreground focus:outline-none focus:border-primary font-mono text-sm min-h-[80px]"
|
| 103 |
-
placeholder='{"plan": "enterprise", "tenure_months": 18, "refund_amount": 1200}'
|
| 104 |
-
value={contextJson}
|
| 105 |
-
onChange={(e) => setContextJson(e.target.value)}
|
| 106 |
-
/>
|
| 107 |
-
</div>
|
| 108 |
-
<button
|
| 109 |
-
type="submit"
|
| 110 |
-
disabled={loading || !scenario}
|
| 111 |
-
className="bg-primary text-background font-bold py-3 px-6 hover:opacity-90 disabled:opacity-50 self-end"
|
| 112 |
-
>
|
| 113 |
-
{loading ? "Thinking..." : "Compare Models"}
|
| 114 |
-
</button>
|
| 115 |
</div>
|
| 116 |
-
</
|
| 117 |
-
|
| 118 |
-
{(withBrainResponse || withoutBrainResponse) && (
|
| 119 |
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 120 |
-
{/* WITHOUT BRAIN */}
|
| 121 |
-
<div className="bg-surface border border-gray-800 p-6 opacity-75">
|
| 122 |
-
<h2 className="text-xl font-bold text-gray-400 mb-4 flex items-center gap-2">
|
| 123 |
-
<span className="w-2 h-2 rounded-full bg-gray-500"></span>
|
| 124 |
-
Without Brain (Generic AI)
|
| 125 |
-
</h2>
|
| 126 |
-
|
| 127 |
-
{withoutBrainResponse ? (
|
| 128 |
-
<div className="space-y-4 text-gray-300">
|
| 129 |
-
<div>
|
| 130 |
-
<h3 className="text-gray-500 text-sm font-bold uppercase tracking-wider mb-1">Response</h3>
|
| 131 |
-
<p className="text-lg bg-background p-4 border border-gray-800 rounded">
|
| 132 |
-
{withoutBrainResponse.recommended_action || "No action"}
|
| 133 |
-
</p>
|
| 134 |
-
</div>
|
| 135 |
-
<div>
|
| 136 |
-
<h3 className="text-gray-500 text-sm font-bold uppercase tracking-wider mb-1">Rule Applied</h3>
|
| 137 |
-
<p className="italic">{withoutBrainResponse.rule_applied || "General knowledge"}</p>
|
| 138 |
-
</div>
|
| 139 |
-
{withoutBrainResponse.reasoning && (
|
| 140 |
-
<div>
|
| 141 |
-
<h3 className="text-gray-500 text-sm font-bold uppercase tracking-wider mb-1">Reasoning</h3>
|
| 142 |
-
<p className="text-sm">{withoutBrainResponse.reasoning}</p>
|
| 143 |
-
</div>
|
| 144 |
-
)}
|
| 145 |
-
</div>
|
| 146 |
-
) : (
|
| 147 |
-
<p>Loading...</p>
|
| 148 |
-
)}
|
| 149 |
-
</div>
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
</div>
|
| 156 |
-
<
|
| 157 |
-
<
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
Skill Matched
|
| 180 |
-
</h3>
|
| 181 |
-
<p className="font-mono text-sm bg-background p-2 rounded">
|
| 182 |
-
{withBrainResponse.skill_matched || "N/A"}
|
| 183 |
-
</p>
|
| 184 |
-
</div>
|
| 185 |
-
<div>
|
| 186 |
-
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 187 |
-
Confidence
|
| 188 |
-
</h3>
|
| 189 |
-
<div className="flex items-center gap-2 mt-2">
|
| 190 |
-
<div className="flex-1 bg-background h-2 rounded-full overflow-hidden">
|
| 191 |
-
<div
|
| 192 |
-
className={`h-full ${confidenceColor(withBrainResponse.confidence || 0)}`}
|
| 193 |
-
style={{ width: `${(withBrainResponse.confidence || 0) * 100}%` }}
|
| 194 |
-
></div>
|
| 195 |
-
</div>
|
| 196 |
-
<span className="text-xs font-mono">
|
| 197 |
-
{((withBrainResponse.confidence || 0) * 100).toFixed(0)}%
|
| 198 |
-
</span>
|
| 199 |
-
</div>
|
| 200 |
-
</div>
|
| 201 |
-
</div>
|
| 202 |
-
|
| 203 |
-
{/* Retrieval Scores */}
|
| 204 |
-
{withBrainResponse.retrieval_scores && withBrainResponse.retrieval_scores.length > 0 && (
|
| 205 |
-
<div>
|
| 206 |
-
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 207 |
-
Retrieval Scores (Top {withBrainResponse.retrieval_scores.length} Skills)
|
| 208 |
-
</h3>
|
| 209 |
-
<div className="flex gap-2 flex-wrap">
|
| 210 |
-
{withBrainResponse.retrieval_scores.map((score, i) => (
|
| 211 |
-
<span
|
| 212 |
-
key={i}
|
| 213 |
-
className="bg-background border border-gray-700 px-2 py-1 rounded text-xs font-mono"
|
| 214 |
-
>
|
| 215 |
-
#{i + 1}: {(score * 100).toFixed(1)}%
|
| 216 |
-
</span>
|
| 217 |
-
))}
|
| 218 |
-
</div>
|
| 219 |
-
</div>
|
| 220 |
-
)}
|
| 221 |
-
|
| 222 |
-
<div>
|
| 223 |
-
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 224 |
-
Rule Applied
|
| 225 |
-
</h3>
|
| 226 |
-
<p className="text-white border-l-2 border-primary pl-3 py-1 font-medium">
|
| 227 |
-
{withBrainResponse.rule_applied}
|
| 228 |
-
</p>
|
| 229 |
-
</div>
|
| 230 |
-
|
| 231 |
-
{/* Reasoning */}
|
| 232 |
-
{withBrainResponse.reasoning && (
|
| 233 |
-
<div>
|
| 234 |
-
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 235 |
-
LLM Reasoning
|
| 236 |
-
</h3>
|
| 237 |
-
<p className="text-sm text-gray-300 bg-background p-3 rounded border border-gray-800">
|
| 238 |
-
{withBrainResponse.reasoning}
|
| 239 |
-
</p>
|
| 240 |
-
</div>
|
| 241 |
-
)}
|
| 242 |
-
|
| 243 |
-
{withBrainResponse.evidence && withBrainResponse.evidence.length > 0 && (
|
| 244 |
-
<div>
|
| 245 |
-
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-2">
|
| 246 |
-
Evidence Trail
|
| 247 |
-
</h3>
|
| 248 |
-
<ul className="space-y-2">
|
| 249 |
-
{withBrainResponse.evidence.map((src, i) => (
|
| 250 |
-
<li key={i} className="text-gray-300 text-sm bg-background p-3 rounded border border-gray-800">
|
| 251 |
-
{src}
|
| 252 |
-
</li>
|
| 253 |
-
))}
|
| 254 |
-
</ul>
|
| 255 |
-
</div>
|
| 256 |
-
)}
|
| 257 |
-
</>
|
| 258 |
-
)}
|
| 259 |
-
</div>
|
| 260 |
-
) : (
|
| 261 |
-
<p>Loading...</p>
|
| 262 |
-
)}
|
| 263 |
</div>
|
| 264 |
</div>
|
| 265 |
)}
|
| 266 |
</div>
|
| 267 |
-
</
|
| 268 |
);
|
| 269 |
}
|
|
|
|
| 2 |
|
| 3 |
import { useState, use } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
+
import { API_BASE } from "@/lib/api";
|
| 6 |
+
import DashboardLayout from "@/components/DashboardLayout";
|
| 7 |
+
import GlassCard from "@/components/ui/GlassCard";
|
| 8 |
+
import ConfidenceBadge from "@/components/ui/ConfidenceBadge";
|
| 9 |
|
| 10 |
type AgentResponse = {
|
| 11 |
recommended_action?: string;
|
|
|
|
| 18 |
error?: string;
|
| 19 |
};
|
| 20 |
|
| 21 |
+
const PRESETS = [
|
| 22 |
+
{ label: "Enterprise Refund", scenario: "Enterprise customer, 18 months tenure, wants a $1,200 refund for unused seats", context: '{"plan": "enterprise", "tenure_months": 18, "refund_amount": 1200}' },
|
| 23 |
+
{ label: "Priority Escalation", scenario: "Customer has been waiting 3 days for a response on a billing issue and is threatening to churn", context: '{"issue_type": "billing", "wait_days": 3, "sentiment": "frustrated"}' },
|
| 24 |
+
{ label: "New Hire Onboarding", scenario: "New support agent just started, needs to know the standard process for handling refund requests", context: '{"agent_level": "junior", "department": "support"}' },
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
export default function QueryDemo({ params }: { params: Promise<{ companyId: string }> }) {
|
| 28 |
const resolvedParams = use(params);
|
| 29 |
const companyId = resolvedParams.companyId;
|
| 30 |
const [scenario, setScenario] = useState("");
|
| 31 |
+
const [contextJson, setContextJson] = useState("");
|
| 32 |
const [loading, setLoading] = useState(false);
|
| 33 |
+
const [withBrain, setWithBrain] = useState<AgentResponse | null>(null);
|
| 34 |
+
const [withoutBrain, setWithoutBrain] = useState<AgentResponse | null>(null);
|
|
|
|
|
|
|
| 35 |
const router = useRouter();
|
| 36 |
|
| 37 |
+
const applyPreset = (p: (typeof PRESETS)[0]) => { setScenario(p.scenario); setContextJson(p.context); setWithBrain(null); setWithoutBrain(null); };
|
| 38 |
+
|
| 39 |
const handleQuery = async (e: React.FormEvent) => {
|
| 40 |
e.preventDefault();
|
| 41 |
if (!scenario) return;
|
| 42 |
+
setLoading(true); setWithBrain(null); setWithoutBrain(null);
|
| 43 |
+
let ctx = {};
|
| 44 |
+
try { if (contextJson.trim()) ctx = JSON.parse(contextJson); } catch { alert("Invalid JSON"); setLoading(false); return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
try {
|
| 46 |
+
const [r1, r2] = await Promise.all([
|
| 47 |
+
fetch(`${API_BASE}/agent/handle`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: companyId, scenario, context: ctx, with_brain: true }) }),
|
| 48 |
+
fetch(`${API_BASE}/agent/handle`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: companyId, scenario, context: ctx, with_brain: false }) }),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
]);
|
| 50 |
+
setWithBrain(await r1.json()); setWithoutBrain(await r2.json());
|
| 51 |
+
} catch { alert("Query failed — is the backend running?"); } finally { setLoading(false); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
};
|
| 53 |
|
| 54 |
return (
|
| 55 |
+
<DashboardLayout>
|
| 56 |
+
<div className="p-6 lg:p-8 max-w-6xl mx-auto animate-fade-in">
|
| 57 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
| 58 |
+
<div>
|
| 59 |
+
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>Brain Query Demo</h1>
|
| 60 |
+
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>Compare AI responses with and without your compiled brain</p>
|
| 61 |
+
</div>
|
| 62 |
+
<button onClick={() => router.push(`/skills/${companyId}`)} className="btn-secondary">View Skills</button>
|
| 63 |
</div>
|
| 64 |
|
| 65 |
+
<div className="mb-6">
|
| 66 |
+
<p className="input-label mb-2">Quick Presets</p>
|
| 67 |
+
<div className="flex gap-2 flex-wrap">
|
| 68 |
+
{PRESETS.map((p) => (<button key={p.label} onClick={() => applyPreset(p)} className="badge" style={{ background: "var(--primary-ghost)", color: "var(--primary)", border: "1px solid rgba(0,210,180,0.2)", cursor: "pointer" }}>{p.label}</button>))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</div>
|
| 70 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
<GlassCard className="mb-8">
|
| 73 |
+
<form onSubmit={handleQuery} className="space-y-4">
|
| 74 |
+
<div><label className="input-label">Scenario</label><textarea className="input-field" style={{ minHeight: "100px" }} placeholder="Describe the scenario..." value={scenario} onChange={(e) => setScenario(e.target.value)} /></div>
|
| 75 |
+
<div><label className="input-label">Context (JSON)</label><textarea className="input-field input-field--mono" style={{ minHeight: "80px" }} placeholder='{"plan": "enterprise"}' value={contextJson} onChange={(e) => setContextJson(e.target.value)} /></div>
|
| 76 |
+
<div className="flex justify-end"><button type="submit" disabled={loading || !scenario} className="btn-primary">{loading ? "Processing..." : "Compare Models"}</button></div>
|
| 77 |
+
</form>
|
| 78 |
+
</GlassCard>
|
| 79 |
+
|
| 80 |
+
{(withBrain || withoutBrain) && (
|
| 81 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 stagger-children">
|
| 82 |
+
<GlassCard className="opacity-70">
|
| 83 |
+
<div className="flex items-center gap-2 mb-5">
|
| 84 |
+
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "var(--text-muted)" }} />
|
| 85 |
+
<h2 className="text-lg font-bold" style={{ color: "var(--text-secondary)" }}>Without Brain</h2>
|
| 86 |
+
<span className="badge badge--neutral ml-auto">Generic AI</span>
|
| 87 |
</div>
|
| 88 |
+
{withoutBrain ? (<div className="space-y-4">
|
| 89 |
+
<div><p className="input-label">Response</p><div className="p-4 rounded text-sm" style={{ background: "var(--bg-input)", color: "var(--text-secondary)", border: "1px solid var(--border)" }}>{withoutBrain.recommended_action || "No action"}</div></div>
|
| 90 |
+
<div><p className="input-label">Rule</p><p className="text-sm" style={{ color: "var(--text-muted)" }}>{withoutBrain.rule_applied || "General knowledge"}</p></div>
|
| 91 |
+
{withoutBrain.reasoning && <div><p className="input-label">Reasoning</p><p className="text-sm" style={{ color: "var(--text-muted)" }}>{withoutBrain.reasoning}</p></div>}
|
| 92 |
+
</div>) : <div className="animate-shimmer h-32 rounded" />}
|
| 93 |
+
</GlassCard>
|
| 94 |
+
|
| 95 |
+
<div className="glass-card p-5 relative" style={{ borderColor: "rgba(0,210,180,0.3)", boxShadow: "0 0 32px -8px rgba(0,210,180,0.08)" }}>
|
| 96 |
+
<span className="absolute -top-3 right-4 text-[10px] font-bold uppercase tracking-wider px-3 py-1 rounded-full" style={{ background: "var(--primary)", color: "var(--text-inverse)" }}>Company Brain</span>
|
| 97 |
+
<div className="flex items-center gap-2 mb-5"><span className="w-2.5 h-2.5 rounded-full animate-pulse" style={{ background: "var(--primary)" }} /><h2 className="text-lg font-bold" style={{ color: "var(--primary)" }}>With Brain</h2></div>
|
| 98 |
+
{withBrain ? (<div className="space-y-4">
|
| 99 |
+
{withBrain.error ? <p style={{ color: "var(--error)" }}>{withBrain.error}</p> : (<>
|
| 100 |
+
<div><p className="input-label">Recommended Action</p><div className="p-4 rounded text-base font-medium" style={{ background: "var(--primary-ghost)", color: "var(--text-primary)", border: "1px solid rgba(0,210,180,0.15)" }}>{withBrain.recommended_action}</div></div>
|
| 101 |
+
<div className="grid grid-cols-2 gap-4">
|
| 102 |
+
<div><p className="input-label">Skill Matched</p><p className="font-mono text-sm" style={{ color: "var(--text-primary)" }}>{withBrain.skill_matched || "N/A"}</p></div>
|
| 103 |
+
<div><p className="input-label">Confidence</p><div className="flex items-center gap-2 mt-1"><div className="progress-bar flex-1"><div className="progress-bar__fill" style={{ width: `${(withBrain.confidence || 0) * 100}%`, background: "var(--primary)" }} /></div><ConfidenceBadge value={withBrain.confidence || 0} /></div></div>
|
| 104 |
+
</div>
|
| 105 |
+
{withBrain.retrieval_scores && withBrain.retrieval_scores.length > 0 && <div><p className="input-label">Retrieval Scores</p><div className="flex gap-2 flex-wrap">{withBrain.retrieval_scores.map((s, i) => <span key={i} className="badge badge--neutral font-mono">#{i+1}: {(s*100).toFixed(1)}%</span>)}</div></div>}
|
| 106 |
+
<div><p className="input-label">Rule Applied</p><p className="text-sm font-medium pl-3 py-1" style={{ color: "var(--text-primary)", borderLeft: "2px solid var(--primary)" }}>{withBrain.rule_applied}</p></div>
|
| 107 |
+
{withBrain.reasoning && <div><p className="input-label">Reasoning</p><div className="text-sm p-3 rounded" style={{ color: "var(--text-secondary)", background: "var(--bg-input)", border: "1px solid var(--border)" }}>{withBrain.reasoning}</div></div>}
|
| 108 |
+
{withBrain.evidence && withBrain.evidence.length > 0 && <div><p className="input-label">Evidence Trail</p><div className="space-y-2">{withBrain.evidence.map((src, i) => <div key={i} className="text-sm p-3 rounded" style={{ color: "var(--text-secondary)", background: "var(--bg-input)", border: "1px solid var(--border)" }}>{src}</div>)}</div></div>}
|
| 109 |
+
</>)}
|
| 110 |
+
</div>) : <div className="animate-shimmer h-32 rounded" />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
| 112 |
</div>
|
| 113 |
)}
|
| 114 |
</div>
|
| 115 |
+
</DashboardLayout>
|
| 116 |
);
|
| 117 |
}
|
frontend/src/app/globals.css
CHANGED
|
@@ -1,24 +1,517 @@
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
:root {
|
| 4 |
-
|
| 5 |
-
--
|
| 6 |
-
--surface: #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
--primary: #00D2B4;
|
| 8 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
|
|
|
| 11 |
@theme inline {
|
| 12 |
-
--color-background: var(--
|
| 13 |
-
--color-foreground: var(--
|
| 14 |
-
--color-surface: var(--surface);
|
|
|
|
| 15 |
--color-primary: var(--primary);
|
|
|
|
|
|
|
|
|
|
| 16 |
--color-text-secondary: var(--text-secondary);
|
| 17 |
-
--
|
| 18 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
body {
|
| 22 |
-
background:
|
| 23 |
-
color:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
|
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
| 3 |
+
/* ═══════════════════════════════════════════════
|
| 4 |
+
KERNL DESIGN SYSTEM — "Aerospace Grade"
|
| 5 |
+
Glassmorphism × Minimalism × Dark-first
|
| 6 |
+
═══════════════════════════════════════════════ */
|
| 7 |
+
|
| 8 |
:root {
|
| 9 |
+
/* ── Core Palette ── */
|
| 10 |
+
--bg-base: #0A0F14;
|
| 11 |
+
--bg-surface: #111820;
|
| 12 |
+
--bg-elevated: #19222D;
|
| 13 |
+
--bg-input: #0C1218;
|
| 14 |
+
--bg-hover: #1E2A36;
|
| 15 |
+
|
| 16 |
+
/* ── Brand Teal ── */
|
| 17 |
--primary: #00D2B4;
|
| 18 |
+
--primary-hover: #00E8C8;
|
| 19 |
+
--primary-dim: #00A893;
|
| 20 |
+
--primary-ghost: rgba(0, 210, 180, 0.08);
|
| 21 |
+
--primary-glow: rgba(0, 210, 180, 0.15);
|
| 22 |
+
|
| 23 |
+
/* ── Text ── */
|
| 24 |
+
--text-primary: #E8EDF2;
|
| 25 |
+
--text-secondary: #8899A6;
|
| 26 |
+
--text-muted: #5C6F7E;
|
| 27 |
+
--text-inverse: #0A0F14;
|
| 28 |
+
|
| 29 |
+
/* ── Borders ── */
|
| 30 |
+
--border: rgba(255, 255, 255, 0.06);
|
| 31 |
+
--border-hover: rgba(255, 255, 255, 0.12);
|
| 32 |
+
--border-active: rgba(0, 210, 180, 0.3);
|
| 33 |
+
|
| 34 |
+
/* ── Status ── */
|
| 35 |
+
--success: #34D399;
|
| 36 |
+
--success-bg: rgba(52, 211, 153, 0.1);
|
| 37 |
+
--warning: #FBBF24;
|
| 38 |
+
--warning-bg: rgba(251, 191, 36, 0.1);
|
| 39 |
+
--error: #F87171;
|
| 40 |
+
--error-bg: rgba(248, 113, 113, 0.1);
|
| 41 |
+
--info: #60A5FA;
|
| 42 |
+
--info-bg: rgba(96, 165, 250, 0.1);
|
| 43 |
+
|
| 44 |
+
/* ── Spacing (8pt grid) ── */
|
| 45 |
+
--space-1: 4px;
|
| 46 |
+
--space-2: 8px;
|
| 47 |
+
--space-3: 12px;
|
| 48 |
+
--space-4: 16px;
|
| 49 |
+
--space-5: 20px;
|
| 50 |
+
--space-6: 24px;
|
| 51 |
+
--space-8: 32px;
|
| 52 |
+
--space-10: 40px;
|
| 53 |
+
--space-12: 48px;
|
| 54 |
+
--space-16: 64px;
|
| 55 |
+
|
| 56 |
+
/* ── Radii ── */
|
| 57 |
+
--radius-sm: 4px;
|
| 58 |
+
--radius: 8px;
|
| 59 |
+
--radius-md: 12px;
|
| 60 |
+
--radius-lg: 16px;
|
| 61 |
+
--radius-full: 9999px;
|
| 62 |
+
|
| 63 |
+
/* ── Sidebar ── */
|
| 64 |
+
--sidebar-width: 64px;
|
| 65 |
+
|
| 66 |
+
/* ── Typography ── */
|
| 67 |
+
--font-sans: 'Inter', var(--font-geist-sans), system-ui, sans-serif;
|
| 68 |
+
--font-mono: 'JetBrains Mono', var(--font-geist-mono), 'Fira Code', monospace;
|
| 69 |
}
|
| 70 |
|
| 71 |
+
/* ── Tailwind Theme Bridge ── */
|
| 72 |
@theme inline {
|
| 73 |
+
--color-background: var(--bg-base);
|
| 74 |
+
--color-foreground: var(--text-primary);
|
| 75 |
+
--color-surface: var(--bg-surface);
|
| 76 |
+
--color-elevated: var(--bg-elevated);
|
| 77 |
--color-primary: var(--primary);
|
| 78 |
+
--color-primary-hover: var(--primary-hover);
|
| 79 |
+
--color-primary-dim: var(--primary-dim);
|
| 80 |
+
--color-primary-ghost: var(--primary-ghost);
|
| 81 |
--color-text-secondary: var(--text-secondary);
|
| 82 |
+
--color-text-muted: var(--text-muted);
|
| 83 |
+
--color-border: var(--border);
|
| 84 |
+
--color-border-hover: var(--border-hover);
|
| 85 |
+
--color-success: var(--success);
|
| 86 |
+
--color-warning: var(--warning);
|
| 87 |
+
--color-error: var(--error);
|
| 88 |
+
--color-info: var(--info);
|
| 89 |
+
--font-sans: var(--font-sans);
|
| 90 |
+
--font-mono: var(--font-mono);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/* ═══════════════ Global Base ═══════════════ */
|
| 94 |
+
|
| 95 |
+
*,
|
| 96 |
+
*::before,
|
| 97 |
+
*::after {
|
| 98 |
+
box-sizing: border-box;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
html {
|
| 102 |
+
scroll-behavior: smooth;
|
| 103 |
+
color-scheme: dark;
|
| 104 |
+
background-color: #0A0F14;
|
| 105 |
}
|
| 106 |
|
| 107 |
body {
|
| 108 |
+
background: #0A0F14 !important;
|
| 109 |
+
background-color: #0A0F14 !important;
|
| 110 |
+
color: var(--text-primary);
|
| 111 |
+
font-family: var(--font-sans);
|
| 112 |
+
-webkit-font-smoothing: antialiased;
|
| 113 |
+
-moz-osx-font-smoothing: grayscale;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/* ── Selection ── */
|
| 117 |
+
::selection {
|
| 118 |
+
background: rgba(0, 210, 180, 0.25);
|
| 119 |
+
color: #fff;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* ── Scrollbar ── */
|
| 123 |
+
::-webkit-scrollbar {
|
| 124 |
+
width: 6px;
|
| 125 |
+
height: 6px;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
::-webkit-scrollbar-track {
|
| 129 |
+
background: transparent;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
::-webkit-scrollbar-thumb {
|
| 133 |
+
background: var(--border-hover);
|
| 134 |
+
border-radius: var(--radius-full);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
::-webkit-scrollbar-thumb:hover {
|
| 138 |
+
background: var(--text-muted);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* ═══════════════ Animation Keyframes ═══════════════ */
|
| 142 |
+
|
| 143 |
+
@keyframes fade-in {
|
| 144 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 145 |
+
to { opacity: 1; transform: translateY(0); }
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
@keyframes fade-up {
|
| 149 |
+
from { opacity: 0; transform: translateY(16px); }
|
| 150 |
+
to { opacity: 1; transform: translateY(0); }
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
@keyframes slide-in-right {
|
| 154 |
+
from { opacity: 0; transform: translateX(24px); }
|
| 155 |
+
to { opacity: 1; transform: translateX(0); }
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
@keyframes slide-in-left {
|
| 159 |
+
from { opacity: 0; transform: translateX(-24px); }
|
| 160 |
+
to { opacity: 1; transform: translateX(0); }
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
@keyframes pulse-glow {
|
| 164 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 210, 180, 0.2); }
|
| 165 |
+
50% { box-shadow: 0 0 16px 4px rgba(0, 210, 180, 0.15); }
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
@keyframes shimmer {
|
| 169 |
+
0% { background-position: -200% 0; }
|
| 170 |
+
100% { background-position: 200% 0; }
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
@keyframes spin {
|
| 174 |
+
from { transform: rotate(0deg); }
|
| 175 |
+
to { transform: rotate(360deg); }
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
@keyframes flow-dots {
|
| 179 |
+
0% { background-position: 0 0; }
|
| 180 |
+
100% { background-position: 24px 0; }
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* ═══════════════ Utility Classes ═══════════════ */
|
| 184 |
+
|
| 185 |
+
.animate-fade-in {
|
| 186 |
+
animation: fade-in 0.4s ease-out both;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.animate-fade-up {
|
| 190 |
+
animation: fade-up 0.5s ease-out both;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.animate-slide-right {
|
| 194 |
+
animation: slide-in-right 0.4s ease-out both;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.animate-pulse-glow {
|
| 198 |
+
animation: pulse-glow 2s ease-in-out infinite;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.animate-shimmer {
|
| 202 |
+
background: linear-gradient(90deg, transparent 25%, rgba(255,255,255,0.04) 50%, transparent 75%);
|
| 203 |
+
background-size: 200% 100%;
|
| 204 |
+
animation: shimmer 2s linear infinite;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.animate-spin-slow {
|
| 208 |
+
animation: spin 2s linear infinite;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* ── Stagger children ── */
|
| 212 |
+
.stagger-children > * {
|
| 213 |
+
animation: fade-up 0.4s ease-out both;
|
| 214 |
+
}
|
| 215 |
+
.stagger-children > *:nth-child(1) { animation-delay: 0.05s; }
|
| 216 |
+
.stagger-children > *:nth-child(2) { animation-delay: 0.1s; }
|
| 217 |
+
.stagger-children > *:nth-child(3) { animation-delay: 0.15s; }
|
| 218 |
+
.stagger-children > *:nth-child(4) { animation-delay: 0.2s; }
|
| 219 |
+
.stagger-children > *:nth-child(5) { animation-delay: 0.25s; }
|
| 220 |
+
.stagger-children > *:nth-child(6) { animation-delay: 0.3s; }
|
| 221 |
+
|
| 222 |
+
/* ═══════════════ Component Primitives ═══════════════ */
|
| 223 |
+
|
| 224 |
+
/* ── Glass Card ── */
|
| 225 |
+
.glass-card {
|
| 226 |
+
background: var(--bg-surface);
|
| 227 |
+
border: 1px solid var(--border);
|
| 228 |
+
border-radius: var(--radius);
|
| 229 |
+
backdrop-filter: blur(12px);
|
| 230 |
+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.glass-card:hover {
|
| 234 |
+
border-color: var(--border-hover);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.glass-card--interactive:hover {
|
| 238 |
+
border-color: var(--border-active);
|
| 239 |
+
box-shadow: 0 0 24px -8px rgba(0, 210, 180, 0.08);
|
| 240 |
+
cursor: pointer;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.glass-card--elevated {
|
| 244 |
+
background: var(--bg-elevated);
|
| 245 |
+
border-color: var(--border-hover);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/* ── Gradient Mesh Background ── */
|
| 249 |
+
.mesh-gradient {
|
| 250 |
+
position: fixed;
|
| 251 |
+
inset: 0;
|
| 252 |
+
z-index: -1;
|
| 253 |
+
background:
|
| 254 |
+
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(0, 210, 180, 0.04) 0%, transparent 60%),
|
| 255 |
+
radial-gradient(ellipse 50% 50% at 80% 20%, rgba(96, 165, 250, 0.03) 0%, transparent 60%),
|
| 256 |
+
radial-gradient(ellipse 40% 60% at 50% 50%, rgba(139, 92, 246, 0.02) 0%, transparent 60%);
|
| 257 |
+
pointer-events: none;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/* ── Button Styles ── */
|
| 261 |
+
.btn-primary {
|
| 262 |
+
display: inline-flex;
|
| 263 |
+
align-items: center;
|
| 264 |
+
justify-content: center;
|
| 265 |
+
gap: 8px;
|
| 266 |
+
padding: 10px 20px;
|
| 267 |
+
background: var(--primary);
|
| 268 |
+
color: var(--text-inverse);
|
| 269 |
+
font-weight: 600;
|
| 270 |
+
font-size: 14px;
|
| 271 |
+
border-radius: var(--radius);
|
| 272 |
+
border: none;
|
| 273 |
+
cursor: pointer;
|
| 274 |
+
transition: all 0.2s ease;
|
| 275 |
+
white-space: nowrap;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.btn-primary:hover {
|
| 279 |
+
background: var(--primary-hover);
|
| 280 |
+
box-shadow: 0 0 20px -4px rgba(0, 210, 180, 0.3);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.btn-primary:disabled {
|
| 284 |
+
opacity: 0.5;
|
| 285 |
+
cursor: not-allowed;
|
| 286 |
+
box-shadow: none;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.btn-secondary {
|
| 290 |
+
display: inline-flex;
|
| 291 |
+
align-items: center;
|
| 292 |
+
justify-content: center;
|
| 293 |
+
gap: 8px;
|
| 294 |
+
padding: 10px 20px;
|
| 295 |
+
background: transparent;
|
| 296 |
+
color: var(--text-primary);
|
| 297 |
+
font-weight: 600;
|
| 298 |
+
font-size: 14px;
|
| 299 |
+
border-radius: var(--radius);
|
| 300 |
+
border: 1px solid var(--border-hover);
|
| 301 |
+
cursor: pointer;
|
| 302 |
+
transition: all 0.2s ease;
|
| 303 |
+
white-space: nowrap;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.btn-secondary:hover {
|
| 307 |
+
background: var(--bg-hover);
|
| 308 |
+
border-color: var(--text-muted);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.btn-ghost {
|
| 312 |
+
display: inline-flex;
|
| 313 |
+
align-items: center;
|
| 314 |
+
justify-content: center;
|
| 315 |
+
gap: 8px;
|
| 316 |
+
padding: 10px 20px;
|
| 317 |
+
background: transparent;
|
| 318 |
+
color: var(--primary);
|
| 319 |
+
font-weight: 600;
|
| 320 |
+
font-size: 14px;
|
| 321 |
+
border-radius: var(--radius);
|
| 322 |
+
border: none;
|
| 323 |
+
cursor: pointer;
|
| 324 |
+
transition: all 0.2s ease;
|
| 325 |
+
white-space: nowrap;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.btn-ghost:hover {
|
| 329 |
+
background: var(--primary-ghost);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/* ── Input Styles ── */
|
| 333 |
+
.input-field {
|
| 334 |
+
width: 100%;
|
| 335 |
+
padding: 10px 14px;
|
| 336 |
+
background: var(--bg-input);
|
| 337 |
+
border: 1px solid var(--border);
|
| 338 |
+
border-radius: var(--radius);
|
| 339 |
+
color: var(--text-primary);
|
| 340 |
+
font-size: 14px;
|
| 341 |
+
font-family: var(--font-sans);
|
| 342 |
+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
| 343 |
+
outline: none;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.input-field::placeholder {
|
| 347 |
+
color: var(--text-muted);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.input-field:focus {
|
| 351 |
+
border-color: var(--primary);
|
| 352 |
+
box-shadow: 0 0 0 3px rgba(0, 210, 180, 0.1);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.input-field--mono {
|
| 356 |
+
font-family: var(--font-mono);
|
| 357 |
+
font-size: 13px;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
/* ── Label ── */
|
| 361 |
+
.input-label {
|
| 362 |
+
display: block;
|
| 363 |
+
font-size: 12px;
|
| 364 |
+
font-weight: 600;
|
| 365 |
+
font-family: var(--font-mono);
|
| 366 |
+
letter-spacing: 0.05em;
|
| 367 |
+
text-transform: uppercase;
|
| 368 |
+
color: var(--text-secondary);
|
| 369 |
+
margin-bottom: 6px;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
/* ── Badge/Chip ── */
|
| 373 |
+
.badge {
|
| 374 |
+
display: inline-flex;
|
| 375 |
+
align-items: center;
|
| 376 |
+
gap: 4px;
|
| 377 |
+
padding: 3px 10px;
|
| 378 |
+
font-size: 11px;
|
| 379 |
+
font-weight: 600;
|
| 380 |
+
font-family: var(--font-mono);
|
| 381 |
+
letter-spacing: 0.02em;
|
| 382 |
+
border-radius: var(--radius-sm);
|
| 383 |
+
white-space: nowrap;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.badge--primary {
|
| 387 |
+
background: var(--primary-ghost);
|
| 388 |
+
color: var(--primary);
|
| 389 |
+
border: 1px solid rgba(0, 210, 180, 0.2);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.badge--success {
|
| 393 |
+
background: var(--success-bg);
|
| 394 |
+
color: var(--success);
|
| 395 |
+
border: 1px solid rgba(52, 211, 153, 0.2);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.badge--warning {
|
| 399 |
+
background: var(--warning-bg);
|
| 400 |
+
color: var(--warning);
|
| 401 |
+
border: 1px solid rgba(251, 191, 36, 0.2);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.badge--error {
|
| 405 |
+
background: var(--error-bg);
|
| 406 |
+
color: var(--error);
|
| 407 |
+
border: 1px solid rgba(248, 113, 113, 0.2);
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.badge--info {
|
| 411 |
+
background: var(--info-bg);
|
| 412 |
+
color: var(--info);
|
| 413 |
+
border: 1px solid rgba(96, 165, 250, 0.2);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.badge--neutral {
|
| 417 |
+
background: rgba(255, 255, 255, 0.04);
|
| 418 |
+
color: var(--text-secondary);
|
| 419 |
+
border: 1px solid var(--border);
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
/* ── Pipeline Node ── */
|
| 423 |
+
.pipeline-connector {
|
| 424 |
+
width: 2px;
|
| 425 |
+
height: 32px;
|
| 426 |
+
margin: 0 auto;
|
| 427 |
+
background: linear-gradient(to bottom, var(--primary-dim), var(--border));
|
| 428 |
+
position: relative;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.pipeline-connector--active {
|
| 432 |
+
background: var(--primary);
|
| 433 |
+
box-shadow: 0 0 8px rgba(0, 210, 180, 0.3);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.pipeline-connector--pending {
|
| 437 |
+
background: var(--border);
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
/* ── Terminal Log ── */
|
| 441 |
+
.terminal {
|
| 442 |
+
background: #05070A;
|
| 443 |
+
border: 1px solid var(--border);
|
| 444 |
+
border-radius: var(--radius);
|
| 445 |
+
font-family: var(--font-mono);
|
| 446 |
+
font-size: 12px;
|
| 447 |
+
line-height: 1.7;
|
| 448 |
+
overflow-y: auto;
|
| 449 |
+
padding: var(--space-4);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.terminal-line {
|
| 453 |
+
display: flex;
|
| 454 |
+
gap: 8px;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
.terminal-time {
|
| 458 |
+
color: var(--text-muted);
|
| 459 |
+
flex-shrink: 0;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.terminal-event {
|
| 463 |
+
color: var(--primary);
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.terminal-detail {
|
| 467 |
+
color: var(--text-secondary);
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.terminal-error {
|
| 471 |
+
color: var(--error);
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/* ── Progress Bar ── */
|
| 475 |
+
.progress-bar {
|
| 476 |
+
height: 6px;
|
| 477 |
+
background: rgba(255, 255, 255, 0.06);
|
| 478 |
+
border-radius: var(--radius-full);
|
| 479 |
+
overflow: hidden;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.progress-bar__fill {
|
| 483 |
+
height: 100%;
|
| 484 |
+
border-radius: var(--radius-full);
|
| 485 |
+
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
/* ── Empty State ── */
|
| 489 |
+
.empty-state {
|
| 490 |
+
text-align: center;
|
| 491 |
+
padding: var(--space-16) var(--space-8);
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.empty-state__icon {
|
| 495 |
+
width: 56px;
|
| 496 |
+
height: 56px;
|
| 497 |
+
margin: 0 auto var(--space-6);
|
| 498 |
+
border-radius: var(--radius-md);
|
| 499 |
+
background: var(--primary-ghost);
|
| 500 |
+
display: flex;
|
| 501 |
+
align-items: center;
|
| 502 |
+
justify-content: center;
|
| 503 |
+
color: var(--primary);
|
| 504 |
+
font-size: 24px;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
/* ═══════════════ Reduced Motion ═══════════════ */
|
| 508 |
+
|
| 509 |
+
@media (prefers-reduced-motion: reduce) {
|
| 510 |
+
*,
|
| 511 |
+
*::before,
|
| 512 |
+
*::after {
|
| 513 |
+
animation-duration: 0.01ms !important;
|
| 514 |
+
animation-iteration-count: 1 !important;
|
| 515 |
+
transition-duration: 0.01ms !important;
|
| 516 |
+
}
|
| 517 |
}
|
frontend/src/app/layout.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
import "./globals.css";
|
|
|
|
| 4 |
|
| 5 |
const geistSans = Geist({
|
| 6 |
variable: "--font-geist-sans",
|
|
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
|
| 13 |
});
|
| 14 |
|
| 15 |
export const metadata: Metadata = {
|
| 16 |
-
title: "
|
| 17 |
-
description: "
|
| 18 |
};
|
| 19 |
|
| 20 |
export default function RootLayout({
|
|
@@ -27,7 +28,11 @@ export default function RootLayout({
|
|
| 27 |
lang="en"
|
| 28 |
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 29 |
>
|
| 30 |
-
<body className="min-h-full
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
</html>
|
| 32 |
);
|
| 33 |
}
|
|
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
import "./globals.css";
|
| 4 |
+
import { AuthProvider } from "@/lib/auth";
|
| 5 |
|
| 6 |
const geistSans = Geist({
|
| 7 |
variable: "--font-geist-sans",
|
|
|
|
| 14 |
});
|
| 15 |
|
| 16 |
export const metadata: Metadata = {
|
| 17 |
+
title: "Kernl — Company Brain Compiler",
|
| 18 |
+
description: "Compile operational knowledge into executable skills",
|
| 19 |
};
|
| 20 |
|
| 21 |
export default function RootLayout({
|
|
|
|
| 28 |
lang="en"
|
| 29 |
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 30 |
>
|
| 31 |
+
<body className="min-h-full" style={{ background: "var(--bg-base)" }}>
|
| 32 |
+
<AuthProvider>
|
| 33 |
+
{children}
|
| 34 |
+
</AuthProvider>
|
| 35 |
+
</body>
|
| 36 |
</html>
|
| 37 |
);
|
| 38 |
}
|
frontend/src/app/login/page.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { useAuth } from "@/lib/auth";
|
| 6 |
+
|
| 7 |
+
export default function LoginPage() {
|
| 8 |
+
const [email, setEmail] = useState("");
|
| 9 |
+
const [password, setPassword] = useState("");
|
| 10 |
+
const [error, setError] = useState("");
|
| 11 |
+
const [loading, setLoading] = useState(false);
|
| 12 |
+
const { login } = useAuth();
|
| 13 |
+
const router = useRouter();
|
| 14 |
+
|
| 15 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 16 |
+
e.preventDefault();
|
| 17 |
+
setError("");
|
| 18 |
+
setLoading(true);
|
| 19 |
+
try {
|
| 20 |
+
await login(email, password);
|
| 21 |
+
router.push("/");
|
| 22 |
+
} catch (err: unknown) {
|
| 23 |
+
setError(err instanceof Error ? err.message : "Login failed");
|
| 24 |
+
} finally {
|
| 25 |
+
setLoading(false);
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<div className="min-h-screen flex items-center justify-center p-6 relative">
|
| 31 |
+
{/* Background mesh */}
|
| 32 |
+
<div className="mesh-gradient" />
|
| 33 |
+
|
| 34 |
+
{/* Auth Card */}
|
| 35 |
+
<div
|
| 36 |
+
className="w-full max-w-sm animate-fade-up"
|
| 37 |
+
style={{ animationDelay: "0.1s" }}
|
| 38 |
+
>
|
| 39 |
+
{/* Logo */}
|
| 40 |
+
<div className="flex items-center justify-center mb-8">
|
| 41 |
+
<div
|
| 42 |
+
className="w-12 h-12 rounded-xl flex items-center justify-center animate-pulse-glow"
|
| 43 |
+
style={{
|
| 44 |
+
background: "var(--primary-ghost)",
|
| 45 |
+
color: "var(--primary)",
|
| 46 |
+
}}
|
| 47 |
+
>
|
| 48 |
+
<svg width="24" height="24" viewBox="0 0 18 18" fill="none">
|
| 49 |
+
<path
|
| 50 |
+
d="M3 2h3v14H3V2zm5 0h2l5 7-5 7H8l5-7-5-7z"
|
| 51 |
+
fill="currentColor"
|
| 52 |
+
/>
|
| 53 |
+
</svg>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div className="glass-card p-8">
|
| 58 |
+
<h1
|
| 59 |
+
className="text-xl font-bold mb-1 text-center"
|
| 60 |
+
style={{ color: "var(--text-primary)" }}
|
| 61 |
+
>
|
| 62 |
+
Welcome back
|
| 63 |
+
</h1>
|
| 64 |
+
<p
|
| 65 |
+
className="text-sm text-center mb-6"
|
| 66 |
+
style={{ color: "var(--text-muted)" }}
|
| 67 |
+
>
|
| 68 |
+
Sign in to your Kernl workspace
|
| 69 |
+
</p>
|
| 70 |
+
|
| 71 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 72 |
+
<div>
|
| 73 |
+
<label className="input-label">Email</label>
|
| 74 |
+
<input
|
| 75 |
+
type="email"
|
| 76 |
+
className="input-field"
|
| 77 |
+
placeholder="you@company.com"
|
| 78 |
+
value={email}
|
| 79 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 80 |
+
required
|
| 81 |
+
/>
|
| 82 |
+
</div>
|
| 83 |
+
<div>
|
| 84 |
+
<label className="input-label">Password</label>
|
| 85 |
+
<input
|
| 86 |
+
type="password"
|
| 87 |
+
className="input-field"
|
| 88 |
+
placeholder="••••••••"
|
| 89 |
+
value={password}
|
| 90 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 91 |
+
required
|
| 92 |
+
/>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{error && (
|
| 96 |
+
<div
|
| 97 |
+
className="text-sm px-3 py-2 rounded"
|
| 98 |
+
style={{
|
| 99 |
+
color: "var(--error)",
|
| 100 |
+
background: "var(--error-bg)",
|
| 101 |
+
border: "1px solid rgba(248, 113, 113, 0.2)",
|
| 102 |
+
}}
|
| 103 |
+
>
|
| 104 |
+
{error}
|
| 105 |
+
</div>
|
| 106 |
+
)}
|
| 107 |
+
|
| 108 |
+
<button
|
| 109 |
+
type="submit"
|
| 110 |
+
disabled={loading}
|
| 111 |
+
className="btn-primary w-full"
|
| 112 |
+
style={{ padding: "12px 20px" }}
|
| 113 |
+
>
|
| 114 |
+
{loading ? (
|
| 115 |
+
<span className="animate-spin-slow inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
|
| 116 |
+
) : (
|
| 117 |
+
"Sign In"
|
| 118 |
+
)}
|
| 119 |
+
</button>
|
| 120 |
+
</form>
|
| 121 |
+
|
| 122 |
+
<p
|
| 123 |
+
className="text-sm mt-6 text-center"
|
| 124 |
+
style={{ color: "var(--text-muted)" }}
|
| 125 |
+
>
|
| 126 |
+
No account?{" "}
|
| 127 |
+
<a
|
| 128 |
+
href="/register"
|
| 129 |
+
className="font-medium transition-colors"
|
| 130 |
+
style={{ color: "var(--primary)" }}
|
| 131 |
+
>
|
| 132 |
+
Create one
|
| 133 |
+
</a>
|
| 134 |
+
</p>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
}
|
frontend/src/app/onboarding/page.tsx
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { API_BASE } from "@/lib/api";
|
| 6 |
+
import DashboardLayout from "@/components/DashboardLayout";
|
| 7 |
+
import GlassCard from "@/components/ui/GlassCard";
|
| 8 |
+
|
| 9 |
+
type AnalysisResult = { suggested_industry: string; suggested_departments: string[]; suggested_size: string; rationale: string };
|
| 10 |
+
|
| 11 |
+
const STEP_LABELS = ["Company", "Upload", "Configure", "Compile"];
|
| 12 |
+
|
| 13 |
+
export default function OnboardingWizard() {
|
| 14 |
+
const [step, setStep] = useState(1);
|
| 15 |
+
const [companyName, setCompanyName] = useState("");
|
| 16 |
+
const [companyId, setCompanyId] = useState("");
|
| 17 |
+
const [files, setFiles] = useState<FileList | null>(null);
|
| 18 |
+
const [uploading, setUploading] = useState(false);
|
| 19 |
+
const [analyzing, setAnalyzing] = useState(false);
|
| 20 |
+
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
|
| 21 |
+
const [compiling, setCompiling] = useState(false);
|
| 22 |
+
const [error, setError] = useState("");
|
| 23 |
+
const [skippedUpload, setSkippedUpload] = useState(false);
|
| 24 |
+
const [showSkipOptions, setShowSkipOptions] = useState(false);
|
| 25 |
+
const [loadingSamples, setLoadingSamples] = useState(false);
|
| 26 |
+
const [sourceCount, setSourceCount] = useState(0);
|
| 27 |
+
const [manualIndustry, setManualIndustry] = useState("");
|
| 28 |
+
const [manualSize, setManualSize] = useState("");
|
| 29 |
+
const [manualDescription, setManualDescription] = useState("");
|
| 30 |
+
const router = useRouter();
|
| 31 |
+
|
| 32 |
+
const generateId = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
| 33 |
+
|
| 34 |
+
const handleBack = () => { setError(""); if (step > 1) { setStep(step - 1); if (step === 3) setShowSkipOptions(false); } else router.push("/"); };
|
| 35 |
+
|
| 36 |
+
const handleNameSubmit = async () => {
|
| 37 |
+
if (!companyName.trim()) return;
|
| 38 |
+
const id = generateId(companyName);
|
| 39 |
+
setCompanyId(id); setError("");
|
| 40 |
+
try { await fetch(`${API_BASE}/companies/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: companyName }) }); } catch { /* ok */ }
|
| 41 |
+
// Persist so dashboard auto-loads this company
|
| 42 |
+
sessionStorage.setItem("kernl_company_id", id);
|
| 43 |
+
setStep(2);
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const handleUpload = async () => {
|
| 47 |
+
if (!files || files.length === 0) return;
|
| 48 |
+
setUploading(true); setError("");
|
| 49 |
+
try {
|
| 50 |
+
for (const file of Array.from(files)) { const form = new FormData(); form.append("company_id", companyId); form.append("file", file); const res = await fetch(`${API_BASE}/sources/upload`, { method: "POST", body: form }); if (!res.ok) throw new Error(`Failed: ${file.name}`); }
|
| 51 |
+
setSourceCount(files.length); setSkippedUpload(false); setStep(3);
|
| 52 |
+
} catch (err: unknown) { setError(err instanceof Error ? err.message : "Upload failed"); } finally { setUploading(false); }
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const handleLoadSamples = async () => {
|
| 56 |
+
setLoadingSamples(true); setError("");
|
| 57 |
+
try { const res = await fetch(`${API_BASE}/companies/${companyId}/load-samples`, { method: "POST" }); if (!res.ok) throw new Error("Failed to load sample data"); const data = await res.json(); setSourceCount(data.count || 0); setSkippedUpload(false); setShowSkipOptions(false); setStep(3); }
|
| 58 |
+
catch (err: unknown) { setError(err instanceof Error ? err.message : "Failed to load samples"); } finally { setLoadingSamples(false); }
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const handleSkipToManual = () => { setSkippedUpload(true); setShowSkipOptions(false); setSourceCount(0); setStep(3); };
|
| 62 |
+
|
| 63 |
+
const handleAnalyze = async () => {
|
| 64 |
+
setAnalyzing(true); setError("");
|
| 65 |
+
try { const res = await fetch(`${API_BASE}/onboarding/analyze`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: companyId }) }); if (!res.ok) throw new Error("Analysis failed"); setAnalysis(await res.json()); }
|
| 66 |
+
catch (err: unknown) { setError(err instanceof Error ? err.message : "Analysis failed"); } finally { setAnalyzing(false); }
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const handleSaveProfile = async () => {
|
| 70 |
+
setError(""); const payload: Record<string, string> = {};
|
| 71 |
+
if (analysis) { if (analysis.suggested_industry) payload.industry = analysis.suggested_industry; if (analysis.suggested_size) payload.company_size = analysis.suggested_size; if (analysis.rationale) payload.description = analysis.rationale; }
|
| 72 |
+
else { if (manualIndustry) payload.industry = manualIndustry; if (manualSize) payload.company_size = manualSize; if (manualDescription) payload.description = manualDescription; }
|
| 73 |
+
if (Object.keys(payload).length > 0) { try { await fetch(`${API_BASE}/companies/${companyId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); } catch (err) { console.warn("Failed to save:", err); } }
|
| 74 |
+
setStep(4);
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const handleCompile = async () => {
|
| 78 |
+
setCompiling(true); setError("");
|
| 79 |
+
try { const res = await fetch(`${API_BASE}/compile`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: companyId }) }); const data = await res.json(); if (data.job_id) router.push(`/compile/${data.job_id}`); }
|
| 80 |
+
catch { setError("Failed to start compilation"); setCompiling(false); }
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
return (
|
| 84 |
+
<DashboardLayout>
|
| 85 |
+
<div className="p-6 lg:p-8 max-w-2xl mx-auto animate-fade-in">
|
| 86 |
+
{/* Header */}
|
| 87 |
+
<div className="flex items-center gap-3 mb-6">
|
| 88 |
+
<button onClick={handleBack} className="btn-ghost" style={{ padding: "6px 12px" }}>
|
| 89 |
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 2L4 7l5 5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
| 90 |
+
{step > 1 ? "Back" : "Home"}
|
| 91 |
+
</button>
|
| 92 |
+
<h1 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>Onboarding</h1>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{/* Progress Steps */}
|
| 96 |
+
<div className="flex mb-8 gap-1">
|
| 97 |
+
{STEP_LABELS.map((label, i) => (
|
| 98 |
+
<div key={label} className="flex-1">
|
| 99 |
+
<div className="flex items-center gap-2 mb-1.5">
|
| 100 |
+
<span className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold" style={{
|
| 101 |
+
background: step > i + 1 || step === i + 1 ? "var(--primary)" : "rgba(255,255,255,0.05)",
|
| 102 |
+
color: step > i + 1 || step === i + 1 ? "var(--text-inverse)" : "var(--text-muted)",
|
| 103 |
+
}}>{step > i + 1 ? "✓" : i + 1}</span>
|
| 104 |
+
<span className="text-xs font-medium" style={{ color: step === i + 1 ? "var(--primary)" : "var(--text-muted)" }}>{label}</span>
|
| 105 |
+
</div>
|
| 106 |
+
<div className="h-0.5 rounded-full" style={{ background: step > i + 1 ? "var(--primary)" : step === i + 1 ? "var(--primary-dim)" : "var(--border)" }} />
|
| 107 |
+
</div>
|
| 108 |
+
))}
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{/* Error */}
|
| 112 |
+
{error && (
|
| 113 |
+
<div className="flex items-center justify-between mb-6 p-3 rounded text-sm" style={{ background: "var(--error-bg)", color: "var(--error)", border: "1px solid rgba(248,113,113,0.2)" }}>
|
| 114 |
+
<span>{error}</span>
|
| 115 |
+
<button onClick={() => setError("")} style={{ color: "var(--error)" }}>✕</button>
|
| 116 |
+
</div>
|
| 117 |
+
)}
|
| 118 |
+
|
| 119 |
+
{/* Step 1 */}
|
| 120 |
+
{step === 1 && (
|
| 121 |
+
<GlassCard elevated padding="lg">
|
| 122 |
+
<h2 className="text-lg font-bold mb-4" style={{ color: "var(--text-primary)" }}>Name your company</h2>
|
| 123 |
+
<input type="text" className="input-field mb-3" placeholder="e.g. Rivanly Inc." value={companyName} onChange={(e) => setCompanyName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && companyName && handleNameSubmit()} />
|
| 124 |
+
{companyName && <p className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>ID: <span className="font-mono" style={{ color: "var(--primary)" }}>{generateId(companyName)}</span></p>}
|
| 125 |
+
<button onClick={handleNameSubmit} disabled={!companyName.trim()} className="btn-primary">Next →</button>
|
| 126 |
+
</GlassCard>
|
| 127 |
+
)}
|
| 128 |
+
|
| 129 |
+
{/* Step 2 */}
|
| 130 |
+
{step === 2 && (
|
| 131 |
+
<GlassCard elevated padding="lg">
|
| 132 |
+
<h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>Upload source documents</h2>
|
| 133 |
+
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>SOPs, Slack exports, Zendesk tickets — anything with operational knowledge.</p>
|
| 134 |
+
<input type="file" multiple onChange={(e) => setFiles(e.target.files)} className="w-full text-sm mb-4 file:mr-4 file:py-2 file:px-4 file:border-0 file:rounded file:font-medium" style={{ color: "var(--text-secondary)" }} />
|
| 135 |
+
<div className="flex gap-3 mb-4">
|
| 136 |
+
<button onClick={handleUpload} disabled={!files || uploading} className="btn-primary">{uploading ? "Uploading..." : "Upload & Continue"}</button>
|
| 137 |
+
<button onClick={() => setShowSkipOptions(true)} className="btn-secondary">Skip</button>
|
| 138 |
+
</div>
|
| 139 |
+
{showSkipOptions && (
|
| 140 |
+
<div className="space-y-3 p-4 rounded" style={{ background: "var(--bg-input)", border: "1px solid var(--border)" }}>
|
| 141 |
+
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No files? Choose how to proceed:</p>
|
| 142 |
+
<button onClick={handleLoadSamples} disabled={loadingSamples} className="w-full text-left p-3 rounded transition-colors" style={{ background: "var(--primary-ghost)", border: "1px solid rgba(0,210,180,0.2)", color: "var(--primary)" }}>
|
| 143 |
+
<span className="font-semibold">{loadingSamples ? "Loading..." : "Load Sample Playbooks"}</span>
|
| 144 |
+
<span className="block text-xs mt-1" style={{ color: "var(--text-muted)" }}>Pre-configured demo data</span>
|
| 145 |
+
</button>
|
| 146 |
+
<button onClick={handleSkipToManual} className="w-full text-left p-3 rounded transition-colors" style={{ background: "rgba(255,255,255,0.03)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
| 147 |
+
<span className="font-semibold">Configure Manually</span>
|
| 148 |
+
<span className="block text-xs mt-1" style={{ color: "var(--text-muted)" }}>Set up by hand</span>
|
| 149 |
+
</button>
|
| 150 |
+
<button onClick={() => setShowSkipOptions(false)} className="text-sm" style={{ color: "var(--text-muted)" }}>Cancel</button>
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
</GlassCard>
|
| 154 |
+
)}
|
| 155 |
+
|
| 156 |
+
{/* Step 3 */}
|
| 157 |
+
{step === 3 && (
|
| 158 |
+
<div className="space-y-6">
|
| 159 |
+
{!skippedUpload && (
|
| 160 |
+
<GlassCard elevated padding="lg">
|
| 161 |
+
<h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>AI Analysis</h2>
|
| 162 |
+
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>Analyze your {sourceCount} document{sourceCount !== 1 ? "s" : ""} to suggest profile settings.</p>
|
| 163 |
+
{!analysis ? (
|
| 164 |
+
<div className="flex gap-3">
|
| 165 |
+
<button onClick={handleAnalyze} disabled={analyzing} className="btn-primary">{analyzing ? "Analyzing..." : "Analyze Documents"}</button>
|
| 166 |
+
<button onClick={handleSaveProfile} className="btn-secondary">Skip to Compile →</button>
|
| 167 |
+
</div>
|
| 168 |
+
) : (
|
| 169 |
+
<div className="space-y-4">
|
| 170 |
+
<div className="grid grid-cols-3 gap-3">
|
| 171 |
+
{[{ label: "Industry", val: analysis.suggested_industry }, { label: "Size", val: analysis.suggested_size }, { label: "Depts", val: String(analysis.suggested_departments.length) }].map(({ label, val }) => (
|
| 172 |
+
<div key={label} className="p-3 rounded" style={{ background: "var(--bg-input)", border: "1px solid var(--border)" }}>
|
| 173 |
+
<p className="text-[10px] uppercase tracking-wider font-mono" style={{ color: "var(--text-muted)" }}>{label}</p>
|
| 174 |
+
<p className="font-bold" style={{ color: "var(--text-primary)" }}>{val}</p>
|
| 175 |
+
</div>
|
| 176 |
+
))}
|
| 177 |
+
</div>
|
| 178 |
+
{analysis.suggested_departments.length > 0 && <div className="flex flex-wrap gap-2">{analysis.suggested_departments.map((d) => <span key={d} className="badge badge--primary">{d}</span>)}</div>}
|
| 179 |
+
{analysis.rationale && <p className="text-sm p-3 rounded" style={{ color: "var(--text-muted)", background: "var(--bg-input)", border: "1px solid var(--border)" }}>{analysis.rationale}</p>}
|
| 180 |
+
<button onClick={handleSaveProfile} className="btn-primary">Save & Continue</button>
|
| 181 |
+
</div>
|
| 182 |
+
)}
|
| 183 |
+
</GlassCard>
|
| 184 |
+
)}
|
| 185 |
+
{skippedUpload && (
|
| 186 |
+
<GlassCard elevated padding="lg">
|
| 187 |
+
<h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>Company Profile</h2>
|
| 188 |
+
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>Set up manually. You can update later.</p>
|
| 189 |
+
<div className="space-y-4">
|
| 190 |
+
<div><label className="input-label">Industry</label><select value={manualIndustry} onChange={(e) => setManualIndustry(e.target.value)} className="input-field"><option value="">Select industry...</option>{["SaaS", "E-commerce", "FinTech", "HealthTech", "EdTech", "Consulting", "Manufacturing", "Other"].map(v => <option key={v} value={v}>{v}</option>)}</select></div>
|
| 191 |
+
<div><label className="input-label">Size</label><select value={manualSize} onChange={(e) => setManualSize(e.target.value)} className="input-field"><option value="">Select size...</option>{["1-10", "11-50", "51-200", "201+"].map(v => <option key={v} value={v}>{v} employees</option>)}</select></div>
|
| 192 |
+
<div><label className="input-label">Description</label><textarea value={manualDescription} onChange={(e) => setManualDescription(e.target.value)} placeholder="Brief description..." className="input-field" style={{ minHeight: "80px" }} /></div>
|
| 193 |
+
</div>
|
| 194 |
+
<div className="flex gap-3 mt-6">
|
| 195 |
+
<button onClick={handleSaveProfile} className="btn-primary">Save & Continue →</button>
|
| 196 |
+
<button onClick={() => setStep(4)} className="btn-secondary">Skip to Compile</button>
|
| 197 |
+
</div>
|
| 198 |
+
</GlassCard>
|
| 199 |
+
)}
|
| 200 |
+
</div>
|
| 201 |
+
)}
|
| 202 |
+
|
| 203 |
+
{/* Step 4 */}
|
| 204 |
+
{step === 4 && (
|
| 205 |
+
<GlassCard elevated padding="lg" className="text-center">
|
| 206 |
+
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style={{ background: "var(--primary-ghost)", color: "var(--primary)" }}>
|
| 207 |
+
<svg width="28" height="28" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="10" cy="10" r="7" /><path d="M10 6v4l3 2" strokeLinecap="round" /></svg>
|
| 208 |
+
</div>
|
| 209 |
+
<h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>Ready to compile</h2>
|
| 210 |
+
<p className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>
|
| 211 |
+
Company <span className="font-mono" style={{ color: "var(--primary)" }}>{companyId}</span>
|
| 212 |
+
{sourceCount > 0 && <> has <span className="font-bold" style={{ color: "var(--primary)" }}>{sourceCount}</span> source document{sourceCount !== 1 ? "s" : ""}</>}
|
| 213 |
+
. Compile your brain now.
|
| 214 |
+
</p>
|
| 215 |
+
<div className="flex gap-3 justify-center">
|
| 216 |
+
<button onClick={handleCompile} disabled={compiling} className="btn-primary">{compiling ? "Starting..." : "Compile Brain"}</button>
|
| 217 |
+
<button onClick={() => router.push(`/skills/${companyId}`)} className="btn-secondary">View Skills</button>
|
| 218 |
+
</div>
|
| 219 |
+
</GlassCard>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
</DashboardLayout>
|
| 223 |
+
);
|
| 224 |
+
}
|
frontend/src/app/page.tsx
CHANGED
|
@@ -1,90 +1,342 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export default function Dashboard() {
|
| 7 |
const [companyId, setCompanyId] = useState("");
|
| 8 |
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const router = useRouter();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
const handleCompile = async () => {
|
| 12 |
if (!companyId) return;
|
| 13 |
setLoading(true);
|
| 14 |
try {
|
| 15 |
-
const res = await fetch(
|
| 16 |
method: "POST",
|
| 17 |
headers: { "Content-Type": "application/json" },
|
| 18 |
body: JSON.stringify({ company_id: companyId }),
|
| 19 |
});
|
| 20 |
const data = await res.json();
|
| 21 |
-
if (data.job_id) {
|
| 22 |
-
|
| 23 |
-
}
|
| 24 |
-
} catch (err) {
|
| 25 |
-
console.error(err);
|
| 26 |
alert("Failed to start compilation");
|
| 27 |
} finally {
|
| 28 |
setLoading(false);
|
| 29 |
}
|
| 30 |
};
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
}
|
| 36 |
-
};
|
| 37 |
-
|
| 38 |
-
const handleViewSkills = () => {
|
| 39 |
-
if (companyId) {
|
| 40 |
-
router.push(`/skills/${companyId}`);
|
| 41 |
-
}
|
| 42 |
-
};
|
| 43 |
|
| 44 |
return (
|
| 45 |
-
<
|
| 46 |
-
<div className="
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</div>
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
className="
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
</div>
|
| 88 |
-
</
|
| 89 |
);
|
| 90 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState, useEffect, useCallback } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
+
import { API_BASE } from "@/lib/api";
|
| 6 |
+
import { useAuth } from "@/lib/auth";
|
| 7 |
+
import DashboardLayout from "@/components/DashboardLayout";
|
| 8 |
+
import StatCard from "@/components/ui/StatCard";
|
| 9 |
+
import GlassCard from "@/components/ui/GlassCard";
|
| 10 |
+
|
| 11 |
+
type CompanyInfo = {
|
| 12 |
+
id: string;
|
| 13 |
+
name?: string;
|
| 14 |
+
industry?: string;
|
| 15 |
+
company_size?: string;
|
| 16 |
+
skill_count?: number;
|
| 17 |
+
source_count?: number;
|
| 18 |
+
last_compile?: { completed_at: string; result_version: string } | null;
|
| 19 |
+
};
|
| 20 |
|
| 21 |
export default function Dashboard() {
|
| 22 |
const [companyId, setCompanyId] = useState("");
|
| 23 |
const [loading, setLoading] = useState(false);
|
| 24 |
+
const [company, setCompany] = useState<CompanyInfo | null>(null);
|
| 25 |
+
const [fetching, setFetching] = useState(false);
|
| 26 |
+
const [error, setError] = useState("");
|
| 27 |
+
const [autoLoaded, setAutoLoaded] = useState(false);
|
| 28 |
const router = useRouter();
|
| 29 |
+
const { user } = useAuth();
|
| 30 |
+
|
| 31 |
+
const fetchCompany = useCallback(async (id: string) => {
|
| 32 |
+
if (!id) return;
|
| 33 |
+
setFetching(true);
|
| 34 |
+
setError("");
|
| 35 |
+
try {
|
| 36 |
+
const res = await fetch(`${API_BASE}/companies/${id}`);
|
| 37 |
+
if (!res.ok) throw new Error("Not found");
|
| 38 |
+
const data = await res.json();
|
| 39 |
+
setCompany(data);
|
| 40 |
+
// Persist the company ID so it auto-loads next time
|
| 41 |
+
sessionStorage.setItem("kernl_company_id", id);
|
| 42 |
+
} catch {
|
| 43 |
+
setCompany(null);
|
| 44 |
+
setError("Company not found. Create it first via Onboarding.");
|
| 45 |
+
} finally {
|
| 46 |
+
setFetching(false);
|
| 47 |
+
}
|
| 48 |
+
}, []);
|
| 49 |
+
|
| 50 |
+
// Auto-load saved company when user is signed in
|
| 51 |
+
useEffect(() => {
|
| 52 |
+
if (autoLoaded) return;
|
| 53 |
+
const savedCompanyId = sessionStorage.getItem("kernl_company_id");
|
| 54 |
+
if (savedCompanyId) {
|
| 55 |
+
setCompanyId(savedCompanyId);
|
| 56 |
+
fetchCompany(savedCompanyId);
|
| 57 |
+
setAutoLoaded(true);
|
| 58 |
+
}
|
| 59 |
+
}, [autoLoaded, fetchCompany]);
|
| 60 |
|
| 61 |
const handleCompile = async () => {
|
| 62 |
if (!companyId) return;
|
| 63 |
setLoading(true);
|
| 64 |
try {
|
| 65 |
+
const res = await fetch(`${API_BASE}/compile`, {
|
| 66 |
method: "POST",
|
| 67 |
headers: { "Content-Type": "application/json" },
|
| 68 |
body: JSON.stringify({ company_id: companyId }),
|
| 69 |
});
|
| 70 |
const data = await res.json();
|
| 71 |
+
if (data.job_id) router.push(`/compile/${data.job_id}`);
|
| 72 |
+
} catch {
|
|
|
|
|
|
|
|
|
|
| 73 |
alert("Failed to start compilation");
|
| 74 |
} finally {
|
| 75 |
setLoading(false);
|
| 76 |
}
|
| 77 |
};
|
| 78 |
|
| 79 |
+
// Determine if we should show the lookup section
|
| 80 |
+
// Hide it when we have a loaded company (auto or manual)
|
| 81 |
+
const showLookup = !company;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
return (
|
| 84 |
+
<DashboardLayout>
|
| 85 |
+
<div className="p-6 lg:p-8 max-w-6xl mx-auto animate-fade-in">
|
| 86 |
+
{/* Page Header */}
|
| 87 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
|
| 88 |
+
<div>
|
| 89 |
+
<h1
|
| 90 |
+
className="text-2xl font-bold tracking-tight"
|
| 91 |
+
style={{ color: "var(--text-primary)" }}
|
| 92 |
+
>
|
| 93 |
+
{company ? company.name || companyId : "Command Center"}
|
| 94 |
+
</h1>
|
| 95 |
+
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
| 96 |
+
{company
|
| 97 |
+
? "Operational brain dashboard"
|
| 98 |
+
: "Compile operational knowledge into an executable AI brain"}
|
| 99 |
+
</p>
|
| 100 |
+
</div>
|
| 101 |
+
<div className="flex gap-3">
|
| 102 |
+
{company && (
|
| 103 |
+
<button
|
| 104 |
+
onClick={() => {
|
| 105 |
+
setCompany(null);
|
| 106 |
+
setCompanyId("");
|
| 107 |
+
setError("");
|
| 108 |
+
sessionStorage.removeItem("kernl_company_id");
|
| 109 |
+
}}
|
| 110 |
+
className="btn-ghost"
|
| 111 |
+
style={{ fontSize: "13px", padding: "8px 14px" }}
|
| 112 |
+
>
|
| 113 |
+
Switch Company
|
| 114 |
+
</button>
|
| 115 |
+
)}
|
| 116 |
+
<button
|
| 117 |
+
onClick={() => router.push("/onboarding")}
|
| 118 |
+
className="btn-secondary"
|
| 119 |
+
>
|
| 120 |
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2">
|
| 121 |
+
<path d="M7 1v12M1 7h12" strokeLinecap="round" />
|
| 122 |
+
</svg>
|
| 123 |
+
New Company
|
| 124 |
+
</button>
|
| 125 |
+
</div>
|
| 126 |
</div>
|
| 127 |
|
| 128 |
+
{/* Company Lookup — only visible when no company is loaded */}
|
| 129 |
+
{showLookup && (
|
| 130 |
+
<GlassCard className="mb-8">
|
| 131 |
+
<label className="input-label">Company ID</label>
|
| 132 |
+
<div className="flex gap-3">
|
| 133 |
+
<input
|
| 134 |
+
type="text"
|
| 135 |
+
className="input-field input-field--mono flex-1"
|
| 136 |
+
placeholder="e.g. rivanly-inc"
|
| 137 |
+
value={companyId}
|
| 138 |
+
onChange={(e) => setCompanyId(e.target.value)}
|
| 139 |
+
onKeyDown={(e) =>
|
| 140 |
+
e.key === "Enter" && companyId && fetchCompany(companyId)
|
| 141 |
+
}
|
| 142 |
+
/>
|
| 143 |
+
<button
|
| 144 |
+
onClick={() => fetchCompany(companyId)}
|
| 145 |
+
disabled={!companyId || fetching}
|
| 146 |
+
className="btn-primary"
|
| 147 |
+
>
|
| 148 |
+
{fetching ? (
|
| 149 |
+
<span className="animate-spin-slow inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
|
| 150 |
+
) : (
|
| 151 |
+
"Load"
|
| 152 |
+
)}
|
| 153 |
+
</button>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
{error && (
|
| 157 |
+
<p className="text-xs mt-2" style={{ color: "var(--error)" }}>
|
| 158 |
+
{error}
|
| 159 |
+
</p>
|
| 160 |
+
)}
|
| 161 |
+
|
| 162 |
+
{!company && !error && (
|
| 163 |
+
<p className="text-xs mt-3" style={{ color: "var(--text-muted)" }}>
|
| 164 |
+
Try:{" "}
|
| 165 |
+
<button
|
| 166 |
+
onClick={() => {
|
| 167 |
+
setCompanyId("rivanly-inc");
|
| 168 |
+
fetchCompany("rivanly-inc");
|
| 169 |
+
}}
|
| 170 |
+
className="font-mono transition-colors"
|
| 171 |
+
style={{ color: "var(--primary)" }}
|
| 172 |
+
>
|
| 173 |
+
rivanly-inc
|
| 174 |
+
</button>{" "}
|
| 175 |
+
(pre-loaded demo company)
|
| 176 |
+
</p>
|
| 177 |
+
)}
|
| 178 |
+
</GlassCard>
|
| 179 |
+
)}
|
| 180 |
+
|
| 181 |
+
{/* Company Dashboard */}
|
| 182 |
+
{company && (
|
| 183 |
+
<div className="stagger-children">
|
| 184 |
+
{/* Stat Cards */}
|
| 185 |
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
| 186 |
+
<StatCard
|
| 187 |
+
label="Skills"
|
| 188 |
+
value={company.skill_count ?? 0}
|
| 189 |
+
accent="primary"
|
| 190 |
+
icon={
|
| 191 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 192 |
+
<path d="M2 4h12M2 8h8M2 12h10" strokeLinecap="round" />
|
| 193 |
+
</svg>
|
| 194 |
+
}
|
| 195 |
+
/>
|
| 196 |
+
<StatCard
|
| 197 |
+
label="Sources"
|
| 198 |
+
value={company.source_count ?? 0}
|
| 199 |
+
accent="info"
|
| 200 |
+
icon={
|
| 201 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 202 |
+
<rect x="3" y="2" width="10" height="12" rx="1.5" />
|
| 203 |
+
<path d="M6 5h4M6 8h4M6 11h2" strokeLinecap="round" />
|
| 204 |
+
</svg>
|
| 205 |
+
}
|
| 206 |
+
/>
|
| 207 |
+
<StatCard
|
| 208 |
+
label="Company Size"
|
| 209 |
+
value={company.company_size || "—"}
|
| 210 |
+
accent="warning"
|
| 211 |
+
icon={
|
| 212 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 213 |
+
<circle cx="8" cy="5" r="3" />
|
| 214 |
+
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" strokeLinecap="round" />
|
| 215 |
+
</svg>
|
| 216 |
+
}
|
| 217 |
+
/>
|
| 218 |
+
<StatCard
|
| 219 |
+
label="Industry"
|
| 220 |
+
value={company.industry || "—"}
|
| 221 |
+
accent="success"
|
| 222 |
+
icon={
|
| 223 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 224 |
+
<rect x="2" y="6" width="4" height="8" rx="0.5" />
|
| 225 |
+
<rect x="6" y="3" width="4" height="11" rx="0.5" />
|
| 226 |
+
<rect x="10" y="1" width="4" height="13" rx="0.5" />
|
| 227 |
+
</svg>
|
| 228 |
+
}
|
| 229 |
+
/>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
{/* Quick Actions */}
|
| 233 |
+
<GlassCard className="mb-6" elevated>
|
| 234 |
+
<p
|
| 235 |
+
className="text-[10px] font-semibold uppercase tracking-[0.08em] font-mono mb-4"
|
| 236 |
+
style={{ color: "var(--text-muted)" }}
|
| 237 |
+
>
|
| 238 |
+
Quick Actions
|
| 239 |
+
</p>
|
| 240 |
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
| 241 |
+
<button onClick={handleCompile} disabled={loading} className="btn-primary w-full">
|
| 242 |
+
{loading ? "Starting..." : "Compile Brain"}
|
| 243 |
+
</button>
|
| 244 |
+
<button
|
| 245 |
+
onClick={() => router.push(`/skills/${companyId}`)}
|
| 246 |
+
className="btn-secondary w-full"
|
| 247 |
+
>
|
| 248 |
+
View Skills
|
| 249 |
+
</button>
|
| 250 |
+
<button
|
| 251 |
+
onClick={() => router.push(`/demo/${companyId}`)}
|
| 252 |
+
className="btn-secondary w-full"
|
| 253 |
+
>
|
| 254 |
+
Query Agent
|
| 255 |
+
</button>
|
| 256 |
+
<button
|
| 257 |
+
onClick={() => {
|
| 258 |
+
const a = document.createElement("a");
|
| 259 |
+
a.href = `${API_BASE}/skills/${companyId}/download`;
|
| 260 |
+
a.click();
|
| 261 |
+
}}
|
| 262 |
+
className="btn-ghost w-full"
|
| 263 |
+
>
|
| 264 |
+
Download JSON
|
| 265 |
+
</button>
|
| 266 |
+
</div>
|
| 267 |
+
</GlassCard>
|
| 268 |
+
|
| 269 |
+
{/* Last Compilation */}
|
| 270 |
+
{company.last_compile && (
|
| 271 |
+
<GlassCard>
|
| 272 |
+
<div className="flex items-center gap-3">
|
| 273 |
+
<span
|
| 274 |
+
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
| 275 |
+
style={{
|
| 276 |
+
background: "var(--success-bg)",
|
| 277 |
+
color: "var(--success)",
|
| 278 |
+
}}
|
| 279 |
+
>
|
| 280 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
|
| 281 |
+
<path d="M3 8l4 4 6-8" strokeLinecap="round" strokeLinejoin="round" />
|
| 282 |
+
</svg>
|
| 283 |
+
</span>
|
| 284 |
+
<div>
|
| 285 |
+
<p
|
| 286 |
+
className="text-[10px] font-semibold uppercase tracking-[0.08em] font-mono"
|
| 287 |
+
style={{ color: "var(--text-muted)" }}
|
| 288 |
+
>
|
| 289 |
+
Last Compilation
|
| 290 |
+
</p>
|
| 291 |
+
<p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
|
| 292 |
+
{new Date(company.last_compile.completed_at).toLocaleString()}
|
| 293 |
+
{company.last_compile.result_version && (
|
| 294 |
+
<span
|
| 295 |
+
className="ml-2 font-mono text-xs"
|
| 296 |
+
style={{ color: "var(--text-muted)" }}
|
| 297 |
+
>
|
| 298 |
+
{company.last_compile.result_version}
|
| 299 |
+
</span>
|
| 300 |
+
)}
|
| 301 |
+
</p>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
</GlassCard>
|
| 305 |
+
)}
|
| 306 |
+
</div>
|
| 307 |
+
)}
|
| 308 |
+
|
| 309 |
+
{/* Empty State — only when no company ID has been entered and nothing loaded */}
|
| 310 |
+
{!company && !error && !companyId && (
|
| 311 |
+
<div className="empty-state animate-fade-up">
|
| 312 |
+
<div className="empty-state__icon">
|
| 313 |
+
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 314 |
+
<circle cx="14" cy="14" r="10" />
|
| 315 |
+
<path d="M14 9v5l3 3" strokeLinecap="round" strokeLinejoin="round" />
|
| 316 |
+
</svg>
|
| 317 |
+
</div>
|
| 318 |
+
<h2 className="text-xl font-bold mb-2" style={{ color: "var(--text-primary)" }}>
|
| 319 |
+
Your company's operational brain
|
| 320 |
+
</h2>
|
| 321 |
+
<p
|
| 322 |
+
className="text-sm mb-8 max-w-md mx-auto leading-relaxed"
|
| 323 |
+
style={{ color: "var(--text-secondary)" }}
|
| 324 |
+
>
|
| 325 |
+
Upload SOPs, Slack exports, and support tickets. Kernl compiles them into structured,
|
| 326 |
+
queryable skills that make your AI agent actually understand your business.
|
| 327 |
+
</p>
|
| 328 |
+
<button
|
| 329 |
+
onClick={() => router.push("/onboarding")}
|
| 330 |
+
className="btn-primary"
|
| 331 |
+
>
|
| 332 |
+
Get Started
|
| 333 |
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2">
|
| 334 |
+
<path d="M1 7h12M8 2l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
|
| 335 |
+
</svg>
|
| 336 |
+
</button>
|
| 337 |
+
</div>
|
| 338 |
+
)}
|
| 339 |
</div>
|
| 340 |
+
</DashboardLayout>
|
| 341 |
);
|
| 342 |
}
|
frontend/src/app/register/page.tsx
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { useAuth } from "@/lib/auth";
|
| 6 |
+
|
| 7 |
+
export default function RegisterPage() {
|
| 8 |
+
const [email, setEmail] = useState("");
|
| 9 |
+
const [password, setPassword] = useState("");
|
| 10 |
+
const [error, setError] = useState("");
|
| 11 |
+
const [loading, setLoading] = useState(false);
|
| 12 |
+
const { register, login } = useAuth();
|
| 13 |
+
const router = useRouter();
|
| 14 |
+
|
| 15 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 16 |
+
e.preventDefault();
|
| 17 |
+
setError("");
|
| 18 |
+
setLoading(true);
|
| 19 |
+
try {
|
| 20 |
+
await register(email, password);
|
| 21 |
+
// Auto-login after registration (works when Confirm Email is OFF)
|
| 22 |
+
try {
|
| 23 |
+
await login(email, password);
|
| 24 |
+
router.push("/");
|
| 25 |
+
return;
|
| 26 |
+
} catch {
|
| 27 |
+
// If auto-login fails, Confirm Email might be ON — show message
|
| 28 |
+
}
|
| 29 |
+
// Fallback: show confirmation message
|
| 30 |
+
setError("");
|
| 31 |
+
router.push("/login");
|
| 32 |
+
} catch (err: unknown) {
|
| 33 |
+
setError(err instanceof Error ? err.message : "Registration failed");
|
| 34 |
+
} finally {
|
| 35 |
+
setLoading(false);
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div className="min-h-screen flex items-center justify-center p-6 relative">
|
| 41 |
+
{/* Background mesh */}
|
| 42 |
+
<div className="mesh-gradient" />
|
| 43 |
+
|
| 44 |
+
<div
|
| 45 |
+
className="w-full max-w-sm animate-fade-up"
|
| 46 |
+
style={{ animationDelay: "0.1s" }}
|
| 47 |
+
>
|
| 48 |
+
{/* Logo */}
|
| 49 |
+
<div className="flex items-center justify-center mb-8">
|
| 50 |
+
<div
|
| 51 |
+
className="w-12 h-12 rounded-xl flex items-center justify-center animate-pulse-glow"
|
| 52 |
+
style={{
|
| 53 |
+
background: "var(--primary-ghost)",
|
| 54 |
+
color: "var(--primary)",
|
| 55 |
+
}}
|
| 56 |
+
>
|
| 57 |
+
<svg width="24" height="24" viewBox="0 0 18 18" fill="none">
|
| 58 |
+
<path
|
| 59 |
+
d="M3 2h3v14H3V2zm5 0h2l5 7-5 7H8l5-7-5-7z"
|
| 60 |
+
fill="currentColor"
|
| 61 |
+
/>
|
| 62 |
+
</svg>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div className="glass-card p-8">
|
| 67 |
+
<h1
|
| 68 |
+
className="text-xl font-bold mb-1 text-center"
|
| 69 |
+
style={{ color: "var(--text-primary)" }}
|
| 70 |
+
>
|
| 71 |
+
Create your account
|
| 72 |
+
</h1>
|
| 73 |
+
<p
|
| 74 |
+
className="text-sm text-center mb-6"
|
| 75 |
+
style={{ color: "var(--text-muted)" }}
|
| 76 |
+
>
|
| 77 |
+
Start compiling your company brain
|
| 78 |
+
</p>
|
| 79 |
+
|
| 80 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 81 |
+
<div>
|
| 82 |
+
<label className="input-label">Email</label>
|
| 83 |
+
<input
|
| 84 |
+
type="email"
|
| 85 |
+
className="input-field"
|
| 86 |
+
placeholder="you@company.com"
|
| 87 |
+
value={email}
|
| 88 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 89 |
+
required
|
| 90 |
+
/>
|
| 91 |
+
</div>
|
| 92 |
+
<div>
|
| 93 |
+
<label className="input-label">Password</label>
|
| 94 |
+
<input
|
| 95 |
+
type="password"
|
| 96 |
+
className="input-field"
|
| 97 |
+
placeholder="••••••••"
|
| 98 |
+
value={password}
|
| 99 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 100 |
+
required
|
| 101 |
+
minLength={6}
|
| 102 |
+
/>
|
| 103 |
+
<p
|
| 104 |
+
className="text-[11px] mt-1.5"
|
| 105 |
+
style={{ color: "var(--text-muted)" }}
|
| 106 |
+
>
|
| 107 |
+
Minimum 6 characters
|
| 108 |
+
</p>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{error && (
|
| 112 |
+
<div
|
| 113 |
+
className="text-sm px-3 py-2 rounded"
|
| 114 |
+
style={{
|
| 115 |
+
color: "var(--error)",
|
| 116 |
+
background: "var(--error-bg)",
|
| 117 |
+
border: "1px solid rgba(248, 113, 113, 0.2)",
|
| 118 |
+
}}
|
| 119 |
+
>
|
| 120 |
+
{error}
|
| 121 |
+
</div>
|
| 122 |
+
)}
|
| 123 |
+
|
| 124 |
+
<button
|
| 125 |
+
type="submit"
|
| 126 |
+
disabled={loading}
|
| 127 |
+
className="btn-primary w-full"
|
| 128 |
+
style={{ padding: "12px 20px" }}
|
| 129 |
+
>
|
| 130 |
+
{loading ? (
|
| 131 |
+
<span className="animate-spin-slow inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
|
| 132 |
+
) : (
|
| 133 |
+
"Create Account"
|
| 134 |
+
)}
|
| 135 |
+
</button>
|
| 136 |
+
</form>
|
| 137 |
+
|
| 138 |
+
<p
|
| 139 |
+
className="text-sm mt-6 text-center"
|
| 140 |
+
style={{ color: "var(--text-muted)" }}
|
| 141 |
+
>
|
| 142 |
+
Already have an account?{" "}
|
| 143 |
+
<a
|
| 144 |
+
href="/login"
|
| 145 |
+
className="font-medium transition-colors"
|
| 146 |
+
style={{ color: "var(--primary)" }}
|
| 147 |
+
>
|
| 148 |
+
Sign In
|
| 149 |
+
</a>
|
| 150 |
+
</p>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
);
|
| 155 |
+
}
|
frontend/src/app/skills/[companyId]/page.tsx
CHANGED
|
@@ -2,6 +2,10 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useState, use } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
type Skill = {
|
| 7 |
id?: string;
|
|
@@ -10,6 +14,8 @@ type Skill = {
|
|
| 10 |
rationale?: string;
|
| 11 |
evidence?: string[];
|
| 12 |
confidence?: number;
|
|
|
|
|
|
|
| 13 |
};
|
| 14 |
|
| 15 |
type SkillsData = {
|
|
@@ -26,19 +32,17 @@ export default function SkillsViewer({ params }: { params: Promise<{ companyId:
|
|
| 26 |
const [loading, setLoading] = useState(true);
|
| 27 |
const [filter, setFilter] = useState("");
|
| 28 |
const [sortBy, setSortBy] = useState<"category" | "confidence">("category");
|
|
|
|
| 29 |
const router = useRouter();
|
| 30 |
|
| 31 |
useEffect(() => {
|
| 32 |
-
fetch(`
|
| 33 |
.then((res) => res.json())
|
| 34 |
.then((d) => {
|
| 35 |
setData(d);
|
| 36 |
setLoading(false);
|
| 37 |
})
|
| 38 |
-
.catch((
|
| 39 |
-
console.error(err);
|
| 40 |
-
setLoading(false);
|
| 41 |
-
});
|
| 42 |
}, [companyId]);
|
| 43 |
|
| 44 |
const skills = data?.skills || [];
|
|
@@ -47,116 +51,279 @@ export default function SkillsViewer({ params }: { params: Promise<{ companyId:
|
|
| 47 |
const filtered = skills
|
| 48 |
.filter((s) => {
|
| 49 |
if (!filter) return true;
|
| 50 |
-
return (s.category || "")
|
| 51 |
})
|
| 52 |
.sort((a, b) => {
|
| 53 |
if (sortBy === "confidence") return (b.confidence || 0) - (a.confidence || 0);
|
| 54 |
return (a.category || "").localeCompare(b.category || "");
|
| 55 |
});
|
| 56 |
|
| 57 |
-
const confidenceColor = (c: number) => {
|
| 58 |
-
if (c >= 0.8) return "text-green-400 border-green-400/30";
|
| 59 |
-
if (c >= 0.6) return "text-yellow-400 border-yellow-400/30";
|
| 60 |
-
if (c >= 0.4) return "text-orange-400 border-orange-400/30";
|
| 61 |
-
return "text-red-400 border-red-400/30";
|
| 62 |
-
};
|
| 63 |
-
|
| 64 |
return (
|
| 65 |
-
<
|
| 66 |
-
<div className="
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
<
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
)}
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
</div>
|
| 79 |
-
<button onClick={() => router.push("/")} className="text-text-secondary hover:text-foreground">
|
| 80 |
-
Back
|
| 81 |
-
</button>
|
| 82 |
-
</div>
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
|
| 107 |
-
<div className="flex-1 overflow-y-auto">
|
| 108 |
{loading ? (
|
| 109 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
) : filtered.length === 0 ? (
|
| 111 |
-
<div className="
|
| 112 |
-
<
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
</p>
|
|
|
|
|
|
|
|
|
|
| 116 |
</div>
|
| 117 |
) : (
|
| 118 |
-
<div className="grid grid-cols-1
|
| 119 |
{filtered.map((skill, i) => (
|
| 120 |
-
<
|
| 121 |
key={skill.id || i}
|
| 122 |
-
|
|
|
|
| 123 |
>
|
| 124 |
<div className="flex justify-between items-start mb-3">
|
| 125 |
-
<span className="
|
| 126 |
-
|
| 127 |
-
</span>
|
| 128 |
-
<span
|
| 129 |
-
className={`text-xs font-mono px-2 py-1 border rounded ${confidenceColor(
|
| 130 |
-
skill.confidence || 0
|
| 131 |
-
)}`}
|
| 132 |
-
>
|
| 133 |
-
{((skill.confidence || 0) * 100).toFixed(0)}%
|
| 134 |
-
</span>
|
| 135 |
</div>
|
| 136 |
|
| 137 |
-
<p className="text-
|
|
|
|
|
|
|
| 138 |
|
| 139 |
{skill.rationale && (
|
| 140 |
-
<p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
)}
|
| 142 |
|
| 143 |
{skill.evidence && skill.evidence.length > 0 && (
|
| 144 |
-
<div
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
</p>
|
| 152 |
-
))}
|
| 153 |
</div>
|
| 154 |
)}
|
| 155 |
-
</
|
| 156 |
))}
|
| 157 |
</div>
|
| 158 |
)}
|
| 159 |
</div>
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
);
|
| 162 |
}
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useState, use } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
+
import { API_BASE } from "@/lib/api";
|
| 6 |
+
import DashboardLayout from "@/components/DashboardLayout";
|
| 7 |
+
import GlassCard from "@/components/ui/GlassCard";
|
| 8 |
+
import ConfidenceBadge from "@/components/ui/ConfidenceBadge";
|
| 9 |
|
| 10 |
type Skill = {
|
| 11 |
id?: string;
|
|
|
|
| 14 |
rationale?: string;
|
| 15 |
evidence?: string[];
|
| 16 |
confidence?: number;
|
| 17 |
+
source_files?: string[];
|
| 18 |
+
embedding_vector?: number[];
|
| 19 |
};
|
| 20 |
|
| 21 |
type SkillsData = {
|
|
|
|
| 32 |
const [loading, setLoading] = useState(true);
|
| 33 |
const [filter, setFilter] = useState("");
|
| 34 |
const [sortBy, setSortBy] = useState<"category" | "confidence">("category");
|
| 35 |
+
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
| 36 |
const router = useRouter();
|
| 37 |
|
| 38 |
useEffect(() => {
|
| 39 |
+
fetch(`${API_BASE}/skills/${companyId}`)
|
| 40 |
.then((res) => res.json())
|
| 41 |
.then((d) => {
|
| 42 |
setData(d);
|
| 43 |
setLoading(false);
|
| 44 |
})
|
| 45 |
+
.catch(() => setLoading(false));
|
|
|
|
|
|
|
|
|
|
| 46 |
}, [companyId]);
|
| 47 |
|
| 48 |
const skills = data?.skills || [];
|
|
|
|
| 51 |
const filtered = skills
|
| 52 |
.filter((s) => {
|
| 53 |
if (!filter) return true;
|
| 54 |
+
return (s.category || "") === filter;
|
| 55 |
})
|
| 56 |
.sort((a, b) => {
|
| 57 |
if (sortBy === "confidence") return (b.confidence || 0) - (a.confidence || 0);
|
| 58 |
return (a.category || "").localeCompare(b.category || "");
|
| 59 |
});
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return (
|
| 62 |
+
<DashboardLayout>
|
| 63 |
+
<div className="p-6 lg:p-8 max-w-7xl mx-auto animate-fade-in">
|
| 64 |
+
{/* Header */}
|
| 65 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
| 66 |
+
<div>
|
| 67 |
+
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
|
| 68 |
+
Skills Explorer
|
| 69 |
+
</h1>
|
| 70 |
+
{data?.version && (
|
| 71 |
+
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
| 72 |
+
<span className="font-mono" style={{ color: "var(--primary)" }}>
|
| 73 |
+
{data.version}
|
| 74 |
+
</span>
|
| 75 |
+
{data.compiled_at && (
|
| 76 |
+
<> · {new Date(data.compiled_at).toLocaleDateString()}</>
|
| 77 |
+
)}
|
| 78 |
+
{" · "}
|
| 79 |
+
<span style={{ color: "var(--text-secondary)" }}>{skills.length} skills</span>
|
| 80 |
+
</p>
|
| 81 |
+
)}
|
| 82 |
+
</div>
|
| 83 |
+
<button
|
| 84 |
+
onClick={() => router.push(`/demo/${companyId}`)}
|
| 85 |
+
className="btn-primary"
|
| 86 |
+
>
|
| 87 |
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 88 |
+
<circle cx="7" cy="7" r="5.5" />
|
| 89 |
+
<path d="M7 4v3l2 1.5" strokeLinecap="round" />
|
| 90 |
+
</svg>
|
| 91 |
+
Query Agent
|
| 92 |
+
</button>
|
| 93 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
+
{/* Filter Chips */}
|
| 96 |
+
<div className="flex gap-2 flex-wrap mb-6">
|
| 97 |
+
<button
|
| 98 |
+
onClick={() => setFilter("")}
|
| 99 |
+
className="badge transition-all"
|
| 100 |
+
style={{
|
| 101 |
+
background: !filter ? "var(--primary-ghost)" : "transparent",
|
| 102 |
+
color: !filter ? "var(--primary)" : "var(--text-muted)",
|
| 103 |
+
border: `1px solid ${!filter ? "rgba(0,210,180,0.2)" : "var(--border)"}`,
|
| 104 |
+
cursor: "pointer",
|
| 105 |
+
}}
|
| 106 |
+
>
|
| 107 |
+
All ({skills.length})
|
| 108 |
+
</button>
|
| 109 |
+
{categories.map((cat) => {
|
| 110 |
+
const count = skills.filter((s) => (s.category || "Unknown") === cat).length;
|
| 111 |
+
const active = filter === cat;
|
| 112 |
+
return (
|
| 113 |
+
<button
|
| 114 |
+
key={cat}
|
| 115 |
+
onClick={() => setFilter(active ? "" : cat)}
|
| 116 |
+
className="badge transition-all"
|
| 117 |
+
style={{
|
| 118 |
+
background: active ? "var(--primary-ghost)" : "transparent",
|
| 119 |
+
color: active ? "var(--primary)" : "var(--text-muted)",
|
| 120 |
+
border: `1px solid ${active ? "rgba(0,210,180,0.2)" : "var(--border)"}`,
|
| 121 |
+
cursor: "pointer",
|
| 122 |
+
}}
|
| 123 |
+
>
|
| 124 |
+
{cat} ({count})
|
| 125 |
+
</button>
|
| 126 |
+
);
|
| 127 |
+
})}
|
| 128 |
+
|
| 129 |
+
{/* Sort toggle */}
|
| 130 |
+
<div className="ml-auto">
|
| 131 |
+
<button
|
| 132 |
+
onClick={() => setSortBy(sortBy === "category" ? "confidence" : "category")}
|
| 133 |
+
className="badge transition-all"
|
| 134 |
+
style={{
|
| 135 |
+
background: "transparent",
|
| 136 |
+
color: "var(--text-secondary)",
|
| 137 |
+
border: "1px solid var(--border)",
|
| 138 |
+
cursor: "pointer",
|
| 139 |
+
}}
|
| 140 |
+
>
|
| 141 |
+
Sort: {sortBy === "category" ? "Category" : "Confidence"}
|
| 142 |
+
</button>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
|
| 146 |
+
{/* Skills Grid */}
|
|
|
|
| 147 |
{loading ? (
|
| 148 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 149 |
+
{[1, 2, 3, 4, 5, 6].map((i) => (
|
| 150 |
+
<div key={i} className="glass-card p-5 animate-shimmer" style={{ height: "180px" }} />
|
| 151 |
+
))}
|
| 152 |
+
</div>
|
| 153 |
) : filtered.length === 0 ? (
|
| 154 |
+
<div className="empty-state animate-fade-up">
|
| 155 |
+
<div className="empty-state__icon">
|
| 156 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 157 |
+
<rect x="4" y="4" width="16" height="16" rx="2" />
|
| 158 |
+
<path d="M9 9h6M9 12h4" strokeLinecap="round" />
|
| 159 |
+
</svg>
|
| 160 |
+
</div>
|
| 161 |
+
<h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>
|
| 162 |
+
No skills compiled yet
|
| 163 |
+
</h2>
|
| 164 |
+
<p className="text-sm mb-6" style={{ color: "var(--text-secondary)" }}>
|
| 165 |
+
Compile your company brain to generate skills from source documents.
|
| 166 |
</p>
|
| 167 |
+
<button onClick={() => router.push("/onboarding")} className="btn-primary">
|
| 168 |
+
Start Onboarding
|
| 169 |
+
</button>
|
| 170 |
</div>
|
| 171 |
) : (
|
| 172 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
|
| 173 |
{filtered.map((skill, i) => (
|
| 174 |
+
<GlassCard
|
| 175 |
key={skill.id || i}
|
| 176 |
+
interactive
|
| 177 |
+
onClick={() => setSelectedSkill(skill)}
|
| 178 |
>
|
| 179 |
<div className="flex justify-between items-start mb-3">
|
| 180 |
+
<span className="badge badge--primary">{skill.category || "Unknown"}</span>
|
| 181 |
+
<ConfidenceBadge value={skill.confidence || 0} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
</div>
|
| 183 |
|
| 184 |
+
<p className="text-sm font-medium mb-2" style={{ color: "var(--text-primary)" }}>
|
| 185 |
+
{skill.rule}
|
| 186 |
+
</p>
|
| 187 |
|
| 188 |
{skill.rationale && (
|
| 189 |
+
<p
|
| 190 |
+
className="text-xs leading-relaxed line-clamp-2 mb-3"
|
| 191 |
+
style={{ color: "var(--text-muted)" }}
|
| 192 |
+
>
|
| 193 |
+
{skill.rationale}
|
| 194 |
+
</p>
|
| 195 |
)}
|
| 196 |
|
| 197 |
{skill.evidence && skill.evidence.length > 0 && (
|
| 198 |
+
<div
|
| 199 |
+
className="pt-3 mt-auto"
|
| 200 |
+
style={{ borderTop: "1px solid var(--border)" }}
|
| 201 |
+
>
|
| 202 |
+
<p className="text-[10px] font-mono uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>
|
| 203 |
+
{skill.evidence.length} evidence source{skill.evidence.length !== 1 ? "s" : ""}
|
| 204 |
+
</p>
|
|
|
|
|
|
|
| 205 |
</div>
|
| 206 |
)}
|
| 207 |
+
</GlassCard>
|
| 208 |
))}
|
| 209 |
</div>
|
| 210 |
)}
|
| 211 |
</div>
|
| 212 |
+
|
| 213 |
+
{/* Detail Slide-in Panel */}
|
| 214 |
+
{selectedSkill && (
|
| 215 |
+
<div
|
| 216 |
+
className="fixed inset-0 z-50 flex"
|
| 217 |
+
onClick={() => setSelectedSkill(null)}
|
| 218 |
+
>
|
| 219 |
+
{/* Backdrop */}
|
| 220 |
+
<div
|
| 221 |
+
className="flex-1"
|
| 222 |
+
style={{ background: "rgba(0, 0, 0, 0.5)" }}
|
| 223 |
+
/>
|
| 224 |
+
|
| 225 |
+
{/* Panel */}
|
| 226 |
+
<div
|
| 227 |
+
className="w-full max-w-lg overflow-y-auto animate-slide-right"
|
| 228 |
+
style={{
|
| 229 |
+
background: "var(--bg-surface)",
|
| 230 |
+
borderLeft: "1px solid var(--border)",
|
| 231 |
+
}}
|
| 232 |
+
onClick={(e) => e.stopPropagation()}
|
| 233 |
+
>
|
| 234 |
+
{/* Panel Header */}
|
| 235 |
+
<div
|
| 236 |
+
className="sticky top-0 z-10 flex items-center justify-between px-6 py-4"
|
| 237 |
+
style={{
|
| 238 |
+
background: "var(--bg-surface)",
|
| 239 |
+
borderBottom: "1px solid var(--border)",
|
| 240 |
+
}}
|
| 241 |
+
>
|
| 242 |
+
<h2 className="text-lg font-bold" style={{ color: "var(--primary)" }}>
|
| 243 |
+
Skill Detail
|
| 244 |
+
</h2>
|
| 245 |
+
<button
|
| 246 |
+
onClick={() => setSelectedSkill(null)}
|
| 247 |
+
className="w-8 h-8 flex items-center justify-center rounded-lg transition-colors"
|
| 248 |
+
style={{ color: "var(--text-muted)" }}
|
| 249 |
+
>
|
| 250 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
|
| 251 |
+
<path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round" />
|
| 252 |
+
</svg>
|
| 253 |
+
</button>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
{/* Panel Body */}
|
| 257 |
+
<div className="p-6 space-y-6">
|
| 258 |
+
<div className="flex justify-between items-start">
|
| 259 |
+
<span className="badge badge--primary">{selectedSkill.category || "Unknown"}</span>
|
| 260 |
+
<ConfidenceBadge value={selectedSkill.confidence || 0} size="md" />
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<div>
|
| 264 |
+
<p className="input-label">Rule</p>
|
| 265 |
+
<p className="text-base font-medium" style={{ color: "var(--text-primary)" }}>
|
| 266 |
+
{selectedSkill.rule}
|
| 267 |
+
</p>
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
{selectedSkill.rationale && (
|
| 271 |
+
<div>
|
| 272 |
+
<p className="input-label">Rationale</p>
|
| 273 |
+
<p className="text-sm leading-relaxed" style={{ color: "var(--text-secondary)" }}>
|
| 274 |
+
{selectedSkill.rationale}
|
| 275 |
+
</p>
|
| 276 |
+
</div>
|
| 277 |
+
)}
|
| 278 |
+
|
| 279 |
+
{selectedSkill.evidence && selectedSkill.evidence.length > 0 && (
|
| 280 |
+
<div>
|
| 281 |
+
<p className="input-label">
|
| 282 |
+
Evidence ({selectedSkill.evidence.length})
|
| 283 |
+
</p>
|
| 284 |
+
<div className="space-y-2">
|
| 285 |
+
{selectedSkill.evidence.map((e, j) => (
|
| 286 |
+
<div
|
| 287 |
+
key={j}
|
| 288 |
+
className="text-sm p-3 rounded"
|
| 289 |
+
style={{
|
| 290 |
+
color: "var(--text-secondary)",
|
| 291 |
+
background: "var(--bg-input)",
|
| 292 |
+
border: "1px solid var(--border)",
|
| 293 |
+
}}
|
| 294 |
+
>
|
| 295 |
+
{e}
|
| 296 |
+
</div>
|
| 297 |
+
))}
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
)}
|
| 301 |
+
|
| 302 |
+
{selectedSkill.source_files && selectedSkill.source_files.length > 0 && (
|
| 303 |
+
<div>
|
| 304 |
+
<p className="input-label">Source Files</p>
|
| 305 |
+
<div className="flex flex-wrap gap-2">
|
| 306 |
+
{selectedSkill.source_files.map((sf, j) => (
|
| 307 |
+
<span key={j} className="badge badge--neutral font-mono">
|
| 308 |
+
{sf}
|
| 309 |
+
</span>
|
| 310 |
+
))}
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
)}
|
| 314 |
+
|
| 315 |
+
<div style={{ borderTop: "1px solid var(--border)", paddingTop: "24px" }}>
|
| 316 |
+
<button
|
| 317 |
+
onClick={() => setSelectedSkill(null)}
|
| 318 |
+
className="btn-secondary w-full"
|
| 319 |
+
>
|
| 320 |
+
Close
|
| 321 |
+
</button>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
)}
|
| 327 |
+
</DashboardLayout>
|
| 328 |
);
|
| 329 |
}
|
frontend/src/components/DashboardLayout.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Sidebar from "./Sidebar";
|
| 4 |
+
import TopBar from "./TopBar";
|
| 5 |
+
|
| 6 |
+
interface DashboardLayoutProps {
|
| 7 |
+
children: React.ReactNode;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
| 11 |
+
return (
|
| 12 |
+
<div className="flex min-h-screen">
|
| 13 |
+
{/* Gradient mesh background */}
|
| 14 |
+
<div className="mesh-gradient" />
|
| 15 |
+
|
| 16 |
+
{/* Sidebar */}
|
| 17 |
+
<Sidebar />
|
| 18 |
+
|
| 19 |
+
{/* Main area */}
|
| 20 |
+
<div
|
| 21 |
+
className="flex-1 flex flex-col min-h-screen"
|
| 22 |
+
style={{ marginLeft: "var(--sidebar-width)" }}
|
| 23 |
+
>
|
| 24 |
+
<TopBar />
|
| 25 |
+
<main className="flex-1 overflow-y-auto">
|
| 26 |
+
{children}
|
| 27 |
+
</main>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
);
|
| 31 |
+
}
|
frontend/src/components/NavBar.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useAuth } from "@/lib/auth";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
export default function NavBar() {
|
| 7 |
+
const { user, logout, loading } = useAuth();
|
| 8 |
+
const router = useRouter();
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<nav className="border-b border-gray-800 bg-surface/80 backdrop-blur-sm">
|
| 12 |
+
<div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between">
|
| 13 |
+
<button
|
| 14 |
+
onClick={() => router.push("/")}
|
| 15 |
+
className="text-lg font-bold text-primary tracking-tight"
|
| 16 |
+
>
|
| 17 |
+
Kernl
|
| 18 |
+
</button>
|
| 19 |
+
<div className="flex items-center gap-4 text-sm">
|
| 20 |
+
{!loading && user ? (
|
| 21 |
+
<>
|
| 22 |
+
<span className="text-text-secondary">{user.email}</span>
|
| 23 |
+
<button
|
| 24 |
+
onClick={() => { logout(); router.push("/login"); }}
|
| 25 |
+
className="text-text-secondary hover:text-foreground"
|
| 26 |
+
>
|
| 27 |
+
Sign Out
|
| 28 |
+
</button>
|
| 29 |
+
</>
|
| 30 |
+
) : (
|
| 31 |
+
<>
|
| 32 |
+
<button
|
| 33 |
+
onClick={() => router.push("/login")}
|
| 34 |
+
className="text-text-secondary hover:text-foreground"
|
| 35 |
+
>
|
| 36 |
+
Sign In
|
| 37 |
+
</button>
|
| 38 |
+
<button
|
| 39 |
+
onClick={() => router.push("/register")}
|
| 40 |
+
className="bg-primary text-background font-bold px-4 py-1.5 text-xs hover:opacity-90"
|
| 41 |
+
>
|
| 42 |
+
Sign Up
|
| 43 |
+
</button>
|
| 44 |
+
</>
|
| 45 |
+
)}
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</nav>
|
| 49 |
+
);
|
| 50 |
+
}
|
frontend/src/components/Sidebar.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { usePathname, useRouter } from "next/navigation";
|
| 4 |
+
|
| 5 |
+
const NAV_ITEMS = [
|
| 6 |
+
{
|
| 7 |
+
id: "home",
|
| 8 |
+
label: "Dashboard",
|
| 9 |
+
path: "/",
|
| 10 |
+
icon: (
|
| 11 |
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 12 |
+
<rect x="2" y="2" width="7" height="8" rx="1.5" />
|
| 13 |
+
<rect x="11" y="2" width="7" height="5" rx="1.5" />
|
| 14 |
+
<rect x="2" y="12" width="7" height="6" rx="1.5" />
|
| 15 |
+
<rect x="11" y="9" width="7" height="9" rx="1.5" />
|
| 16 |
+
</svg>
|
| 17 |
+
),
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
id: "onboarding",
|
| 21 |
+
label: "Onboarding",
|
| 22 |
+
path: "/onboarding",
|
| 23 |
+
icon: (
|
| 24 |
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 25 |
+
<path d="M10 2v16M2 10h16" strokeLinecap="round" />
|
| 26 |
+
</svg>
|
| 27 |
+
),
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
id: "skills",
|
| 31 |
+
label: "Skills",
|
| 32 |
+
path: "/skills",
|
| 33 |
+
icon: (
|
| 34 |
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 35 |
+
<path d="M3 4h14M3 8h10M3 12h12M3 16h8" strokeLinecap="round" />
|
| 36 |
+
</svg>
|
| 37 |
+
),
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
id: "demo",
|
| 41 |
+
label: "Query",
|
| 42 |
+
path: "/demo",
|
| 43 |
+
icon: (
|
| 44 |
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 45 |
+
<circle cx="10" cy="10" r="7" />
|
| 46 |
+
<path d="M10 6v4l3 2" strokeLinecap="round" strokeLinejoin="round" />
|
| 47 |
+
</svg>
|
| 48 |
+
),
|
| 49 |
+
},
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
export default function Sidebar() {
|
| 53 |
+
const pathname = usePathname();
|
| 54 |
+
const router = useRouter();
|
| 55 |
+
|
| 56 |
+
const isActive = (path: string) => {
|
| 57 |
+
if (path === "/") return pathname === "/";
|
| 58 |
+
return pathname.startsWith(path);
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<aside
|
| 63 |
+
className="fixed left-0 top-0 bottom-0 z-40 flex flex-col items-center py-5 border-r"
|
| 64 |
+
style={{
|
| 65 |
+
width: "var(--sidebar-width)",
|
| 66 |
+
background: "var(--bg-surface)",
|
| 67 |
+
borderColor: "var(--border)",
|
| 68 |
+
}}
|
| 69 |
+
>
|
| 70 |
+
{/* Logo */}
|
| 71 |
+
<button
|
| 72 |
+
onClick={() => router.push("/")}
|
| 73 |
+
className="mb-8 flex items-center justify-center w-9 h-9 rounded-lg transition-all hover:scale-105"
|
| 74 |
+
style={{
|
| 75 |
+
background: "var(--primary-ghost)",
|
| 76 |
+
color: "var(--primary)",
|
| 77 |
+
}}
|
| 78 |
+
title="Kernl"
|
| 79 |
+
>
|
| 80 |
+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
| 81 |
+
<path
|
| 82 |
+
d="M3 2h3v14H3V2zm5 0h2l5 7-5 7H8l5-7-5-7z"
|
| 83 |
+
fill="currentColor"
|
| 84 |
+
/>
|
| 85 |
+
</svg>
|
| 86 |
+
</button>
|
| 87 |
+
|
| 88 |
+
{/* Nav Items */}
|
| 89 |
+
<nav className="flex-1 flex flex-col gap-1 w-full px-2">
|
| 90 |
+
{NAV_ITEMS.map((item) => {
|
| 91 |
+
const active = isActive(item.path);
|
| 92 |
+
return (
|
| 93 |
+
<button
|
| 94 |
+
key={item.id}
|
| 95 |
+
onClick={() => router.push(item.path)}
|
| 96 |
+
title={item.label}
|
| 97 |
+
className="relative flex items-center justify-center w-10 h-10 mx-auto rounded-lg transition-all duration-200 group"
|
| 98 |
+
style={{
|
| 99 |
+
color: active ? "var(--primary)" : "var(--text-muted)",
|
| 100 |
+
background: active ? "var(--primary-ghost)" : "transparent",
|
| 101 |
+
}}
|
| 102 |
+
>
|
| 103 |
+
{/* Active indicator */}
|
| 104 |
+
{active && (
|
| 105 |
+
<span
|
| 106 |
+
className="absolute left-0 top-2 bottom-2 w-[2px] rounded-full"
|
| 107 |
+
style={{ background: "var(--primary)" }}
|
| 108 |
+
/>
|
| 109 |
+
)}
|
| 110 |
+
{item.icon}
|
| 111 |
+
|
| 112 |
+
{/* Tooltip */}
|
| 113 |
+
<span
|
| 114 |
+
className="absolute left-full ml-3 px-2 py-1 rounded text-xs font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity"
|
| 115 |
+
style={{
|
| 116 |
+
background: "var(--bg-elevated)",
|
| 117 |
+
color: "var(--text-primary)",
|
| 118 |
+
border: "1px solid var(--border-hover)",
|
| 119 |
+
}}
|
| 120 |
+
>
|
| 121 |
+
{item.label}
|
| 122 |
+
</span>
|
| 123 |
+
</button>
|
| 124 |
+
);
|
| 125 |
+
})}
|
| 126 |
+
</nav>
|
| 127 |
+
|
| 128 |
+
{/* Bottom accent dot */}
|
| 129 |
+
<div
|
| 130 |
+
className="w-2 h-2 rounded-full"
|
| 131 |
+
style={{ background: "var(--primary-dim)", opacity: 0.5 }}
|
| 132 |
+
/>
|
| 133 |
+
</aside>
|
| 134 |
+
);
|
| 135 |
+
}
|
frontend/src/components/TopBar.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useAuth } from "@/lib/auth";
|
| 4 |
+
import { useRouter, usePathname } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
function getPageTitle(pathname: string): string {
|
| 7 |
+
if (pathname === "/") return "Dashboard";
|
| 8 |
+
if (pathname.startsWith("/onboarding")) return "Onboarding";
|
| 9 |
+
if (pathname.startsWith("/skills")) return "Skills Explorer";
|
| 10 |
+
if (pathname.startsWith("/demo")) return "Query Agent";
|
| 11 |
+
if (pathname.startsWith("/compile")) return "Compile Pipeline";
|
| 12 |
+
return "Kernl";
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function TopBar() {
|
| 16 |
+
const { user, logout, loading } = useAuth();
|
| 17 |
+
const router = useRouter();
|
| 18 |
+
const pathname = usePathname();
|
| 19 |
+
const pageTitle = getPageTitle(pathname);
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<header
|
| 23 |
+
className="sticky top-0 z-30 flex items-center justify-between h-14 px-6 border-b"
|
| 24 |
+
style={{
|
| 25 |
+
background: "rgba(10, 15, 20, 0.85)",
|
| 26 |
+
backdropFilter: "blur(12px)",
|
| 27 |
+
borderColor: "var(--border)",
|
| 28 |
+
}}
|
| 29 |
+
>
|
| 30 |
+
{/* Left: Breadcrumb */}
|
| 31 |
+
<div className="flex items-center gap-2 text-sm">
|
| 32 |
+
<span style={{ color: "var(--text-muted)" }}>Kernl</span>
|
| 33 |
+
<span style={{ color: "var(--text-muted)" }}>/</span>
|
| 34 |
+
<span style={{ color: "var(--text-primary)", fontWeight: 600 }}>
|
| 35 |
+
{pageTitle}
|
| 36 |
+
</span>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
{/* Right: User */}
|
| 40 |
+
<div className="flex items-center gap-4">
|
| 41 |
+
{!loading && user ? (
|
| 42 |
+
<>
|
| 43 |
+
<span
|
| 44 |
+
className="text-xs font-mono"
|
| 45 |
+
style={{ color: "var(--text-muted)" }}
|
| 46 |
+
>
|
| 47 |
+
{user.email}
|
| 48 |
+
</span>
|
| 49 |
+
<button
|
| 50 |
+
onClick={() => {
|
| 51 |
+
logout();
|
| 52 |
+
router.push("/login");
|
| 53 |
+
}}
|
| 54 |
+
className="text-xs font-medium px-3 py-1.5 rounded-md transition-colors"
|
| 55 |
+
style={{
|
| 56 |
+
color: "var(--text-secondary)",
|
| 57 |
+
border: "1px solid var(--border)",
|
| 58 |
+
}}
|
| 59 |
+
onMouseEnter={(e) => {
|
| 60 |
+
e.currentTarget.style.borderColor = "var(--border-hover)";
|
| 61 |
+
e.currentTarget.style.color = "var(--text-primary)";
|
| 62 |
+
}}
|
| 63 |
+
onMouseLeave={(e) => {
|
| 64 |
+
e.currentTarget.style.borderColor = "var(--border)";
|
| 65 |
+
e.currentTarget.style.color = "var(--text-secondary)";
|
| 66 |
+
}}
|
| 67 |
+
>
|
| 68 |
+
Sign Out
|
| 69 |
+
</button>
|
| 70 |
+
</>
|
| 71 |
+
) : !loading ? (
|
| 72 |
+
<div className="flex items-center gap-2">
|
| 73 |
+
<button
|
| 74 |
+
onClick={() => router.push("/login")}
|
| 75 |
+
className="text-xs font-medium px-3 py-1.5 rounded-md transition-colors"
|
| 76 |
+
style={{ color: "var(--text-secondary)" }}
|
| 77 |
+
>
|
| 78 |
+
Sign In
|
| 79 |
+
</button>
|
| 80 |
+
<button
|
| 81 |
+
onClick={() => router.push("/register")}
|
| 82 |
+
className="btn-primary"
|
| 83 |
+
style={{ fontSize: "12px", padding: "6px 14px" }}
|
| 84 |
+
>
|
| 85 |
+
Sign Up
|
| 86 |
+
</button>
|
| 87 |
+
</div>
|
| 88 |
+
) : null}
|
| 89 |
+
</div>
|
| 90 |
+
</header>
|
| 91 |
+
);
|
| 92 |
+
}
|
frontend/src/components/ui/ConfidenceBadge.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface ConfidenceBadgeProps {
|
| 2 |
+
value: number; // 0.0 - 1.0
|
| 3 |
+
size?: "sm" | "md";
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
export default function ConfidenceBadge({ value, size = "sm" }: ConfidenceBadgeProps) {
|
| 7 |
+
const pct = Math.round(value * 100);
|
| 8 |
+
|
| 9 |
+
let colorVar: string;
|
| 10 |
+
let bgVar: string;
|
| 11 |
+
let borderVar: string;
|
| 12 |
+
|
| 13 |
+
if (value >= 0.8) {
|
| 14 |
+
colorVar = "var(--success)";
|
| 15 |
+
bgVar = "var(--success-bg)";
|
| 16 |
+
borderVar = "rgba(52, 211, 153, 0.25)";
|
| 17 |
+
} else if (value >= 0.6) {
|
| 18 |
+
colorVar = "var(--warning)";
|
| 19 |
+
bgVar = "var(--warning-bg)";
|
| 20 |
+
borderVar = "rgba(251, 191, 36, 0.25)";
|
| 21 |
+
} else if (value >= 0.4) {
|
| 22 |
+
colorVar = "var(--info)";
|
| 23 |
+
bgVar = "var(--info-bg)";
|
| 24 |
+
borderVar = "rgba(96, 165, 250, 0.25)";
|
| 25 |
+
} else {
|
| 26 |
+
colorVar = "var(--error)";
|
| 27 |
+
bgVar = "var(--error-bg)";
|
| 28 |
+
borderVar = "rgba(248, 113, 113, 0.25)";
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const textSize = size === "sm" ? "text-[10px]" : "text-xs";
|
| 32 |
+
const px = size === "sm" ? "px-2 py-0.5" : "px-2.5 py-1";
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<span
|
| 36 |
+
className={`inline-flex items-center gap-1 font-mono font-semibold ${textSize} ${px} rounded`}
|
| 37 |
+
style={{
|
| 38 |
+
color: colorVar,
|
| 39 |
+
background: bgVar,
|
| 40 |
+
border: `1px solid ${borderVar}`,
|
| 41 |
+
}}
|
| 42 |
+
>
|
| 43 |
+
<span
|
| 44 |
+
className="inline-block w-1.5 h-1.5 rounded-full"
|
| 45 |
+
style={{ background: colorVar }}
|
| 46 |
+
/>
|
| 47 |
+
{pct}%
|
| 48 |
+
</span>
|
| 49 |
+
);
|
| 50 |
+
}
|
frontend/src/components/ui/GlassCard.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ReactNode } from "react";
|
| 2 |
+
|
| 3 |
+
interface GlassCardProps {
|
| 4 |
+
children: ReactNode;
|
| 5 |
+
className?: string;
|
| 6 |
+
interactive?: boolean;
|
| 7 |
+
elevated?: boolean;
|
| 8 |
+
padding?: "none" | "sm" | "md" | "lg";
|
| 9 |
+
onClick?: () => void;
|
| 10 |
+
style?: React.CSSProperties;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const paddingMap = {
|
| 14 |
+
none: "",
|
| 15 |
+
sm: "p-4",
|
| 16 |
+
md: "p-5",
|
| 17 |
+
lg: "p-6",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function GlassCard({
|
| 21 |
+
children,
|
| 22 |
+
className = "",
|
| 23 |
+
interactive = false,
|
| 24 |
+
elevated = false,
|
| 25 |
+
padding = "md",
|
| 26 |
+
onClick,
|
| 27 |
+
style,
|
| 28 |
+
}: GlassCardProps) {
|
| 29 |
+
const baseClass = elevated ? "glass-card--elevated" : "glass-card";
|
| 30 |
+
const interactiveClass = interactive ? "glass-card--interactive" : "";
|
| 31 |
+
const padClass = paddingMap[padding];
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<div
|
| 35 |
+
className={`${baseClass} ${interactiveClass} ${padClass} ${className}`}
|
| 36 |
+
style={style}
|
| 37 |
+
onClick={onClick}
|
| 38 |
+
role={onClick ? "button" : undefined}
|
| 39 |
+
tabIndex={onClick ? 0 : undefined}
|
| 40 |
+
onKeyDown={
|
| 41 |
+
onClick
|
| 42 |
+
? (e) => {
|
| 43 |
+
if (e.key === "Enter" || e.key === " ") {
|
| 44 |
+
e.preventDefault();
|
| 45 |
+
onClick();
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
: undefined
|
| 49 |
+
}
|
| 50 |
+
>
|
| 51 |
+
{children}
|
| 52 |
+
</div>
|
| 53 |
+
);
|
| 54 |
+
}
|
frontend/src/components/ui/StatCard.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface StatCardProps {
|
| 2 |
+
label: string;
|
| 3 |
+
value: string | number;
|
| 4 |
+
icon?: React.ReactNode;
|
| 5 |
+
accent?: "primary" | "success" | "warning" | "info";
|
| 6 |
+
subtitle?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const accentMap = {
|
| 10 |
+
primary: { bg: "var(--primary-ghost)", color: "var(--primary)" },
|
| 11 |
+
success: { bg: "var(--success-bg)", color: "var(--success)" },
|
| 12 |
+
warning: { bg: "var(--warning-bg)", color: "var(--warning)" },
|
| 13 |
+
info: { bg: "var(--info-bg)", color: "var(--info)" },
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export default function StatCard({
|
| 17 |
+
label,
|
| 18 |
+
value,
|
| 19 |
+
icon,
|
| 20 |
+
accent = "primary",
|
| 21 |
+
subtitle,
|
| 22 |
+
}: StatCardProps) {
|
| 23 |
+
const colors = accentMap[accent];
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="glass-card p-5 animate-fade-in">
|
| 27 |
+
<div className="flex items-start justify-between mb-3">
|
| 28 |
+
<span
|
| 29 |
+
className="text-[10px] font-semibold uppercase tracking-[0.08em] font-mono"
|
| 30 |
+
style={{ color: "var(--text-muted)" }}
|
| 31 |
+
>
|
| 32 |
+
{label}
|
| 33 |
+
</span>
|
| 34 |
+
{icon && (
|
| 35 |
+
<span
|
| 36 |
+
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm"
|
| 37 |
+
style={{ background: colors.bg, color: colors.color }}
|
| 38 |
+
>
|
| 39 |
+
{icon}
|
| 40 |
+
</span>
|
| 41 |
+
)}
|
| 42 |
+
</div>
|
| 43 |
+
<p
|
| 44 |
+
className="text-2xl font-bold tracking-tight"
|
| 45 |
+
style={{ color: colors.color }}
|
| 46 |
+
>
|
| 47 |
+
{value}
|
| 48 |
+
</p>
|
| 49 |
+
{subtitle && (
|
| 50 |
+
<p
|
| 51 |
+
className="text-xs mt-1"
|
| 52 |
+
style={{ color: "var(--text-muted)" }}
|
| 53 |
+
>
|
| 54 |
+
{subtitle}
|
| 55 |
+
</p>
|
| 56 |
+
)}
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
}
|
frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const API_BASE =
|
| 2 |
+
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8081";
|
frontend/src/lib/auth.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { API_BASE } from "@/lib/api";
|
| 6 |
+
|
| 7 |
+
type AuthConfig = {
|
| 8 |
+
supabase_url: string;
|
| 9 |
+
supabase_anon_key: string;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
type AuthUser = {
|
| 13 |
+
id: string;
|
| 14 |
+
email: string;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
type AuthContextType = {
|
| 18 |
+
user: AuthUser | null;
|
| 19 |
+
token: string | null;
|
| 20 |
+
loading: boolean;
|
| 21 |
+
login: (email: string, password: string) => Promise<void>;
|
| 22 |
+
register: (email: string, password: string) => Promise<void>;
|
| 23 |
+
logout: () => void;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
let cachedConfig: AuthConfig | null = null;
|
| 27 |
+
|
| 28 |
+
async function getAuthConfig(): Promise<AuthConfig> {
|
| 29 |
+
if (cachedConfig) return cachedConfig;
|
| 30 |
+
const res = await fetch(`${API_BASE}/auth/config`);
|
| 31 |
+
cachedConfig = await res.json();
|
| 32 |
+
return cachedConfig!;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export async function loginWithSupabase(email: string, password: string) {
|
| 36 |
+
const config = await getAuthConfig();
|
| 37 |
+
const res = await fetch(`${config.supabase_url}/auth/v1/token?grant_type=password`, {
|
| 38 |
+
method: "POST",
|
| 39 |
+
headers: {
|
| 40 |
+
"Content-Type": "application/json",
|
| 41 |
+
apikey: config.supabase_anon_key,
|
| 42 |
+
},
|
| 43 |
+
body: JSON.stringify({ email, password }),
|
| 44 |
+
});
|
| 45 |
+
if (!res.ok) throw new Error((await res.json()).error_description || "Login failed");
|
| 46 |
+
const data = await res.json();
|
| 47 |
+
sessionStorage.setItem("kernl_token", data.access_token);
|
| 48 |
+
sessionStorage.setItem("kernl_user", JSON.stringify({ id: data.user.id, email: data.user.email }));
|
| 49 |
+
return data;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export async function registerWithSupabase(email: string, password: string) {
|
| 53 |
+
const config = await getAuthConfig();
|
| 54 |
+
const res = await fetch(`${config.supabase_url}/auth/v1/signup`, {
|
| 55 |
+
method: "POST",
|
| 56 |
+
headers: {
|
| 57 |
+
"Content-Type": "application/json",
|
| 58 |
+
apikey: config.supabase_anon_key,
|
| 59 |
+
},
|
| 60 |
+
body: JSON.stringify({ email, password }),
|
| 61 |
+
});
|
| 62 |
+
if (!res.ok) throw new Error((await res.json()).msg || "Registration failed");
|
| 63 |
+
return res.json();
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const AuthContext = createContext<AuthContextType | null>(null);
|
| 67 |
+
|
| 68 |
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
| 69 |
+
const [user, setUser] = useState<AuthUser | null>(null);
|
| 70 |
+
const [token, setToken] = useState<string | null>(null);
|
| 71 |
+
const [loading, setLoading] = useState(true);
|
| 72 |
+
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
const savedToken = sessionStorage.getItem("kernl_token");
|
| 75 |
+
const savedUser = sessionStorage.getItem("kernl_user");
|
| 76 |
+
if (savedToken && savedUser) {
|
| 77 |
+
setToken(savedToken);
|
| 78 |
+
setUser(JSON.parse(savedUser));
|
| 79 |
+
}
|
| 80 |
+
setLoading(false);
|
| 81 |
+
}, []);
|
| 82 |
+
|
| 83 |
+
const login = async (email: string, password: string) => {
|
| 84 |
+
const data = await loginWithSupabase(email, password);
|
| 85 |
+
setToken(data.access_token);
|
| 86 |
+
setUser({ id: data.user.id, email: data.user.email });
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
const register = async (email: string, password: string) => {
|
| 90 |
+
await registerWithSupabase(email, password);
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const logout = () => {
|
| 94 |
+
sessionStorage.removeItem("kernl_token");
|
| 95 |
+
sessionStorage.removeItem("kernl_user");
|
| 96 |
+
setToken(null);
|
| 97 |
+
setUser(null);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
return (
|
| 101 |
+
<AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
|
| 102 |
+
{children}
|
| 103 |
+
</AuthContext.Provider>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export function useAuth() {
|
| 108 |
+
const ctx = useContext(AuthContext);
|
| 109 |
+
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
| 110 |
+
return ctx;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
export function useProtectedAuth() {
|
| 114 |
+
const auth = useAuth();
|
| 115 |
+
const router = useRouter();
|
| 116 |
+
|
| 117 |
+
useEffect(() => {
|
| 118 |
+
if (!auth.loading && !auth.user) {
|
| 119 |
+
router.push("/login");
|
| 120 |
+
}
|
| 121 |
+
}, [auth.loading, auth.user, router]);
|
| 122 |
+
|
| 123 |
+
return auth;
|
| 124 |
+
}
|