Spaces:
Sleeping
Sleeping
Commit ·
68af3c5
1
Parent(s): cef4843
feat: add confluence/slack search tools, chat history, cloud Qdrant support, sync trigger fixes
Browse files- .dockerignore +14 -0
- .github/workflows/keep-alive.yml +12 -0
- .gitignore +3 -0
- Dockerfile +26 -0
- README.md +0 -9
- agent/agents/_gemini.py +29 -19
- agent/api.py +7 -1
- agent/config.py +14 -5
- agent/graph.py +58 -0
- agent/models.py +1 -1
- agent/prompts.py +12 -8
- agent/tools/confluence_search.py +67 -0
- agent/tools/doc_search.py +38 -12
- agent/tools/slack_search.py +87 -0
- agent/tools/sql_query.py +4 -4
- agent/tools/ticket_lookup.py +54 -7
- frontend/src/App.tsx +1 -1
- frontend/src/components/admin/AdminDashboard.tsx +11 -3
- frontend/src/components/admin/AdminUserManagement.tsx +142 -144
- frontend/src/components/admin/SyncTrigger.tsx +72 -11
- frontend/src/components/common/Sidebar.tsx +2 -2
- frontend/src/components/query/SuggestedTopics.tsx +5 -5
- frontend/src/components/results/KnowledgeGraph.tsx +9 -1
- frontend/src/components/results/ResultsPage.tsx +193 -106
- frontend/src/components/workspace/QueryHistory.tsx +17 -4
- frontend/src/config/env.ts +12 -9
- frontend/src/config/routes.ts +6 -1
- frontend/src/hooks/useGraphStream.ts +6 -1
- frontend/src/hooks/useNotifications.ts +4 -2
- frontend/src/pages/AcceptInvitePage.tsx +2 -2
- frontend/src/pages/Home.tsx +1 -1
- frontend/src/pages/LoginPage.tsx +2 -2
- frontend/src/pages/OAuthCallbackPage.tsx +5 -1
- frontend/src/pages/WorkspacePage.tsx +4 -3
- frontend/src/types/api.ts +1 -1
- graph_store/stream.py +41 -5
- ingestion/jobs/ingest_job.py +6 -2
- ingestion/sources/github.py +7 -4
- ingestion/storage/bm25_store.py +13 -4
- ingestion/storage/supabase_store.py +1 -1
- main.py +20 -1
- migrate_qdrant.py +58 -0
- requirements.txt +11 -4
- src/admin/router.py +8 -6
- src/admin/users_api.py +1 -1
- src/auth/email.py +21 -18
- src/auth/router.py +4 -3
- src/confluence_agent/tasks.py +3 -3
- src/jira_agent/tasks.py +2 -2
- start-ngrok.sh +50 -0
.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv
|
| 3 |
+
venv
|
| 4 |
+
__pycache__
|
| 5 |
+
*.pyc
|
| 6 |
+
*.pyo
|
| 7 |
+
.git
|
| 8 |
+
.gitignore
|
| 9 |
+
frontend/node_modules
|
| 10 |
+
frontend/src
|
| 11 |
+
frontend/.env
|
| 12 |
+
data/bm25_index.pkl
|
| 13 |
+
*.log
|
| 14 |
+
.DS_Store
|
.github/workflows/keep-alive.yml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Keep HF Space Alive
|
| 2 |
+
on:
|
| 3 |
+
schedule:
|
| 4 |
+
- cron: '*/30 * * * *'
|
| 5 |
+
workflow_dispatch:
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
ping:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
steps:
|
| 11 |
+
- name: Ping space
|
| 12 |
+
run: curl -sf ${{ secrets.HF_SPACE_URL }}/health || true
|
.gitignore
CHANGED
|
@@ -38,3 +38,6 @@ Thumbs.db
|
|
| 38 |
celerybeat-schedule
|
| 39 |
celerybeat.pid
|
| 40 |
.env
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
celerybeat-schedule
|
| 39 |
celerybeat.pid
|
| 40 |
.env
|
| 41 |
+
frontend/node_modules/
|
| 42 |
+
frontend/package-lock.json
|
| 43 |
+
frontend/tsconfig.tsbuildinfo
|
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# System deps
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
build-essential curl git \
|
| 6 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
# Install Python deps first (layer cache)
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Pre-download the BGE-M3 model so cold starts are fast
|
| 15 |
+
RUN python3 -c "from FlagEmbedding import BGEM3FlagModel; BGEM3FlagModel('BAAI/bge-m3', use_fp16=False)"
|
| 16 |
+
|
| 17 |
+
# Download spacy model
|
| 18 |
+
RUN python3 -m spacy download en_core_web_sm
|
| 19 |
+
|
| 20 |
+
# Copy app code
|
| 21 |
+
COPY . .
|
| 22 |
+
|
| 23 |
+
# HuggingFace Spaces requires port 7860
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
|
| 26 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,12 +1,3 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: GodSpeed
|
| 3 |
-
emoji: 🚀
|
| 4 |
-
colorFrom: red
|
| 5 |
-
colorTo: yellow
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
# Godspeed
|
| 11 |
|
| 12 |
Enterprise Knowledge Copilot is a fully open-source, locally-compliant, agentic RAG platform that unifies internal and live external knowledge into a single cited, validated answer engine — purpose-built for IT enterprises operating under GDPR and India's DPDP Act.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Godspeed
|
| 2 |
|
| 3 |
Enterprise Knowledge Copilot is a fully open-source, locally-compliant, agentic RAG platform that unifies internal and live external knowledge into a single cited, validated answer engine — purpose-built for IT enterprises operating under GDPR and India's DPDP Act.
|
agent/agents/_gemini.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -7,7 +7,6 @@ import json
|
|
| 7 |
import logging
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
-
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 11 |
from langchain_core.messages import HumanMessage, SystemMessage
|
| 12 |
|
| 13 |
from agent.config import settings
|
|
@@ -15,16 +14,33 @@ from agent.config import settings
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
async def call_gemini_text(
|
| 19 |
model_name: str,
|
| 20 |
system_prompt: str,
|
| 21 |
user_message: str,
|
| 22 |
) -> str:
|
| 23 |
-
llm =
|
| 24 |
-
model=model_name,
|
| 25 |
-
google_api_key=settings.google_api_key,
|
| 26 |
-
temperature=0.0,
|
| 27 |
-
)
|
| 28 |
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_message)]
|
| 29 |
|
| 30 |
for attempt in range(settings.gemini_max_retries):
|
|
@@ -33,10 +49,10 @@ async def call_gemini_text(
|
|
| 33 |
return response.content
|
| 34 |
except Exception as exc:
|
| 35 |
if attempt == settings.gemini_max_retries - 1:
|
| 36 |
-
logger.error("
|
| 37 |
raise
|
| 38 |
delay = settings.gemini_retry_base_delay * (2 ** attempt)
|
| 39 |
-
logger.warning("
|
| 40 |
await asyncio.sleep(delay)
|
| 41 |
|
| 42 |
raise RuntimeError("Unreachable")
|
|
@@ -48,7 +64,6 @@ async def call_gemini_json(
|
|
| 48 |
user_message: str,
|
| 49 |
) -> dict[str, Any]:
|
| 50 |
raw = await call_gemini_text(model_name, system_prompt, user_message)
|
| 51 |
-
# Strip markdown code fences if model ignores the instruction
|
| 52 |
cleaned = raw.strip()
|
| 53 |
if cleaned.startswith("```"):
|
| 54 |
cleaned = cleaned.split("\n", 1)[-1]
|
|
@@ -57,7 +72,7 @@ async def call_gemini_json(
|
|
| 57 |
try:
|
| 58 |
return json.loads(cleaned)
|
| 59 |
except json.JSONDecodeError as exc:
|
| 60 |
-
logger.error("Failed to parse
|
| 61 |
raise
|
| 62 |
|
| 63 |
|
|
@@ -66,12 +81,7 @@ async def stream_gemini_text(
|
|
| 66 |
system_prompt: str,
|
| 67 |
user_message: str,
|
| 68 |
):
|
| 69 |
-
llm =
|
| 70 |
-
model=model_name,
|
| 71 |
-
google_api_key=settings.google_api_key,
|
| 72 |
-
temperature=0.0,
|
| 73 |
-
streaming=True,
|
| 74 |
-
)
|
| 75 |
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_message)]
|
| 76 |
|
| 77 |
for attempt in range(settings.gemini_max_retries):
|
|
@@ -81,8 +91,8 @@ async def stream_gemini_text(
|
|
| 81 |
return
|
| 82 |
except Exception as exc:
|
| 83 |
if attempt == settings.gemini_max_retries - 1:
|
| 84 |
-
logger.error("
|
| 85 |
raise
|
| 86 |
delay = settings.gemini_retry_base_delay * (2 ** attempt)
|
| 87 |
-
logger.warning("
|
| 88 |
await asyncio.sleep(delay)
|
|
|
|
| 1 |
+
"""LLM call helper with exponential-backoff retry. Uses OpenAI when key is set, else Gemini."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 7 |
import logging
|
| 8 |
from typing import Any
|
| 9 |
|
|
|
|
| 10 |
from langchain_core.messages import HumanMessage, SystemMessage
|
| 11 |
|
| 12 |
from agent.config import settings
|
|
|
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
|
| 17 |
+
def _make_llm(model_name: str, streaming: bool = False):
|
| 18 |
+
openai_key = getattr(settings, "openai_api_key", "")
|
| 19 |
+
if openai_key:
|
| 20 |
+
from langchain_openai import ChatOpenAI
|
| 21 |
+
# Map Gemini model names to OpenAI equivalents
|
| 22 |
+
oai_model = "gpt-4o-mini"
|
| 23 |
+
return ChatOpenAI(
|
| 24 |
+
model=oai_model,
|
| 25 |
+
api_key=openai_key,
|
| 26 |
+
temperature=0.0,
|
| 27 |
+
streaming=streaming,
|
| 28 |
+
)
|
| 29 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 30 |
+
return ChatGoogleGenerativeAI(
|
| 31 |
+
model=model_name,
|
| 32 |
+
google_api_key=settings.google_api_key,
|
| 33 |
+
temperature=0.0,
|
| 34 |
+
streaming=streaming,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
async def call_gemini_text(
|
| 39 |
model_name: str,
|
| 40 |
system_prompt: str,
|
| 41 |
user_message: str,
|
| 42 |
) -> str:
|
| 43 |
+
llm = _make_llm(model_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_message)]
|
| 45 |
|
| 46 |
for attempt in range(settings.gemini_max_retries):
|
|
|
|
| 49 |
return response.content
|
| 50 |
except Exception as exc:
|
| 51 |
if attempt == settings.gemini_max_retries - 1:
|
| 52 |
+
logger.error("LLM call failed after %d retries: %s", settings.gemini_max_retries, exc)
|
| 53 |
raise
|
| 54 |
delay = settings.gemini_retry_base_delay * (2 ** attempt)
|
| 55 |
+
logger.warning("LLM call attempt %d failed (%s); retrying in %.1fs", attempt + 1, exc, delay)
|
| 56 |
await asyncio.sleep(delay)
|
| 57 |
|
| 58 |
raise RuntimeError("Unreachable")
|
|
|
|
| 64 |
user_message: str,
|
| 65 |
) -> dict[str, Any]:
|
| 66 |
raw = await call_gemini_text(model_name, system_prompt, user_message)
|
|
|
|
| 67 |
cleaned = raw.strip()
|
| 68 |
if cleaned.startswith("```"):
|
| 69 |
cleaned = cleaned.split("\n", 1)[-1]
|
|
|
|
| 72 |
try:
|
| 73 |
return json.loads(cleaned)
|
| 74 |
except json.JSONDecodeError as exc:
|
| 75 |
+
logger.error("Failed to parse LLM JSON response: %s\nRaw: %s", exc, raw[:500])
|
| 76 |
raise
|
| 77 |
|
| 78 |
|
|
|
|
| 81 |
system_prompt: str,
|
| 82 |
user_message: str,
|
| 83 |
):
|
| 84 |
+
llm = _make_llm(model_name, streaming=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_message)]
|
| 86 |
|
| 87 |
for attempt in range(settings.gemini_max_retries):
|
|
|
|
| 91 |
return
|
| 92 |
except Exception as exc:
|
| 93 |
if attempt == settings.gemini_max_retries - 1:
|
| 94 |
+
logger.error("LLM stream failed after %d retries: %s", settings.gemini_max_retries, exc)
|
| 95 |
raise
|
| 96 |
delay = settings.gemini_retry_base_delay * (2 ** attempt)
|
| 97 |
+
logger.warning("LLM stream attempt %d failed (%s); retrying in %.1fs", attempt + 1, exc, delay)
|
| 98 |
await asyncio.sleep(delay)
|
agent/api.py
CHANGED
|
@@ -31,12 +31,17 @@ async def _store_query_event(
|
|
| 31 |
agent_results: dict | None = None,
|
| 32 |
guardrail_score: float | None = None,
|
| 33 |
escalated: bool = False,
|
|
|
|
| 34 |
) -> None:
|
| 35 |
"""Persist query event to Redis for analytics and workspace history."""
|
| 36 |
try:
|
| 37 |
import redis.asyncio as aioredis
|
| 38 |
from src.config import settings
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
event = {
|
| 41 |
"id": str(uuid4()),
|
| 42 |
"query": query_input.query,
|
|
@@ -45,7 +50,7 @@ async def _store_query_event(
|
|
| 45 |
"created_at": datetime.utcnow().isoformat(),
|
| 46 |
"success": success,
|
| 47 |
"duration_ms": duration_ms,
|
| 48 |
-
"answer_brief":
|
| 49 |
# Per-agent retrieval metrics — populated from final graph state
|
| 50 |
"agents": {
|
| 51 |
agent: {
|
|
@@ -122,6 +127,7 @@ async def _event_generator(
|
|
| 122 |
agent_results=final_state.get("agent_results", {}),
|
| 123 |
guardrail_score=final_state.get("guardrail_score"),
|
| 124 |
escalated=final_state.get("escalate", False),
|
|
|
|
| 125 |
)
|
| 126 |
except Exception as exc:
|
| 127 |
logger.exception(
|
|
|
|
| 31 |
agent_results: dict | None = None,
|
| 32 |
guardrail_score: float | None = None,
|
| 33 |
escalated: bool = False,
|
| 34 |
+
answer_text: str = "",
|
| 35 |
) -> None:
|
| 36 |
"""Persist query event to Redis for analytics and workspace history."""
|
| 37 |
try:
|
| 38 |
import redis.asyncio as aioredis
|
| 39 |
from src.config import settings
|
| 40 |
|
| 41 |
+
brief = answer_text[:500].rstrip() if answer_text else ""
|
| 42 |
+
if answer_text and len(answer_text) > 500:
|
| 43 |
+
brief += "…"
|
| 44 |
+
|
| 45 |
event = {
|
| 46 |
"id": str(uuid4()),
|
| 47 |
"query": query_input.query,
|
|
|
|
| 50 |
"created_at": datetime.utcnow().isoformat(),
|
| 51 |
"success": success,
|
| 52 |
"duration_ms": duration_ms,
|
| 53 |
+
"answer_brief": brief,
|
| 54 |
# Per-agent retrieval metrics — populated from final graph state
|
| 55 |
"agents": {
|
| 56 |
agent: {
|
|
|
|
| 127 |
agent_results=final_state.get("agent_results", {}),
|
| 128 |
guardrail_score=final_state.get("guardrail_score"),
|
| 129 |
escalated=final_state.get("escalate", False),
|
| 130 |
+
answer_text=final_state.get("final_answer") or "",
|
| 131 |
)
|
| 132 |
except Exception as exc:
|
| 133 |
logger.exception(
|
agent/config.py
CHANGED
|
@@ -10,14 +10,17 @@ class Settings(BaseSettings):
|
|
| 10 |
)
|
| 11 |
|
| 12 |
google_api_key: str = ""
|
|
|
|
| 13 |
|
| 14 |
-
planner_model: str = "gemini-2.
|
| 15 |
-
synthesiser_model: str = "gemini-2.
|
| 16 |
-
summariser_model: str = "gemini-2.
|
| 17 |
-
guardrail_model: str = "gemini-2.
|
| 18 |
|
| 19 |
qdrant_host: str = "localhost"
|
| 20 |
qdrant_port: int = 6333
|
|
|
|
|
|
|
| 21 |
qdrant_collection: str = "knowledge_base"
|
| 22 |
qdrant_dense_vector_name: str = "dense"
|
| 23 |
qdrant_sparse_vector_name: str = "sparse"
|
|
@@ -33,7 +36,7 @@ class Settings(BaseSettings):
|
|
| 33 |
rrf_top_k: int = 50
|
| 34 |
final_top_k: int = 5
|
| 35 |
reranker_high_threshold: float = 0.6
|
| 36 |
-
reranker_medium_threshold: float = 0.
|
| 37 |
live_docs_confidence_threshold: float = 0.5
|
| 38 |
|
| 39 |
gemini_max_retries: int = 3
|
|
@@ -49,8 +52,14 @@ class Settings(BaseSettings):
|
|
| 49 |
# NL-to-SQL tool — direct PostgreSQL connection string.
|
| 50 |
# e.g. postgresql://postgres:password@db.yourproject.supabase.co:5432/postgres
|
| 51 |
# Leave empty to disable the sql_query agent gracefully.
|
|
|
|
| 52 |
database_url: str = ""
|
|
|
|
| 53 |
sql_max_rows: int = 20
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
settings = Settings()
|
|
|
|
| 10 |
)
|
| 11 |
|
| 12 |
google_api_key: str = ""
|
| 13 |
+
openai_api_key: str = ""
|
| 14 |
|
| 15 |
+
planner_model: str = "gemini-2.0-flash"
|
| 16 |
+
synthesiser_model: str = "gemini-2.0-flash"
|
| 17 |
+
summariser_model: str = "gemini-2.0-flash"
|
| 18 |
+
guardrail_model: str = "gemini-2.0-flash"
|
| 19 |
|
| 20 |
qdrant_host: str = "localhost"
|
| 21 |
qdrant_port: int = 6333
|
| 22 |
+
qdrant_url: str = "" # overrides host+port when set (Qdrant Cloud)
|
| 23 |
+
qdrant_api_key: str = "" # required for Qdrant Cloud
|
| 24 |
qdrant_collection: str = "knowledge_base"
|
| 25 |
qdrant_dense_vector_name: str = "dense"
|
| 26 |
qdrant_sparse_vector_name: str = "sparse"
|
|
|
|
| 36 |
rrf_top_k: int = 50
|
| 37 |
final_top_k: int = 5
|
| 38 |
reranker_high_threshold: float = 0.6
|
| 39 |
+
reranker_medium_threshold: float = 0.3
|
| 40 |
live_docs_confidence_threshold: float = 0.5
|
| 41 |
|
| 42 |
gemini_max_retries: int = 3
|
|
|
|
| 52 |
# NL-to-SQL tool — direct PostgreSQL connection string.
|
| 53 |
# e.g. postgresql://postgres:password@db.yourproject.supabase.co:5432/postgres
|
| 54 |
# Leave empty to disable the sql_query agent gracefully.
|
| 55 |
+
# Reads DATABASE_URL or PG_DSN from environment.
|
| 56 |
database_url: str = ""
|
| 57 |
+
pg_dsn: str = ""
|
| 58 |
sql_max_rows: int = 20
|
| 59 |
|
| 60 |
+
@property
|
| 61 |
+
def effective_database_url(self) -> str:
|
| 62 |
+
return self.database_url or self.pg_dsn
|
| 63 |
+
|
| 64 |
|
| 65 |
settings = Settings()
|
agent/graph.py
CHANGED
|
@@ -12,8 +12,10 @@ from agent.agents.guardrail import run_guardrail
|
|
| 12 |
from agent.agents.planner import run_planner
|
| 13 |
from agent.agents.synthesiser import stream_synthesis
|
| 14 |
from agent.models import AgentResult, KnowledgeGraphState, RetrievedChunk
|
|
|
|
| 15 |
from agent.tools.doc_search import compute_retrieval_confidence, run_doc_search
|
| 16 |
from agent.tools.live_docs import run_live_docs
|
|
|
|
| 17 |
from agent.tools.sql_query import run_sql_query
|
| 18 |
from agent.tools.ticket_lookup import run_ticket_lookup
|
| 19 |
|
|
@@ -101,6 +103,56 @@ async def ticket_lookup_node(state: KnowledgeGraphState) -> dict:
|
|
| 101 |
return {"agent_results": {**state.agent_results, "ticket_lookup": result}}
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
async def live_docs_node(state: KnowledgeGraphState) -> dict:
|
| 105 |
queue = state.sse_queue
|
| 106 |
await _push_event(queue, "agent_started", {"agent": "live_docs"})
|
|
@@ -246,6 +298,8 @@ def build_graph() -> Any:
|
|
| 246 |
builder.add_node("planner_node", planner_node)
|
| 247 |
builder.add_node("doc_search_node", doc_search_node)
|
| 248 |
builder.add_node("ticket_lookup_node", ticket_lookup_node)
|
|
|
|
|
|
|
| 249 |
builder.add_node("live_docs_node", live_docs_node)
|
| 250 |
builder.add_node("sql_query_node", sql_query_node)
|
| 251 |
builder.add_node("join_node", join_node)
|
|
@@ -260,6 +314,8 @@ def build_graph() -> Any:
|
|
| 260 |
{
|
| 261 |
"doc_search_node": "doc_search_node",
|
| 262 |
"ticket_lookup_node": "ticket_lookup_node",
|
|
|
|
|
|
|
| 263 |
"live_docs_node": "live_docs_node",
|
| 264 |
"sql_query_node": "sql_query_node",
|
| 265 |
"summariser_node": "synthesiser_node",
|
|
@@ -271,6 +327,8 @@ def build_graph() -> Any:
|
|
| 271 |
# incoming edge to fire before executing join_node (fan-in).
|
| 272 |
builder.add_edge("doc_search_node", "join_node")
|
| 273 |
builder.add_edge("ticket_lookup_node", "join_node")
|
|
|
|
|
|
|
| 274 |
builder.add_edge("live_docs_node", "join_node")
|
| 275 |
builder.add_edge("sql_query_node", "join_node")
|
| 276 |
|
|
|
|
| 12 |
from agent.agents.planner import run_planner
|
| 13 |
from agent.agents.synthesiser import stream_synthesis
|
| 14 |
from agent.models import AgentResult, KnowledgeGraphState, RetrievedChunk
|
| 15 |
+
from agent.tools.confluence_search import run_confluence_search
|
| 16 |
from agent.tools.doc_search import compute_retrieval_confidence, run_doc_search
|
| 17 |
from agent.tools.live_docs import run_live_docs
|
| 18 |
+
from agent.tools.slack_search import run_slack_search
|
| 19 |
from agent.tools.sql_query import run_sql_query
|
| 20 |
from agent.tools.ticket_lookup import run_ticket_lookup
|
| 21 |
|
|
|
|
| 103 |
return {"agent_results": {**state.agent_results, "ticket_lookup": result}}
|
| 104 |
|
| 105 |
|
| 106 |
+
async def confluence_search_node(state: KnowledgeGraphState) -> dict:
|
| 107 |
+
queue = state.sse_queue
|
| 108 |
+
await _push_event(queue, "agent_started", {"agent": "confluence_search"})
|
| 109 |
+
|
| 110 |
+
task_input = _find_task_input(state, "confluence_search") or state.query_input.query
|
| 111 |
+
chunks: list[RetrievedChunk] = []
|
| 112 |
+
error: str | None = None
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
chunks = await run_confluence_search(task_input, state.query_input.team_id)
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
logger.exception("confluence_search_node error")
|
| 118 |
+
error = str(exc)
|
| 119 |
+
|
| 120 |
+
confidence = compute_retrieval_confidence(chunks)
|
| 121 |
+
result = AgentResult(
|
| 122 |
+
agent="confluence_search",
|
| 123 |
+
chunks=chunks,
|
| 124 |
+
retrieval_confidence=confidence,
|
| 125 |
+
error=error,
|
| 126 |
+
)
|
| 127 |
+
await _push_event(queue, "agent_done", {"agent": "confluence_search", "retrieval_confidence": confidence})
|
| 128 |
+
return {"agent_results": {**state.agent_results, "confluence_search": result}}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def slack_search_node(state: KnowledgeGraphState) -> dict:
|
| 132 |
+
queue = state.sse_queue
|
| 133 |
+
await _push_event(queue, "agent_started", {"agent": "slack_search"})
|
| 134 |
+
|
| 135 |
+
task_input = _find_task_input(state, "slack_search") or state.query_input.query
|
| 136 |
+
chunks: list[RetrievedChunk] = []
|
| 137 |
+
error: str | None = None
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
chunks = await run_slack_search(task_input, state.query_input.team_id)
|
| 141 |
+
except Exception as exc:
|
| 142 |
+
logger.exception("slack_search_node error")
|
| 143 |
+
error = str(exc)
|
| 144 |
+
|
| 145 |
+
confidence = compute_retrieval_confidence(chunks)
|
| 146 |
+
result = AgentResult(
|
| 147 |
+
agent="slack_search",
|
| 148 |
+
chunks=chunks,
|
| 149 |
+
retrieval_confidence=confidence,
|
| 150 |
+
error=error,
|
| 151 |
+
)
|
| 152 |
+
await _push_event(queue, "agent_done", {"agent": "slack_search", "retrieval_confidence": confidence})
|
| 153 |
+
return {"agent_results": {**state.agent_results, "slack_search": result}}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
async def live_docs_node(state: KnowledgeGraphState) -> dict:
|
| 157 |
queue = state.sse_queue
|
| 158 |
await _push_event(queue, "agent_started", {"agent": "live_docs"})
|
|
|
|
| 298 |
builder.add_node("planner_node", planner_node)
|
| 299 |
builder.add_node("doc_search_node", doc_search_node)
|
| 300 |
builder.add_node("ticket_lookup_node", ticket_lookup_node)
|
| 301 |
+
builder.add_node("confluence_search_node", confluence_search_node)
|
| 302 |
+
builder.add_node("slack_search_node", slack_search_node)
|
| 303 |
builder.add_node("live_docs_node", live_docs_node)
|
| 304 |
builder.add_node("sql_query_node", sql_query_node)
|
| 305 |
builder.add_node("join_node", join_node)
|
|
|
|
| 314 |
{
|
| 315 |
"doc_search_node": "doc_search_node",
|
| 316 |
"ticket_lookup_node": "ticket_lookup_node",
|
| 317 |
+
"confluence_search_node": "confluence_search_node",
|
| 318 |
+
"slack_search_node": "slack_search_node",
|
| 319 |
"live_docs_node": "live_docs_node",
|
| 320 |
"sql_query_node": "sql_query_node",
|
| 321 |
"summariser_node": "synthesiser_node",
|
|
|
|
| 327 |
# incoming edge to fire before executing join_node (fan-in).
|
| 328 |
builder.add_edge("doc_search_node", "join_node")
|
| 329 |
builder.add_edge("ticket_lookup_node", "join_node")
|
| 330 |
+
builder.add_edge("confluence_search_node", "join_node")
|
| 331 |
+
builder.add_edge("slack_search_node", "join_node")
|
| 332 |
builder.add_edge("live_docs_node", "join_node")
|
| 333 |
builder.add_edge("sql_query_node", "join_node")
|
| 334 |
|
agent/models.py
CHANGED
|
@@ -16,7 +16,7 @@ class QueryInput(BaseModel):
|
|
| 16 |
|
| 17 |
|
| 18 |
class AgentTask(BaseModel):
|
| 19 |
-
agent: Literal["doc_search", "ticket_lookup", "live_docs", "summariser", "sql_query"]
|
| 20 |
input: str
|
| 21 |
depends_on: list[str] = Field(default_factory=list)
|
| 22 |
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class AgentTask(BaseModel):
|
| 19 |
+
agent: Literal["doc_search", "ticket_lookup", "confluence_search", "slack_search", "live_docs", "summariser", "sql_query"]
|
| 20 |
input: str
|
| 21 |
depends_on: list[str] = Field(default_factory=list)
|
| 22 |
|
agent/prompts.py
CHANGED
|
@@ -3,19 +3,23 @@ PLANNER_SYSTEM_PROMPT = """You are a planning agent for an Enterprise Knowledge
|
|
| 3 |
Given a user query, decide which retrieval agents are needed and in what order.
|
| 4 |
|
| 5 |
Available agents:
|
| 6 |
-
- doc_search: Searches the internal knowledge base (Qdrant vector DB). Use for product docs, runbooks,
|
| 7 |
-
- ticket_lookup: Searches Jira tickets. Use when query mentions bugs, issues, tickets, sprints,
|
|
|
|
|
|
|
| 8 |
- live_docs: Fetches live web content via Firecrawl/Tavily. Use ONLY when the query mentions a specific external library, framework, or third-party tool where internal docs are insufficient.
|
| 9 |
- summariser: Summarises a large set of retrieved chunks. Use ONLY when more than 10 chunks are expected.
|
| 10 |
- sql_query: Queries the internal Supabase database with SQL. Use when the query asks about counts, stats, aggregations, ingestion status, document lists, or any structured/numeric data (e.g. "how many documents", "failed jobs", "which source types", "ingestion stats").
|
| 11 |
|
| 12 |
Rules:
|
| 13 |
-
1. doc_search and
|
| 14 |
-
2.
|
| 15 |
-
3.
|
| 16 |
-
4.
|
| 17 |
-
5.
|
| 18 |
-
6.
|
|
|
|
|
|
|
| 19 |
|
| 20 |
Return ONLY valid JSON matching this exact schema. No preamble. No markdown code fences. No explanation outside the JSON.
|
| 21 |
|
|
|
|
| 3 |
Given a user query, decide which retrieval agents are needed and in what order.
|
| 4 |
|
| 5 |
Available agents:
|
| 6 |
+
- doc_search: Searches the internal knowledge base (Qdrant vector DB). Use for general product docs, runbooks, architecture docs, GitHub code, and any knowledge not specific to Confluence, Jira, or Slack.
|
| 7 |
+
- ticket_lookup: Searches Jira tickets. Use when query mentions bugs, issues, tickets, sprints, task tracking, or specific ticket IDs (e.g. KAN-7).
|
| 8 |
+
- confluence_search: Searches Confluence pages. Use when query explicitly mentions Confluence, internal wiki pages, design docs, meeting notes, or space/page content.
|
| 9 |
+
- slack_search: Searches Slack messages live. Use when query mentions Slack, team conversations, channel discussions, or asks what was discussed or said about a topic.
|
| 10 |
- live_docs: Fetches live web content via Firecrawl/Tavily. Use ONLY when the query mentions a specific external library, framework, or third-party tool where internal docs are insufficient.
|
| 11 |
- summariser: Summarises a large set of retrieved chunks. Use ONLY when more than 10 chunks are expected.
|
| 12 |
- sql_query: Queries the internal Supabase database with SQL. Use when the query asks about counts, stats, aggregations, ingestion status, document lists, or any structured/numeric data (e.g. "how many documents", "failed jobs", "which source types", "ingestion stats").
|
| 13 |
|
| 14 |
Rules:
|
| 15 |
+
1. doc_search, ticket_lookup, confluence_search, and slack_search can all run in parallel — set depends_on: [] for each.
|
| 16 |
+
2. Use confluence_search instead of (or in addition to) doc_search when the query is clearly about Confluence content.
|
| 17 |
+
3. Use slack_search when the query is clearly about Slack conversations.
|
| 18 |
+
4. live_docs only runs if you expect doc_search confidence will be low OR the query names a specific external library/framework. Set depends_on: ["doc_search"] to run after.
|
| 19 |
+
5. summariser only runs after doc_search. Set depends_on: ["doc_search"].
|
| 20 |
+
6. Do NOT include agents that are not needed for this query.
|
| 21 |
+
7. Rephrase the input for each agent to be focused and specific to what that agent can retrieve.
|
| 22 |
+
8. sql_query runs independently (depends_on: []). Use it when the query is about structured or aggregated data rather than semantic knowledge retrieval. It can run in parallel with doc_search.
|
| 23 |
|
| 24 |
Return ONLY valid JSON matching this exact schema. No preamble. No markdown code fences. No explanation outside the JSON.
|
| 25 |
|
agent/tools/confluence_search.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Confluence page search — retrieves ingested Confluence chunks from Qdrant."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from agent.config import settings
|
| 8 |
+
from agent.models import RetrievedChunk
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def run_confluence_search(query: str, team_id: str) -> list[RetrievedChunk]:
|
| 14 |
+
"""Search Qdrant for Confluence chunks matching the query."""
|
| 15 |
+
from qdrant_client import AsyncQdrantClient
|
| 16 |
+
from qdrant_client.http import models as qmodels
|
| 17 |
+
from agent.tools.doc_search import _get_embedding_model
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
model = _get_embedding_model()
|
| 21 |
+
output = model.encode(
|
| 22 |
+
[query],
|
| 23 |
+
batch_size=1,
|
| 24 |
+
max_length=512,
|
| 25 |
+
return_dense=True,
|
| 26 |
+
return_sparse=True,
|
| 27 |
+
return_colbert_vecs=False,
|
| 28 |
+
)
|
| 29 |
+
dense_vector = output["dense_vecs"][0].tolist()
|
| 30 |
+
|
| 31 |
+
from agent.tools.doc_search import _get_qdrant_client
|
| 32 |
+
client = _get_qdrant_client()
|
| 33 |
+
|
| 34 |
+
conf_filter = qmodels.Filter(
|
| 35 |
+
must=[
|
| 36 |
+
qmodels.FieldCondition(key="source_type", match=qmodels.MatchValue(value="confluence")),
|
| 37 |
+
qmodels.FieldCondition(key="team_id", match=qmodels.MatchValue(value=team_id)),
|
| 38 |
+
]
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
response = await client.query_points(
|
| 42 |
+
collection_name=settings.qdrant_collection,
|
| 43 |
+
query=dense_vector,
|
| 44 |
+
using=settings.qdrant_dense_vector_name,
|
| 45 |
+
query_filter=conf_filter,
|
| 46 |
+
limit=10,
|
| 47 |
+
with_payload=True,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
chunks = []
|
| 51 |
+
for hit in response.points:
|
| 52 |
+
p = hit.payload or {}
|
| 53 |
+
chunks.append(RetrievedChunk(
|
| 54 |
+
chunk_id=p.get("chunk_id", str(hit.id)),
|
| 55 |
+
text=p.get("text", ""),
|
| 56 |
+
source=p.get("source", ""),
|
| 57 |
+
source_type="confluence",
|
| 58 |
+
score=hit.score,
|
| 59 |
+
metadata=p.get("metadata", {}),
|
| 60 |
+
))
|
| 61 |
+
|
| 62 |
+
logger.info("confluence_search: found %d chunks for query=%r team=%s", len(chunks), query, team_id)
|
| 63 |
+
return chunks
|
| 64 |
+
|
| 65 |
+
except Exception:
|
| 66 |
+
logger.exception("confluence_search: search failed")
|
| 67 |
+
return []
|
agent/tools/doc_search.py
CHANGED
|
@@ -16,6 +16,16 @@ from agent.models import RetrievedChunk
|
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
_PII_ENTITY_TYPES = [
|
| 20 |
"person",
|
| 21 |
"email",
|
|
@@ -33,6 +43,7 @@ _gliner = None
|
|
| 33 |
_bm25_index: Optional[BM25Okapi] = None
|
| 34 |
_bm25_corpus: Optional[list[str]] = None
|
| 35 |
_bm25_doc_ids: Optional[list[str]] = None
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
def _get_embedding_model():
|
|
@@ -47,9 +58,17 @@ def _get_embedding_model():
|
|
| 47 |
def _get_reranker():
|
| 48 |
global _reranker
|
| 49 |
if _reranker is None:
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
return _reranker
|
| 54 |
|
| 55 |
|
|
@@ -62,15 +81,15 @@ def _get_gliner():
|
|
| 62 |
return _gliner
|
| 63 |
|
| 64 |
|
| 65 |
-
def _load_bm25() -> tuple[Optional[BM25Okapi], Optional[list[str]], Optional[list[str]]]:
|
| 66 |
-
global _bm25_index, _bm25_corpus, _bm25_doc_ids
|
| 67 |
if _bm25_index is not None:
|
| 68 |
-
return _bm25_index, _bm25_corpus, _bm25_doc_ids
|
| 69 |
|
| 70 |
index_path = Path(settings.bm25_index_path)
|
| 71 |
if not index_path.exists():
|
| 72 |
logger.warning("BM25 index not found at %s — skipping BM25 retrieval", index_path)
|
| 73 |
-
return None, None, None
|
| 74 |
|
| 75 |
try:
|
| 76 |
with index_path.open("rb") as f:
|
|
@@ -78,12 +97,13 @@ def _load_bm25() -> tuple[Optional[BM25Okapi], Optional[list[str]], Optional[lis
|
|
| 78 |
_bm25_index = data["index"]
|
| 79 |
_bm25_corpus = data["corpus"]
|
| 80 |
_bm25_doc_ids = data["doc_ids"]
|
|
|
|
| 81 |
logger.info("Loaded BM25 index with %d documents", len(_bm25_doc_ids))
|
| 82 |
except Exception:
|
| 83 |
logger.exception("Failed to load BM25 index from %s", index_path)
|
| 84 |
-
return None, None, None
|
| 85 |
|
| 86 |
-
return _bm25_index, _bm25_corpus, _bm25_doc_ids
|
| 87 |
|
| 88 |
|
| 89 |
def _mask_pii(text: str) -> str:
|
|
@@ -133,7 +153,7 @@ async def run_doc_search(
|
|
| 133 |
sparse_values = [sparse_weights[i] for i in sparse_indices]
|
| 134 |
|
| 135 |
bm25_ranked_ids: list[str] = []
|
| 136 |
-
bm25, corpus, doc_ids = _load_bm25()
|
| 137 |
if bm25 is not None and doc_ids:
|
| 138 |
tokenized = masked_query.lower().split()
|
| 139 |
bm25_scores = bm25.get_scores(tokenized)
|
|
@@ -147,7 +167,7 @@ async def run_doc_search(
|
|
| 147 |
qdrant_score_map: dict[str, float] = {}
|
| 148 |
|
| 149 |
try:
|
| 150 |
-
client =
|
| 151 |
|
| 152 |
# Channel-based filter (RBAC): use allowed_channel_ids when set.
|
| 153 |
# Falls back to team_id for legacy points that pre-date channel tagging.
|
|
@@ -224,7 +244,13 @@ async def run_doc_search(
|
|
| 224 |
if doc_ids and doc_id in doc_ids:
|
| 225 |
idx = doc_ids.index(doc_id)
|
| 226 |
text = corpus[idx] if corpus else ""
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
else:
|
| 229 |
continue
|
| 230 |
candidates.append({"id": doc_id, "payload": payload, "rrf_score": rrf_scores[doc_id]})
|
|
|
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
| 19 |
+
|
| 20 |
+
def _get_qdrant_client() -> AsyncQdrantClient:
|
| 21 |
+
"""Return Qdrant client — cloud if QDRANT_URL is set, otherwise local."""
|
| 22 |
+
if settings.qdrant_url:
|
| 23 |
+
return AsyncQdrantClient(
|
| 24 |
+
url=settings.qdrant_url,
|
| 25 |
+
api_key=settings.qdrant_api_key or None,
|
| 26 |
+
)
|
| 27 |
+
return AsyncQdrantClient(host=settings.qdrant_host, port=settings.qdrant_port)
|
| 28 |
+
|
| 29 |
_PII_ENTITY_TYPES = [
|
| 30 |
"person",
|
| 31 |
"email",
|
|
|
|
| 43 |
_bm25_index: Optional[BM25Okapi] = None
|
| 44 |
_bm25_corpus: Optional[list[str]] = None
|
| 45 |
_bm25_doc_ids: Optional[list[str]] = None
|
| 46 |
+
_bm25_metadata: Optional[list[dict]] = None
|
| 47 |
|
| 48 |
|
| 49 |
def _get_embedding_model():
|
|
|
|
| 58 |
def _get_reranker():
|
| 59 |
global _reranker
|
| 60 |
if _reranker is None:
|
| 61 |
+
import numpy as np
|
| 62 |
+
from sentence_transformers import CrossEncoder
|
| 63 |
+
logger.info("Loading CrossEncoder reranker (ms-marco-MiniLM-L-6-v2)")
|
| 64 |
+
_model = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
|
| 65 |
+
class _Reranker:
|
| 66 |
+
def compute_score(self, pairs, normalize=True):
|
| 67 |
+
raw = _model.predict(pairs)
|
| 68 |
+
if normalize:
|
| 69 |
+
return list(1 / (1 + np.exp(-raw)))
|
| 70 |
+
return list(raw.tolist())
|
| 71 |
+
_reranker = _Reranker()
|
| 72 |
return _reranker
|
| 73 |
|
| 74 |
|
|
|
|
| 81 |
return _gliner
|
| 82 |
|
| 83 |
|
| 84 |
+
def _load_bm25() -> tuple[Optional[BM25Okapi], Optional[list[str]], Optional[list[str]], Optional[list[dict]]]:
|
| 85 |
+
global _bm25_index, _bm25_corpus, _bm25_doc_ids, _bm25_metadata
|
| 86 |
if _bm25_index is not None:
|
| 87 |
+
return _bm25_index, _bm25_corpus, _bm25_doc_ids, _bm25_metadata
|
| 88 |
|
| 89 |
index_path = Path(settings.bm25_index_path)
|
| 90 |
if not index_path.exists():
|
| 91 |
logger.warning("BM25 index not found at %s — skipping BM25 retrieval", index_path)
|
| 92 |
+
return None, None, None, None
|
| 93 |
|
| 94 |
try:
|
| 95 |
with index_path.open("rb") as f:
|
|
|
|
| 97 |
_bm25_index = data["index"]
|
| 98 |
_bm25_corpus = data["corpus"]
|
| 99 |
_bm25_doc_ids = data["doc_ids"]
|
| 100 |
+
_bm25_metadata = data.get("metadata")
|
| 101 |
logger.info("Loaded BM25 index with %d documents", len(_bm25_doc_ids))
|
| 102 |
except Exception:
|
| 103 |
logger.exception("Failed to load BM25 index from %s", index_path)
|
| 104 |
+
return None, None, None, None
|
| 105 |
|
| 106 |
+
return _bm25_index, _bm25_corpus, _bm25_doc_ids, _bm25_metadata
|
| 107 |
|
| 108 |
|
| 109 |
def _mask_pii(text: str) -> str:
|
|
|
|
| 153 |
sparse_values = [sparse_weights[i] for i in sparse_indices]
|
| 154 |
|
| 155 |
bm25_ranked_ids: list[str] = []
|
| 156 |
+
bm25, corpus, doc_ids, bm25_metadata = _load_bm25()
|
| 157 |
if bm25 is not None and doc_ids:
|
| 158 |
tokenized = masked_query.lower().split()
|
| 159 |
bm25_scores = bm25.get_scores(tokenized)
|
|
|
|
| 167 |
qdrant_score_map: dict[str, float] = {}
|
| 168 |
|
| 169 |
try:
|
| 170 |
+
client = _get_qdrant_client()
|
| 171 |
|
| 172 |
# Channel-based filter (RBAC): use allowed_channel_ids when set.
|
| 173 |
# Falls back to team_id for legacy points that pre-date channel tagging.
|
|
|
|
| 244 |
if doc_ids and doc_id in doc_ids:
|
| 245 |
idx = doc_ids.index(doc_id)
|
| 246 |
text = corpus[idx] if corpus else ""
|
| 247 |
+
meta = bm25_metadata[idx] if bm25_metadata else {}
|
| 248 |
+
payload = {
|
| 249 |
+
"chunk_id": doc_id,
|
| 250 |
+
"text": text,
|
| 251 |
+
"source": meta.get("source") or meta.get("source_url") or "bm25",
|
| 252 |
+
"source_type": meta.get("source_type", "internal"),
|
| 253 |
+
}
|
| 254 |
else:
|
| 255 |
continue
|
| 256 |
candidates.append({"id": doc_id, "payload": payload, "rrf_score": rrf_scores[doc_id]})
|
agent/tools/slack_search.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Slack search — searches conversation history using bot token (search.messages requires user token)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
|
| 10 |
+
from agent.models import RetrievedChunk
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def run_slack_search(query: str, team_id: str) -> list[RetrievedChunk]:
|
| 16 |
+
"""Search Slack message history for the query by scanning channels the bot is in."""
|
| 17 |
+
token = os.environ.get("SLACK_BOT_TOKEN", "")
|
| 18 |
+
if not token:
|
| 19 |
+
logger.warning("slack_search: SLACK_BOT_TOKEN not set")
|
| 20 |
+
return []
|
| 21 |
+
|
| 22 |
+
keywords = [w.lower() for w in query.split() if len(w) > 3]
|
| 23 |
+
if not keywords:
|
| 24 |
+
keywords = query.lower().split()
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
async with httpx.AsyncClient(timeout=15) as h:
|
| 28 |
+
# Get channels the bot is a member of
|
| 29 |
+
r = await h.get(
|
| 30 |
+
"https://slack.com/api/conversations.list",
|
| 31 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 32 |
+
params={"types": "public_channel,private_channel", "limit": 200, "exclude_archived": True},
|
| 33 |
+
)
|
| 34 |
+
data = r.json()
|
| 35 |
+
if not data.get("ok"):
|
| 36 |
+
logger.warning("slack_search: conversations.list error: %s", data.get("error"))
|
| 37 |
+
return []
|
| 38 |
+
|
| 39 |
+
channels = [c for c in data.get("channels", []) if c.get("is_member")]
|
| 40 |
+
if not channels:
|
| 41 |
+
logger.info("slack_search: bot is not a member of any channels")
|
| 42 |
+
return []
|
| 43 |
+
|
| 44 |
+
chunks: list[RetrievedChunk] = []
|
| 45 |
+
|
| 46 |
+
for ch in channels[:10]: # cap at 10 channels
|
| 47 |
+
ch_id = ch["id"]
|
| 48 |
+
ch_name = ch.get("name", ch_id)
|
| 49 |
+
try:
|
| 50 |
+
async with httpx.AsyncClient(timeout=15) as h:
|
| 51 |
+
r = await h.get(
|
| 52 |
+
"https://slack.com/api/conversations.history",
|
| 53 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 54 |
+
params={"channel": ch_id, "limit": 200},
|
| 55 |
+
)
|
| 56 |
+
msgs = r.json().get("messages", [])
|
| 57 |
+
except Exception:
|
| 58 |
+
continue
|
| 59 |
+
|
| 60 |
+
for m in msgs:
|
| 61 |
+
text = m.get("text", "")
|
| 62 |
+
if not text:
|
| 63 |
+
continue
|
| 64 |
+
text_lower = text.lower()
|
| 65 |
+
if any(kw in text_lower for kw in keywords):
|
| 66 |
+
ts = m.get("ts", "")
|
| 67 |
+
user = m.get("user", m.get("username", "unknown"))
|
| 68 |
+
link = f"https://slack.com/archives/{ch_id}/p{ts.replace('.', '')}"
|
| 69 |
+
chunks.append(RetrievedChunk(
|
| 70 |
+
chunk_id=f"slack-{ch_id}-{ts}",
|
| 71 |
+
text=f"[#{ch_name}] {text}",
|
| 72 |
+
source=link,
|
| 73 |
+
source_type="slack",
|
| 74 |
+
score=1.0,
|
| 75 |
+
metadata={"channel": ch_name, "channel_id": ch_id, "user": user, "ts": ts},
|
| 76 |
+
))
|
| 77 |
+
if len(chunks) >= 10:
|
| 78 |
+
break
|
| 79 |
+
if len(chunks) >= 10:
|
| 80 |
+
break
|
| 81 |
+
|
| 82 |
+
logger.info("slack_search: found %d messages for query=%r", len(chunks), query)
|
| 83 |
+
return chunks
|
| 84 |
+
|
| 85 |
+
except Exception:
|
| 86 |
+
logger.exception("slack_search: search failed")
|
| 87 |
+
return []
|
agent/tools/sql_query.py
CHANGED
|
@@ -94,8 +94,8 @@ async def run_sql_query(
|
|
| 94 |
team_id: str,
|
| 95 |
allowed_channel_ids: list[str] | None = None,
|
| 96 |
) -> list[RetrievedChunk]:
|
| 97 |
-
if not settings.
|
| 98 |
-
logger.warning("sql_query: DATABASE_URL not configured — skipping")
|
| 99 |
return []
|
| 100 |
|
| 101 |
# --- Step 1: NL → SQL via Gemini Flash ---
|
|
@@ -128,7 +128,7 @@ async def run_sql_query(
|
|
| 128 |
return []
|
| 129 |
|
| 130 |
# Replace placeholder with positional parameter for asyncpg
|
| 131 |
-
parameterized_sql =
|
| 132 |
|
| 133 |
# --- Step 3: Safety validation ---
|
| 134 |
ok, reason = _validate_sql(parameterized_sql)
|
|
@@ -140,7 +140,7 @@ async def run_sql_query(
|
|
| 140 |
try:
|
| 141 |
import asyncpg # imported lazily — only needed when tool is active
|
| 142 |
|
| 143 |
-
conn = await asyncpg.connect(settings.
|
| 144 |
try:
|
| 145 |
rows = await conn.fetch(parameterized_sql, team_id)
|
| 146 |
finally:
|
|
|
|
| 94 |
team_id: str,
|
| 95 |
allowed_channel_ids: list[str] | None = None,
|
| 96 |
) -> list[RetrievedChunk]:
|
| 97 |
+
if not settings.effective_database_url:
|
| 98 |
+
logger.warning("sql_query: DATABASE_URL / PG_DSN not configured — skipping")
|
| 99 |
return []
|
| 100 |
|
| 101 |
# --- Step 1: NL → SQL via Gemini Flash ---
|
|
|
|
| 128 |
return []
|
| 129 |
|
| 130 |
# Replace placeholder with positional parameter for asyncpg
|
| 131 |
+
parameterized_sql = re.sub(r"'<TEAM_ID_PLACEHOLDER>'", "$1", raw_sql, count=1)
|
| 132 |
|
| 133 |
# --- Step 3: Safety validation ---
|
| 134 |
ok, reason = _validate_sql(parameterized_sql)
|
|
|
|
| 140 |
try:
|
| 141 |
import asyncpg # imported lazily — only needed when tool is active
|
| 142 |
|
| 143 |
+
conn = await asyncpg.connect(settings.effective_database_url)
|
| 144 |
try:
|
| 145 |
rows = await conn.fetch(parameterized_sql, team_id)
|
| 146 |
finally:
|
agent/tools/ticket_lookup.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Jira ticket lookup
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -11,10 +11,57 @@ logger = logging.getLogger(__name__)
|
|
| 11 |
|
| 12 |
|
| 13 |
async def run_ticket_lookup(query: str, team_id: str) -> list[RetrievedChunk]:
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Jira ticket lookup — searches ingested Jira chunks from Qdrant."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
async def run_ticket_lookup(query: str, team_id: str) -> list[RetrievedChunk]:
|
| 14 |
+
"""Search Qdrant for Jira chunks matching the query."""
|
| 15 |
+
from qdrant_client import AsyncQdrantClient
|
| 16 |
+
from qdrant_client.http import models as qmodels
|
| 17 |
+
from agent.tools.doc_search import _get_embedding_model
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
model = _get_embedding_model()
|
| 21 |
+
output = model.encode(
|
| 22 |
+
[query],
|
| 23 |
+
batch_size=1,
|
| 24 |
+
max_length=512,
|
| 25 |
+
return_dense=True,
|
| 26 |
+
return_sparse=True,
|
| 27 |
+
return_colbert_vecs=False,
|
| 28 |
+
)
|
| 29 |
+
dense_vector = output["dense_vecs"][0].tolist()
|
| 30 |
+
|
| 31 |
+
from agent.tools.doc_search import _get_qdrant_client
|
| 32 |
+
client = _get_qdrant_client()
|
| 33 |
+
|
| 34 |
+
jira_filter = qmodels.Filter(
|
| 35 |
+
must=[
|
| 36 |
+
qmodels.FieldCondition(key="source_type", match=qmodels.MatchValue(value="jira")),
|
| 37 |
+
qmodels.FieldCondition(key="team_id", match=qmodels.MatchValue(value=team_id)),
|
| 38 |
+
]
|
| 39 |
+
)
|
| 40 |
|
| 41 |
+
response = await client.query_points(
|
| 42 |
+
collection_name=settings.qdrant_collection,
|
| 43 |
+
query=dense_vector,
|
| 44 |
+
using=settings.qdrant_dense_vector_name,
|
| 45 |
+
query_filter=jira_filter,
|
| 46 |
+
limit=10,
|
| 47 |
+
with_payload=True,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
chunks = []
|
| 51 |
+
for hit in response.points:
|
| 52 |
+
p = hit.payload or {}
|
| 53 |
+
chunks.append(RetrievedChunk(
|
| 54 |
+
chunk_id=p.get("chunk_id", str(hit.id)),
|
| 55 |
+
text=p.get("text", ""),
|
| 56 |
+
source=p.get("source", ""),
|
| 57 |
+
source_type="jira",
|
| 58 |
+
score=hit.score,
|
| 59 |
+
metadata=p.get("metadata", {}),
|
| 60 |
+
))
|
| 61 |
+
|
| 62 |
+
logger.info("ticket_lookup: found %d chunks for query=%r team=%s", len(chunks), query, team_id)
|
| 63 |
+
return chunks
|
| 64 |
+
|
| 65 |
+
except Exception:
|
| 66 |
+
logger.exception("ticket_lookup: search failed")
|
| 67 |
+
return []
|
frontend/src/App.tsx
CHANGED
|
@@ -47,7 +47,7 @@ export default function App() {
|
|
| 47 |
const handler = (e: KeyboardEvent) => {
|
| 48 |
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
| 49 |
e.preventDefault()
|
| 50 |
-
navigate({ to: '/query', search: { q: undefined } })
|
| 51 |
}
|
| 52 |
}
|
| 53 |
window.addEventListener('keydown', handler)
|
|
|
|
| 47 |
const handler = (e: KeyboardEvent) => {
|
| 48 |
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
| 49 |
e.preventDefault()
|
| 50 |
+
navigate({ to: '/query', search: { q: undefined, qid: undefined, fresh: false } })
|
| 51 |
}
|
| 52 |
}
|
| 53 |
window.addEventListener('keydown', handler)
|
frontend/src/components/admin/AdminDashboard.tsx
CHANGED
|
@@ -5,13 +5,15 @@ import { GraphIngestButton } from './GraphIngestButton'
|
|
| 5 |
import { FileUploadWidget } from './FileUploadWidget'
|
| 6 |
import { DataSourceManager } from './DataSourceManager'
|
| 7 |
import { SystemLogs } from './SystemLogs'
|
|
|
|
| 8 |
|
| 9 |
-
type Tab = 'overview' | 'data-sources' | 'ingest' | 'logs'
|
| 10 |
|
| 11 |
const TABS: { id: Tab; label: string }[] = [
|
| 12 |
{ id: 'overview', label: 'System Status' },
|
| 13 |
{ id: 'data-sources', label: 'Data Sources' },
|
| 14 |
{ id: 'ingest', label: 'Ingest' },
|
|
|
|
| 15 |
{ id: 'logs', label: 'System Logs' },
|
| 16 |
]
|
| 17 |
|
|
@@ -45,8 +47,8 @@ export function AdminDashboard() {
|
|
| 45 |
<div className="rounded-xl border border-surface-subtle p-5">
|
| 46 |
<p className="mb-4 text-sm font-medium text-stone-500">Sync Controls</p>
|
| 47 |
<div className="flex flex-col gap-3">
|
| 48 |
-
<SyncTrigger source="jira" sourceKey="
|
| 49 |
-
<SyncTrigger source="confluence" sourceKey="
|
| 50 |
</div>
|
| 51 |
</div>
|
| 52 |
|
|
@@ -69,6 +71,12 @@ export function AdminDashboard() {
|
|
| 69 |
</div>
|
| 70 |
)}
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
{tab === 'logs' && <SystemLogs />}
|
| 73 |
</div>
|
| 74 |
)
|
|
|
|
| 5 |
import { FileUploadWidget } from './FileUploadWidget'
|
| 6 |
import { DataSourceManager } from './DataSourceManager'
|
| 7 |
import { SystemLogs } from './SystemLogs'
|
| 8 |
+
import { AdminUserManagement } from './AdminUserManagement'
|
| 9 |
|
| 10 |
+
type Tab = 'overview' | 'data-sources' | 'ingest' | 'users' | 'logs'
|
| 11 |
|
| 12 |
const TABS: { id: Tab; label: string }[] = [
|
| 13 |
{ id: 'overview', label: 'System Status' },
|
| 14 |
{ id: 'data-sources', label: 'Data Sources' },
|
| 15 |
{ id: 'ingest', label: 'Ingest' },
|
| 16 |
+
{ id: 'users', label: 'Users' },
|
| 17 |
{ id: 'logs', label: 'System Logs' },
|
| 18 |
]
|
| 19 |
|
|
|
|
| 47 |
<div className="rounded-xl border border-surface-subtle p-5">
|
| 48 |
<p className="mb-4 text-sm font-medium text-stone-500">Sync Controls</p>
|
| 49 |
<div className="flex flex-col gap-3">
|
| 50 |
+
<SyncTrigger source="jira" sourceKey="KAN" label="Jira (KAN)" />
|
| 51 |
+
<SyncTrigger source="confluence" sourceKey="Godspeed" label="Confluence (Godspeed)" />
|
| 52 |
</div>
|
| 53 |
</div>
|
| 54 |
|
|
|
|
| 71 |
</div>
|
| 72 |
)}
|
| 73 |
|
| 74 |
+
{tab === 'users' && (
|
| 75 |
+
<div className="rounded-xl border border-surface-subtle">
|
| 76 |
+
<AdminUserManagement />
|
| 77 |
+
</div>
|
| 78 |
+
)}
|
| 79 |
+
|
| 80 |
{tab === 'logs' && <SystemLogs />}
|
| 81 |
</div>
|
| 82 |
)
|
frontend/src/components/admin/AdminUserManagement.tsx
CHANGED
|
@@ -1,194 +1,181 @@
|
|
| 1 |
-
import { useState
|
| 2 |
-
import { useQuery, useMutation } from '@tanstack/react-query'
|
| 3 |
import { apiFetch } from '@/lib/http'
|
| 4 |
-
import { UserInvite
|
| 5 |
-
import { User } from '@/types/user'
|
| 6 |
import { LoadingSkeleton } from '../common/LoadingSkeleton'
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
export function AdminUserManagement() {
|
|
|
|
| 9 |
const [showAddForm, setShowAddForm] = useState(false)
|
| 10 |
-
const [
|
| 11 |
-
const [
|
| 12 |
-
|
| 13 |
-
name: '',
|
| 14 |
-
role: 'engineer',
|
| 15 |
-
})
|
| 16 |
-
const fileInputRef = useRef<HTMLInputElement>(null)
|
| 17 |
|
| 18 |
-
|
| 19 |
-
const { data: users, isLoading, refetch } = useQuery({
|
| 20 |
queryKey: ['workspace-users'],
|
| 21 |
queryFn: async () => {
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
},
|
| 25 |
})
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
const { mutate: addUser, isPending: isAddingUser } = useMutation({
|
| 29 |
mutationFn: async (input: UserInvite) => {
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
},
|
| 33 |
-
onSuccess: () => {
|
| 34 |
setFormData({ email: '', name: '', role: 'engineer' })
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
},
|
| 38 |
})
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
console.log('Bulk import file:', file)
|
| 45 |
-
// For now, mock parsing
|
| 46 |
-
return { count: 0 } as { count: number }
|
| 47 |
-
},
|
| 48 |
-
onSuccess: () => {
|
| 49 |
-
setShowBulkImport(false)
|
| 50 |
-
if (fileInputRef.current) fileInputRef.current.value = ''
|
| 51 |
-
refetch()
|
| 52 |
},
|
|
|
|
| 53 |
})
|
| 54 |
|
| 55 |
-
const
|
| 56 |
if (!formData.email.trim() || !formData.name.trim()) return
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
const handleBulkImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 61 |
-
const file = e.currentTarget.files?.[0]
|
| 62 |
-
if (file) {
|
| 63 |
-
bulkImport(file)
|
| 64 |
-
}
|
| 65 |
}
|
| 66 |
|
| 67 |
return (
|
| 68 |
<div className="space-y-6 p-6">
|
| 69 |
-
{/* Header
|
| 70 |
<div className="flex items-center justify-between">
|
| 71 |
<div>
|
| 72 |
<h3 className="text-sm font-semibold">Workspace Users</h3>
|
| 73 |
-
<p className="mt-1 text-xs text-stone-500">{users
|
| 74 |
-
</div>
|
| 75 |
-
<div className="flex gap-2">
|
| 76 |
-
<button
|
| 77 |
-
onClick={() => setShowAddForm(!showAddForm)}
|
| 78 |
-
className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark"
|
| 79 |
-
>
|
| 80 |
-
+ Add User
|
| 81 |
-
</button>
|
| 82 |
-
<button
|
| 83 |
-
onClick={() => setShowBulkImport(!showBulkImport)}
|
| 84 |
-
className="rounded border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-50 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800"
|
| 85 |
-
>
|
| 86 |
-
📥 Import CSV
|
| 87 |
-
</button>
|
| 88 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
| 90 |
|
| 91 |
-
{/*
|
| 92 |
{showAddForm && (
|
| 93 |
<div className="space-y-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-stone-900">
|
| 94 |
-
<div>
|
| 95 |
-
<
|
| 96 |
-
Name
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
/>
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
className="
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
</div>
|
| 119 |
|
| 120 |
-
|
| 121 |
-
<
|
| 122 |
-
|
| 123 |
-
</
|
| 124 |
-
|
| 125 |
-
value={formData.role}
|
| 126 |
-
onChange={(e) =>
|
| 127 |
-
setFormData({
|
| 128 |
-
...formData,
|
| 129 |
-
role: e.target.value as 'engineer' | 'manager' | 'admin',
|
| 130 |
-
})
|
| 131 |
-
}
|
| 132 |
-
className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-stone-900 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
|
| 133 |
-
>
|
| 134 |
-
<option value="engineer">Engineer</option>
|
| 135 |
-
<option value="manager">Manager</option>
|
| 136 |
-
<option value="admin">Admin</option>
|
| 137 |
-
</select>
|
| 138 |
-
</div>
|
| 139 |
|
| 140 |
<div className="flex gap-2">
|
| 141 |
<button
|
| 142 |
-
onClick={
|
| 143 |
-
disabled={
|
| 144 |
className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark disabled:opacity-50"
|
| 145 |
>
|
| 146 |
-
{
|
| 147 |
</button>
|
| 148 |
<button
|
| 149 |
-
onClick={() => setShowAddForm(false)}
|
| 150 |
className="rounded border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-100 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-700"
|
| 151 |
>
|
| 152 |
Cancel
|
| 153 |
</button>
|
| 154 |
</div>
|
| 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 |
-
|
| 180 |
-
|
| 181 |
-
Cancel
|
| 182 |
-
</button>
|
| 183 |
</div>
|
| 184 |
)}
|
| 185 |
|
| 186 |
-
{/* Users
|
| 187 |
<div>
|
| 188 |
-
<h4 className="mb-3 text-sm font-semibold">Active Users</h4>
|
| 189 |
{isLoading ? (
|
| 190 |
<LoadingSkeleton rows={5} className="h-10" />
|
| 191 |
-
) : users
|
| 192 |
<div className="overflow-x-auto rounded-lg border border-stone-200 dark:border-stone-700">
|
| 193 |
<table className="w-full text-left text-sm">
|
| 194 |
<thead className="border-b border-stone-200 bg-stone-50 dark:border-stone-700 dark:bg-stone-800">
|
|
@@ -196,10 +183,8 @@ export function AdminUserManagement() {
|
|
| 196 |
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Name</th>
|
| 197 |
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Email</th>
|
| 198 |
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Role</th>
|
| 199 |
-
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">
|
| 200 |
-
<th className="px-4 py-2 text-right font-semibold text-stone-700 dark:text-stone-300">
|
| 201 |
-
Actions
|
| 202 |
-
</th>
|
| 203 |
</tr>
|
| 204 |
</thead>
|
| 205 |
<tbody className="divide-y divide-stone-200 dark:divide-stone-700">
|
|
@@ -212,9 +197,22 @@ export function AdminUserManagement() {
|
|
| 212 |
{u.role}
|
| 213 |
</span>
|
| 214 |
</td>
|
| 215 |
-
<td className="px-4 py-3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
<td className="px-4 py-3 text-right">
|
| 217 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
Remove
|
| 219 |
</button>
|
| 220 |
</td>
|
|
@@ -226,7 +224,7 @@ export function AdminUserManagement() {
|
|
| 226 |
) : (
|
| 227 |
<div className="rounded-lg border-2 border-dashed border-stone-300 p-6 text-center dark:border-stone-600">
|
| 228 |
<p className="text-sm text-stone-500">No users yet</p>
|
| 229 |
-
<p className="mt-1 text-xs text-stone-400">
|
| 230 |
</div>
|
| 231 |
)}
|
| 232 |
</div>
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
| 3 |
import { apiFetch } from '@/lib/http'
|
| 4 |
+
import { UserInvite } from '@/types/settings'
|
|
|
|
| 5 |
import { LoadingSkeleton } from '../common/LoadingSkeleton'
|
| 6 |
|
| 7 |
+
interface WorkspaceUser {
|
| 8 |
+
id: string
|
| 9 |
+
email: string
|
| 10 |
+
name: string
|
| 11 |
+
role: string
|
| 12 |
+
is_active: boolean
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
export function AdminUserManagement() {
|
| 16 |
+
const qc = useQueryClient()
|
| 17 |
const [showAddForm, setShowAddForm] = useState(false)
|
| 18 |
+
const [formData, setFormData] = useState<UserInvite>({ email: '', name: '', role: 'engineer' })
|
| 19 |
+
const [inviteLink, setInviteLink] = useState<{ url: string; emailSent: boolean } | null>(null)
|
| 20 |
+
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
const { data, isLoading } = useQuery({
|
|
|
|
| 23 |
queryKey: ['workspace-users'],
|
| 24 |
queryFn: async () => {
|
| 25 |
+
const res = await apiFetch('/api/workspace/users')
|
| 26 |
+
const json = await res.json()
|
| 27 |
+
return (json.users ?? []) as WorkspaceUser[]
|
| 28 |
},
|
| 29 |
})
|
| 30 |
+
const users = data ?? []
|
| 31 |
|
| 32 |
+
const { mutate: sendInvite, isPending: isSending } = useMutation({
|
|
|
|
| 33 |
mutationFn: async (input: UserInvite) => {
|
| 34 |
+
const res = await apiFetch('/api/auth/invite', {
|
| 35 |
+
method: 'POST',
|
| 36 |
+
headers: { 'Content-Type': 'application/json' },
|
| 37 |
+
body: JSON.stringify({ email: input.email, name: input.name, role: input.role }),
|
| 38 |
+
})
|
| 39 |
+
if (!res.ok) {
|
| 40 |
+
const body = await res.json().catch(() => ({}))
|
| 41 |
+
throw new Error(body.detail ?? `Error ${res.status}`)
|
| 42 |
+
}
|
| 43 |
+
return res.json() as Promise<{ ok: boolean; email: string; invite_url: string; email_sent: boolean }>
|
| 44 |
},
|
| 45 |
+
onSuccess: (data) => {
|
| 46 |
setFormData({ email: '', name: '', role: 'engineer' })
|
| 47 |
+
setError(null)
|
| 48 |
+
setInviteLink({ url: data.invite_url, emailSent: data.email_sent })
|
| 49 |
+
qc.invalidateQueries({ queryKey: ['workspace-users'] })
|
| 50 |
+
},
|
| 51 |
+
onError: (err: Error) => {
|
| 52 |
+
setError(err.message)
|
| 53 |
},
|
| 54 |
})
|
| 55 |
|
| 56 |
+
const { mutate: removeUser } = useMutation({
|
| 57 |
+
mutationFn: async (userId: string) => {
|
| 58 |
+
const res = await apiFetch(`/api/workspace/users/${userId}`, { method: 'DELETE' })
|
| 59 |
+
if (!res.ok) throw new Error(`Error ${res.status}`)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
},
|
| 61 |
+
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace-users'] }),
|
| 62 |
})
|
| 63 |
|
| 64 |
+
const handleInvite = () => {
|
| 65 |
if (!formData.email.trim() || !formData.name.trim()) return
|
| 66 |
+
setError(null)
|
| 67 |
+
setInviteLink(null as null)
|
| 68 |
+
sendInvite(formData)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
return (
|
| 72 |
<div className="space-y-6 p-6">
|
| 73 |
+
{/* Header */}
|
| 74 |
<div className="flex items-center justify-between">
|
| 75 |
<div>
|
| 76 |
<h3 className="text-sm font-semibold">Workspace Users</h3>
|
| 77 |
+
<p className="mt-1 text-xs text-stone-500">{users.length} user{users.length !== 1 ? 's' : ''}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
</div>
|
| 79 |
+
<button
|
| 80 |
+
onClick={() => { setShowAddForm(!showAddForm); setInviteLink(null as null); setError(null) }}
|
| 81 |
+
className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark"
|
| 82 |
+
>
|
| 83 |
+
+ Invite User
|
| 84 |
+
</button>
|
| 85 |
</div>
|
| 86 |
|
| 87 |
+
{/* Invite Form */}
|
| 88 |
{showAddForm && (
|
| 89 |
<div className="space-y-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-stone-900">
|
| 90 |
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
| 91 |
+
<div>
|
| 92 |
+
<label className="block text-xs font-medium text-stone-700 dark:text-stone-300">Name</label>
|
| 93 |
+
<input
|
| 94 |
+
type="text"
|
| 95 |
+
value={formData.name}
|
| 96 |
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
| 97 |
+
placeholder="Jane Smith"
|
| 98 |
+
className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
|
| 99 |
+
/>
|
| 100 |
+
</div>
|
| 101 |
+
<div>
|
| 102 |
+
<label className="block text-xs font-medium text-stone-700 dark:text-stone-300">Email</label>
|
| 103 |
+
<input
|
| 104 |
+
type="email"
|
| 105 |
+
value={formData.email}
|
| 106 |
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
| 107 |
+
placeholder="jane@company.com"
|
| 108 |
+
className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
|
| 109 |
+
/>
|
| 110 |
+
</div>
|
| 111 |
+
<div>
|
| 112 |
+
<label className="block text-xs font-medium text-stone-700 dark:text-stone-300">Role</label>
|
| 113 |
+
<select
|
| 114 |
+
value={formData.role}
|
| 115 |
+
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserInvite['role'] })}
|
| 116 |
+
className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
|
| 117 |
+
>
|
| 118 |
+
<option value="engineer">Engineer</option>
|
| 119 |
+
<option value="manager">Manager</option>
|
| 120 |
+
<option value="admin">Admin</option>
|
| 121 |
+
<option value="org_admin">Org Admin</option>
|
| 122 |
+
</select>
|
| 123 |
+
</div>
|
| 124 |
</div>
|
| 125 |
|
| 126 |
+
{error && (
|
| 127 |
+
<p className="rounded bg-red-100 px-3 py-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
| 128 |
+
{error}
|
| 129 |
+
</p>
|
| 130 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
<div className="flex gap-2">
|
| 133 |
<button
|
| 134 |
+
onClick={handleInvite}
|
| 135 |
+
disabled={isSending || !formData.email.trim() || !formData.name.trim()}
|
| 136 |
className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark disabled:opacity-50"
|
| 137 |
>
|
| 138 |
+
{isSending ? 'Sending...' : 'Send Invite'}
|
| 139 |
</button>
|
| 140 |
<button
|
| 141 |
+
onClick={() => { setShowAddForm(false); setInviteLink(null as null); setError(null) }}
|
| 142 |
className="rounded border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-100 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-700"
|
| 143 |
>
|
| 144 |
Cancel
|
| 145 |
</button>
|
| 146 |
</div>
|
|
|
|
|
|
|
| 147 |
|
| 148 |
+
{inviteLink && (
|
| 149 |
+
<div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950/30">
|
| 150 |
+
<p className="mb-1 text-xs font-medium text-green-800 dark:text-green-300">
|
| 151 |
+
{inviteLink.emailSent
|
| 152 |
+
? 'Invite email sent! Share the link below as backup:'
|
| 153 |
+
: 'Email not configured — share this link directly with the user:'}
|
| 154 |
+
</p>
|
| 155 |
+
<div className="flex items-center gap-2">
|
| 156 |
+
<input
|
| 157 |
+
readOnly
|
| 158 |
+
value={inviteLink.url}
|
| 159 |
+
className="flex-1 rounded border border-green-300 bg-white px-2 py-1.5 text-xs font-mono text-stone-800 dark:border-green-700 dark:bg-stone-800 dark:text-stone-200"
|
| 160 |
+
/>
|
| 161 |
+
<button
|
| 162 |
+
onClick={() => navigator.clipboard.writeText(inviteLink.url)}
|
| 163 |
+
className="rounded border border-green-300 px-2 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 dark:border-green-700 dark:text-green-400 dark:hover:bg-green-900/40"
|
| 164 |
+
>
|
| 165 |
+
Copy
|
| 166 |
+
</button>
|
| 167 |
+
</div>
|
| 168 |
+
<p className="mt-1 text-xs text-green-700 dark:text-green-400">Expires in 7 days.</p>
|
| 169 |
+
</div>
|
| 170 |
+
)}
|
|
|
|
|
|
|
| 171 |
</div>
|
| 172 |
)}
|
| 173 |
|
| 174 |
+
{/* Users Table */}
|
| 175 |
<div>
|
|
|
|
| 176 |
{isLoading ? (
|
| 177 |
<LoadingSkeleton rows={5} className="h-10" />
|
| 178 |
+
) : users.length > 0 ? (
|
| 179 |
<div className="overflow-x-auto rounded-lg border border-stone-200 dark:border-stone-700">
|
| 180 |
<table className="w-full text-left text-sm">
|
| 181 |
<thead className="border-b border-stone-200 bg-stone-50 dark:border-stone-700 dark:bg-stone-800">
|
|
|
|
| 183 |
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Name</th>
|
| 184 |
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Email</th>
|
| 185 |
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Role</th>
|
| 186 |
+
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Status</th>
|
| 187 |
+
<th className="px-4 py-2 text-right font-semibold text-stone-700 dark:text-stone-300">Actions</th>
|
|
|
|
|
|
|
| 188 |
</tr>
|
| 189 |
</thead>
|
| 190 |
<tbody className="divide-y divide-stone-200 dark:divide-stone-700">
|
|
|
|
| 197 |
{u.role}
|
| 198 |
</span>
|
| 199 |
</td>
|
| 200 |
+
<td className="px-4 py-3">
|
| 201 |
+
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
| 202 |
+
u.is_active
|
| 203 |
+
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
| 204 |
+
: 'bg-stone-100 text-stone-500 dark:bg-stone-700 dark:text-stone-400'
|
| 205 |
+
}`}>
|
| 206 |
+
{u.is_active ? 'Active' : 'Inactive'}
|
| 207 |
+
</span>
|
| 208 |
+
</td>
|
| 209 |
<td className="px-4 py-3 text-right">
|
| 210 |
+
<button
|
| 211 |
+
onClick={() => {
|
| 212 |
+
if (confirm(`Remove ${u.name} from workspace?`)) removeUser(u.id)
|
| 213 |
+
}}
|
| 214 |
+
className="text-xs font-medium text-stone-500 hover:text-red-600"
|
| 215 |
+
>
|
| 216 |
Remove
|
| 217 |
</button>
|
| 218 |
</td>
|
|
|
|
| 224 |
) : (
|
| 225 |
<div className="rounded-lg border-2 border-dashed border-stone-300 p-6 text-center dark:border-stone-600">
|
| 226 |
<p className="text-sm text-stone-500">No users yet</p>
|
| 227 |
+
<p className="mt-1 text-xs text-stone-400">Click "Invite User" to add your first team member</p>
|
| 228 |
</div>
|
| 229 |
)}
|
| 230 |
</div>
|
frontend/src/components/admin/SyncTrigger.tsx
CHANGED
|
@@ -1,23 +1,70 @@
|
|
| 1 |
-
import { useState } from 'react'
|
| 2 |
import { apiFetch, ApiError } from '@/lib/http'
|
| 3 |
import { useUIStore } from '@/stores/uiStore'
|
| 4 |
import { cn } from '@/lib/utils'
|
| 5 |
|
|
|
|
|
|
|
| 6 |
interface Props {
|
| 7 |
source: 'jira' | 'confluence'
|
| 8 |
-
/** project key for Jira, space key for Confluence */
|
| 9 |
sourceKey: string
|
| 10 |
label: string
|
| 11 |
lastSynced?: string
|
| 12 |
}
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
export function SyncTrigger({ source, sourceKey, label, lastSynced }: Props) {
|
| 15 |
-
const [taskId,
|
| 16 |
-
const [
|
| 17 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
const trigger = async () => {
|
| 20 |
setLoading(true)
|
|
|
|
|
|
|
| 21 |
try {
|
| 22 |
const path = source === 'jira'
|
| 23 |
? `/jira/sync/${sourceKey}`
|
|
@@ -25,39 +72,53 @@ export function SyncTrigger({ source, sourceKey, label, lastSynced }: Props) {
|
|
| 25 |
const res = await apiFetch(path, { method: 'POST' })
|
| 26 |
const data = await res.json() as { task_id: string }
|
| 27 |
setTaskId(data.task_id)
|
|
|
|
| 28 |
addToast({ type: 'success', message: `${label} sync queued` })
|
| 29 |
} catch (err) {
|
| 30 |
if (!(err instanceof ApiError)) {
|
| 31 |
addToast({ type: 'error', message: `Failed to trigger ${label} sync` })
|
| 32 |
}
|
| 33 |
-
// ApiError toasts are handled in apiFetch
|
| 34 |
} finally {
|
| 35 |
setLoading(false)
|
| 36 |
}
|
| 37 |
}
|
| 38 |
|
|
|
|
|
|
|
| 39 |
return (
|
| 40 |
<div className="flex items-center justify-between rounded-lg border border-surface-subtle p-4">
|
| 41 |
<div>
|
| 42 |
<p className="text-sm font-medium">{label}</p>
|
| 43 |
-
{lastSynced && (
|
| 44 |
<p className="text-xs text-stone-500">Last synced {lastSynced}</p>
|
| 45 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
{taskId && (
|
| 47 |
-
<p className="mt-0.5 font-mono text-
|
|
|
|
|
|
|
| 48 |
)}
|
| 49 |
</div>
|
| 50 |
<button
|
| 51 |
onClick={trigger}
|
| 52 |
-
disabled={
|
| 53 |
className={cn(
|
| 54 |
'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
| 55 |
-
|
| 56 |
? 'cursor-not-allowed bg-stone-100 text-stone-400 dark:bg-stone-800'
|
| 57 |
: 'bg-brand text-white hover:bg-brand-dark',
|
| 58 |
)}
|
| 59 |
>
|
| 60 |
-
{loading ? 'Queuing…' : 'Sync now'}
|
| 61 |
</button>
|
| 62 |
</div>
|
| 63 |
)
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from 'react'
|
| 2 |
import { apiFetch, ApiError } from '@/lib/http'
|
| 3 |
import { useUIStore } from '@/stores/uiStore'
|
| 4 |
import { cn } from '@/lib/utils'
|
| 5 |
|
| 6 |
+
type JobStatus = 'pending' | 'running' | 'completed' | 'failed'
|
| 7 |
+
|
| 8 |
interface Props {
|
| 9 |
source: 'jira' | 'confluence'
|
|
|
|
| 10 |
sourceKey: string
|
| 11 |
label: string
|
| 12 |
lastSynced?: string
|
| 13 |
}
|
| 14 |
|
| 15 |
+
const STATUS_LABEL: Record<JobStatus, string> = {
|
| 16 |
+
pending: 'Queued…',
|
| 17 |
+
running: 'Syncing…',
|
| 18 |
+
completed: 'Sync complete',
|
| 19 |
+
failed: 'Sync failed',
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const STATUS_COLOR: Record<JobStatus, string> = {
|
| 23 |
+
pending: 'text-stone-400',
|
| 24 |
+
running: 'text-blue-500',
|
| 25 |
+
completed: 'text-green-600',
|
| 26 |
+
failed: 'text-red-500',
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
export function SyncTrigger({ source, sourceKey, label, lastSynced }: Props) {
|
| 30 |
+
const [taskId, setTaskId] = useState<string | null>(null)
|
| 31 |
+
const [jobStatus, setJobStatus] = useState<JobStatus | null>(null)
|
| 32 |
+
const [loading, setLoading] = useState(false)
|
| 33 |
+
const addToast = useUIStore((s) => s.addToast)
|
| 34 |
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
| 35 |
+
|
| 36 |
+
// Poll job status until terminal state
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
if (!taskId) return
|
| 39 |
+
if (pollRef.current) clearInterval(pollRef.current)
|
| 40 |
+
|
| 41 |
+
const poll = async () => {
|
| 42 |
+
try {
|
| 43 |
+
const res = await apiFetch(`/ingest/jobs/${taskId}`)
|
| 44 |
+
const data = await res.json() as { status: JobStatus }
|
| 45 |
+
setJobStatus(data.status)
|
| 46 |
+
if (data.status === 'completed' || data.status === 'failed') {
|
| 47 |
+
clearInterval(pollRef.current!)
|
| 48 |
+
pollRef.current = null
|
| 49 |
+
addToast({
|
| 50 |
+
type: data.status === 'completed' ? 'success' : 'error',
|
| 51 |
+
message: data.status === 'completed'
|
| 52 |
+
? `${label} sync finished`
|
| 53 |
+
: `${label} sync failed`,
|
| 54 |
+
})
|
| 55 |
+
}
|
| 56 |
+
} catch { /* ignore poll errors */ }
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
poll() // immediate first check
|
| 60 |
+
pollRef.current = setInterval(poll, 4000)
|
| 61 |
+
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
| 62 |
+
}, [taskId]) // eslint-disable-line react-hooks/exhaustive-deps
|
| 63 |
|
| 64 |
const trigger = async () => {
|
| 65 |
setLoading(true)
|
| 66 |
+
setTaskId(null)
|
| 67 |
+
setJobStatus(null)
|
| 68 |
try {
|
| 69 |
const path = source === 'jira'
|
| 70 |
? `/jira/sync/${sourceKey}`
|
|
|
|
| 72 |
const res = await apiFetch(path, { method: 'POST' })
|
| 73 |
const data = await res.json() as { task_id: string }
|
| 74 |
setTaskId(data.task_id)
|
| 75 |
+
setJobStatus('pending')
|
| 76 |
addToast({ type: 'success', message: `${label} sync queued` })
|
| 77 |
} catch (err) {
|
| 78 |
if (!(err instanceof ApiError)) {
|
| 79 |
addToast({ type: 'error', message: `Failed to trigger ${label} sync` })
|
| 80 |
}
|
|
|
|
| 81 |
} finally {
|
| 82 |
setLoading(false)
|
| 83 |
}
|
| 84 |
}
|
| 85 |
|
| 86 |
+
const isBusy = loading || jobStatus === 'pending' || jobStatus === 'running'
|
| 87 |
+
|
| 88 |
return (
|
| 89 |
<div className="flex items-center justify-between rounded-lg border border-surface-subtle p-4">
|
| 90 |
<div>
|
| 91 |
<p className="text-sm font-medium">{label}</p>
|
| 92 |
+
{lastSynced && !jobStatus && (
|
| 93 |
<p className="text-xs text-stone-500">Last synced {lastSynced}</p>
|
| 94 |
)}
|
| 95 |
+
{jobStatus && (
|
| 96 |
+
<div className="mt-1 flex items-center gap-1.5">
|
| 97 |
+
{(jobStatus === 'pending' || jobStatus === 'running') && (
|
| 98 |
+
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border-2 border-blue-300 border-t-blue-600" />
|
| 99 |
+
)}
|
| 100 |
+
<p className={cn('text-xs font-medium', STATUS_COLOR[jobStatus])}>
|
| 101 |
+
{STATUS_LABEL[jobStatus]}
|
| 102 |
+
</p>
|
| 103 |
+
</div>
|
| 104 |
+
)}
|
| 105 |
{taskId && (
|
| 106 |
+
<p className="mt-0.5 font-mono text-[10px] text-stone-400">
|
| 107 |
+
Task {taskId.slice(0, 8)}…
|
| 108 |
+
</p>
|
| 109 |
)}
|
| 110 |
</div>
|
| 111 |
<button
|
| 112 |
onClick={trigger}
|
| 113 |
+
disabled={isBusy}
|
| 114 |
className={cn(
|
| 115 |
'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
| 116 |
+
isBusy
|
| 117 |
? 'cursor-not-allowed bg-stone-100 text-stone-400 dark:bg-stone-800'
|
| 118 |
: 'bg-brand text-white hover:bg-brand-dark',
|
| 119 |
)}
|
| 120 |
>
|
| 121 |
+
{loading ? 'Queuing…' : isBusy ? 'Syncing…' : 'Sync now'}
|
| 122 |
</button>
|
| 123 |
</div>
|
| 124 |
)
|
frontend/src/components/common/Sidebar.tsx
CHANGED
|
@@ -165,7 +165,7 @@ export function Sidebar() {
|
|
| 165 |
{recent.map((item) => (
|
| 166 |
<button
|
| 167 |
key={item.id}
|
| 168 |
-
onClick={() => navigate({ to: '/query', search: { q: item.query } })}
|
| 169 |
title={item.query}
|
| 170 |
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-stone-50 dark:hover:bg-stone-900"
|
| 171 |
>
|
|
@@ -175,7 +175,7 @@ export function Sidebar() {
|
|
| 175 |
item.success ? 'bg-green-400' : 'bg-stone-300',
|
| 176 |
)}
|
| 177 |
/>
|
| 178 |
-
<span className="truncate text-xs text-stone-500 dark:text-stone-400">
|
| 179 |
{item.query.length > 34 ? item.query.slice(0, 34) + '…' : item.query}
|
| 180 |
</span>
|
| 181 |
</button>
|
|
|
|
| 165 |
{recent.map((item) => (
|
| 166 |
<button
|
| 167 |
key={item.id}
|
| 168 |
+
onClick={() => navigate({ to: '/query', search: { q: item.query, qid: undefined, fresh: false } })}
|
| 169 |
title={item.query}
|
| 170 |
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-stone-50 dark:hover:bg-stone-900"
|
| 171 |
>
|
|
|
|
| 175 |
item.success ? 'bg-green-400' : 'bg-stone-300',
|
| 176 |
)}
|
| 177 |
/>
|
| 178 |
+
<span className="truncate text-xs text-stone-500 dark:text-stone-400" title={item.query}>
|
| 179 |
{item.query.length > 34 ? item.query.slice(0, 34) + '…' : item.query}
|
| 180 |
</span>
|
| 181 |
</button>
|
frontend/src/components/query/SuggestedTopics.tsx
CHANGED
|
@@ -3,11 +3,11 @@ interface Props {
|
|
| 3 |
}
|
| 4 |
|
| 5 |
const DEFAULTS = [
|
| 6 |
-
'What
|
| 7 |
-
'
|
| 8 |
-
'
|
| 9 |
-
'What
|
| 10 |
-
'
|
| 11 |
]
|
| 12 |
|
| 13 |
export function SuggestedTopics({ onSelect }: Props) {
|
|
|
|
| 3 |
}
|
| 4 |
|
| 5 |
const DEFAULTS = [
|
| 6 |
+
'What is Godspeed and what does it do?',
|
| 7 |
+
'How do I set up Godspeed locally?',
|
| 8 |
+
'What are the main components of the Godspeed architecture?',
|
| 9 |
+
'What API endpoints does Godspeed expose?',
|
| 10 |
+
'What is the incident runbook for Godspeed?',
|
| 11 |
]
|
| 12 |
|
| 13 |
export function SuggestedTopics({ onSelect }: Props) {
|
frontend/src/components/results/KnowledgeGraph.tsx
CHANGED
|
@@ -68,6 +68,13 @@ export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHov
|
|
| 68 |
if (!containerRef.current) return
|
| 69 |
|
| 70 |
const el = containerRef.current
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
const fg = ForceGraph2D()
|
| 72 |
fg(el)
|
| 73 |
fg.backgroundColor('transparent')
|
|
@@ -85,7 +92,7 @@ export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHov
|
|
| 85 |
onNodeClick({ id: n.id, label: n.label, name: n.name })
|
| 86 |
})
|
| 87 |
.onNodeHover((n: FGNode | null) => {
|
| 88 |
-
onNodeHover(n ? { id: n.id, label: n.label, name: n.name } : null,
|
| 89 |
})
|
| 90 |
graphRef.current = fg
|
| 91 |
|
|
@@ -108,6 +115,7 @@ export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHov
|
|
| 108 |
ro?.disconnect()
|
| 109 |
graphRef.current?._destructor?.()
|
| 110 |
graphRef.current = null
|
|
|
|
| 111 |
}
|
| 112 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 113 |
}, [])
|
|
|
|
| 68 |
if (!containerRef.current) return
|
| 69 |
|
| 70 |
const el = containerRef.current
|
| 71 |
+
|
| 72 |
+
// Track real mouse position since force-graph's onNodeHover doesn't provide it
|
| 73 |
+
let mouseX = 0
|
| 74 |
+
let mouseY = 0
|
| 75 |
+
const trackMouse = (e: MouseEvent) => { mouseX = e.clientX; mouseY = e.clientY }
|
| 76 |
+
el.addEventListener('mousemove', trackMouse)
|
| 77 |
+
|
| 78 |
const fg = ForceGraph2D()
|
| 79 |
fg(el)
|
| 80 |
fg.backgroundColor('transparent')
|
|
|
|
| 92 |
onNodeClick({ id: n.id, label: n.label, name: n.name })
|
| 93 |
})
|
| 94 |
.onNodeHover((n: FGNode | null) => {
|
| 95 |
+
onNodeHover(n ? { id: n.id, label: n.label, name: n.name } : null, mouseX, mouseY)
|
| 96 |
})
|
| 97 |
graphRef.current = fg
|
| 98 |
|
|
|
|
| 115 |
ro?.disconnect()
|
| 116 |
graphRef.current?._destructor?.()
|
| 117 |
graphRef.current = null
|
| 118 |
+
// trackMouse listener is on el which is removed from DOM — GC handles it
|
| 119 |
}
|
| 120 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 121 |
}, [])
|
frontend/src/components/results/ResultsPage.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 2 |
-
import { useSearch } from '@tanstack/react-router'
|
| 3 |
import { useAuthStore } from '@/stores/authStore'
|
| 4 |
import { useUIStore } from '@/stores/uiStore'
|
| 5 |
import { useSSEStream } from '@/hooks/useSSEStream'
|
|
@@ -27,13 +27,28 @@ import type {
|
|
| 27 |
|
| 28 |
type Tab = 'graph' | 'answer'
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
export function ResultsPage() {
|
| 31 |
const user = useAuthStore((s) => s.user)
|
|
|
|
| 32 |
const { graphCollapsed, toggleGraphCollapsed } = useUIStore()
|
| 33 |
const sessionRef = useRef(crypto.randomUUID())
|
| 34 |
-
const
|
|
|
|
| 35 |
|
| 36 |
-
// ──
|
|
|
|
|
|
|
|
|
|
| 37 |
const [plan, setPlan] = useState<AgentTask[]>([])
|
| 38 |
const [agentStatuses, setStatuses] = useState<Record<string, AgentStatus>>({})
|
| 39 |
const [answerText, setAnswerText] = useState('')
|
|
@@ -43,28 +58,39 @@ export function ResultsPage() {
|
|
| 43 |
const [queryId, setQueryId] = useState('')
|
| 44 |
const [shareOpen, setShareOpen] = useState(false)
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
// ── Graph state ─────────────────────────────────────────────────────────────
|
| 47 |
const [graphNodes, setGraphNodes] = useState<GraphNode[]>([])
|
| 48 |
const [graphEdges, setGraphEdges] = useState<GraphEdge[]>([])
|
| 49 |
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null)
|
| 50 |
const [hoverPos, setHoverPos] = useState({ x: 0, y: 0 })
|
| 51 |
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
|
| 52 |
-
// Accumulated in refs to avoid setState on every 50ms node message
|
| 53 |
const nodesAccRef = useRef<GraphNode[]>([])
|
| 54 |
const edgesAccRef = useRef<GraphEdge[]>([])
|
| 55 |
|
| 56 |
// ── Mobile tab ──────────────────────────────────────────────────────────────
|
| 57 |
const [activeTab, setActiveTab] = useState<Tab>('graph')
|
| 58 |
-
// Local — not persisted; maximize is a session-level power-user action
|
| 59 |
const [graphMaximized, setGraphMaximized] = useState(false)
|
| 60 |
|
| 61 |
// ── Hooks ───────────────────────────────────────────────────────────────────
|
| 62 |
const { state, error, firstEventArrived, stream } = useSSEStream()
|
| 63 |
const { gState, retryCount, connect, disconnect } = useGraphStream()
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
//
|
|
|
|
| 68 |
const reconnectGraph = useCallback(() => {
|
| 69 |
nodesAccRef.current = []
|
| 70 |
edgesAccRef.current = []
|
|
@@ -88,7 +114,21 @@ export function ResultsPage() {
|
|
| 88 |
// ── Query runner ─────────────────────────────────────────────────────────────
|
| 89 |
const runQuery = useCallback(
|
| 90 |
async (query: string) => {
|
| 91 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
setPlan([])
|
| 93 |
setStatuses({})
|
| 94 |
setAnswerText('')
|
|
@@ -97,7 +137,7 @@ export function ResultsPage() {
|
|
| 97 |
setCurrentQuery(query)
|
| 98 |
setQueryId(crypto.randomUUID())
|
| 99 |
setShareOpen(false)
|
| 100 |
-
setActiveTab('graph')
|
| 101 |
setSelectedNode(null)
|
| 102 |
|
| 103 |
reconnectGraph()
|
|
@@ -153,13 +193,65 @@ export function ResultsPage() {
|
|
| 153 |
setSelectedNode(node)
|
| 154 |
}, [])
|
| 155 |
|
| 156 |
-
// Auto-fire from URL ?q= on mount
|
| 157 |
const runQueryRef = useRef(runQuery)
|
| 158 |
runQueryRef.current = runQuery
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
if (initialQuery) runQueryRef.current(initialQuery)
|
| 161 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 162 |
-
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
// ── Derived ─────────────────────────────────────────────────────────────────
|
| 165 |
const isLoading = state === 'loading'
|
|
@@ -167,36 +259,34 @@ export function ResultsPage() {
|
|
| 167 |
const isComplete = state === 'complete'
|
| 168 |
const isError = state === 'error'
|
| 169 |
const isActive = isLoading || isStreaming
|
| 170 |
-
const hasData = firstEventArrived.current
|
| 171 |
|
| 172 |
-
// ── Tab indicators ──────────────────────────────────────────────────────────
|
| 173 |
const graphHasContent = graphNodes.length > 0
|
| 174 |
const answerHasContent = !!answerText || plan.length > 0
|
| 175 |
|
| 176 |
return (
|
| 177 |
<div className="mx-auto flex min-h-screen max-w-7xl flex-col gap-6 px-4 py-8">
|
| 178 |
|
| 179 |
-
{/* Search bar
|
| 180 |
<SearchBox
|
|
|
|
| 181 |
onSubmit={runQuery}
|
| 182 |
disabled={isActive}
|
| 183 |
-
defaultValue={initialQuery ?? ''}
|
| 184 |
/>
|
| 185 |
|
| 186 |
-
{/* Idle home state
|
| 187 |
-
{state === 'idle' && !currentQuery && (
|
| 188 |
<div className="flex flex-1 items-center justify-center">
|
| 189 |
<p className="text-sm text-stone-400">Ask a question to get started.</p>
|
| 190 |
</div>
|
| 191 |
)}
|
| 192 |
|
| 193 |
-
{/* ── Results layout
|
| 194 |
-
{currentQuery && (
|
| 195 |
<>
|
| 196 |
-
{/* Mobile tab bar
|
| 197 |
-
{/* Answer tab is disabled (grey) until first SSE event arrives */}
|
| 198 |
<div className="flex rounded-xl border border-surface-subtle bg-stone-50 p-1 dark:bg-stone-800/50 lg:hidden">
|
| 199 |
-
{/* Graph tab — always accessible; shows animation even before nodes arrive */}
|
| 200 |
<button
|
| 201 |
onClick={() => setActiveTab('graph')}
|
| 202 |
className={cn(
|
|
@@ -213,14 +303,12 @@ export function ResultsPage() {
|
|
| 213 |
</span>
|
| 214 |
)}
|
| 215 |
</button>
|
| 216 |
-
|
| 217 |
-
{/* Answer tab — disabled (grey, not clickable) until first SSE event */}
|
| 218 |
<button
|
| 219 |
onClick={() => setActiveTab('answer')}
|
| 220 |
-
disabled={!hasData}
|
| 221 |
className={cn(
|
| 222 |
'flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-medium transition-colors',
|
| 223 |
-
!hasData
|
| 224 |
? 'cursor-not-allowed text-stone-300 dark:text-stone-600'
|
| 225 |
: activeTab === 'answer'
|
| 226 |
? 'bg-white text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
|
|
@@ -228,26 +316,23 @@ export function ResultsPage() {
|
|
| 228 |
)}
|
| 229 |
>
|
| 230 |
Answer
|
| 231 |
-
{/* Pulsing dot only once answer has data and is still streaming */}
|
| 232 |
{hasData && isActive && answerHasContent && (
|
| 233 |
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand" />
|
| 234 |
)}
|
| 235 |
</button>
|
| 236 |
</div>
|
| 237 |
|
| 238 |
-
{/* Two-column grid
|
| 239 |
<div className={cn(
|
| 240 |
'flex flex-col gap-6 lg:grid lg:items-start lg:gap-8',
|
| 241 |
graphCollapsed ? 'lg:grid-cols-1' : 'lg:grid-cols-[1fr_380px]',
|
| 242 |
)}>
|
| 243 |
|
| 244 |
-
{/* ── Left:
|
| 245 |
<div className={cn(
|
| 246 |
-
'flex flex-col gap-
|
| 247 |
activeTab === 'graph' && 'hidden lg:flex',
|
| 248 |
)}>
|
| 249 |
-
|
| 250 |
-
{/* Show-graph chip — visible on desktop when graph panel is collapsed */}
|
| 251 |
{graphCollapsed && (
|
| 252 |
<button
|
| 253 |
onClick={toggleGraphCollapsed}
|
|
@@ -257,88 +342,98 @@ export function ResultsPage() {
|
|
| 257 |
</button>
|
| 258 |
)}
|
| 259 |
|
| 260 |
-
{/*
|
| 261 |
-
{
|
| 262 |
-
<div className="flex
|
| 263 |
-
<
|
| 264 |
-
|
|
|
|
| 265 |
</div>
|
| 266 |
-
)}
|
| 267 |
-
|
| 268 |
-
{/*
|
| 269 |
-
{
|
| 270 |
-
<
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
<
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
</div>
|
| 302 |
)}
|
| 303 |
|
| 304 |
-
{
|
| 305 |
-
{citations.length > 0 && <Citations chunks={citations} />}
|
| 306 |
|
| 307 |
-
{/*
|
| 308 |
-
{isComplete
|
| 309 |
-
<
|
| 310 |
-
)}
|
| 311 |
-
|
| 312 |
-
{/* Footer actions */}
|
| 313 |
-
{isComplete && (
|
| 314 |
-
<div className="flex items-center justify-between gap-4">
|
| 315 |
-
<QueryFeedback queryId={queryId} />
|
| 316 |
-
<button
|
| 317 |
-
onClick={() => setShareOpen(true)}
|
| 318 |
-
className="shrink-0 text-xs text-stone-400 underline hover:text-stone-600"
|
| 319 |
-
>
|
| 320 |
-
Share
|
| 321 |
-
</button>
|
| 322 |
-
</div>
|
| 323 |
)}
|
| 324 |
-
|
| 325 |
-
{isComplete && <FollowUp onSubmit={runQuery} />}
|
| 326 |
</div>
|
| 327 |
|
| 328 |
{/* ── Right: graph panel ───────────────────────────────────────── */}
|
| 329 |
-
{/* On mobile: controlled by activeTab. On desktop: hidden when graphCollapsed */}
|
| 330 |
<div className={cn(
|
| 331 |
'flex flex-col overflow-hidden rounded-xl border border-surface-subtle',
|
| 332 |
-
// Mobile: toggle by tab; desktop: toggle by collapsed state
|
| 333 |
activeTab === 'answer' ? 'hidden lg:flex' : 'flex',
|
| 334 |
graphCollapsed && 'lg:hidden',
|
| 335 |
-
// Maximized: fixed fullscreen overlay
|
| 336 |
graphMaximized
|
| 337 |
? 'fixed inset-0 z-50 rounded-none border-0 bg-white dark:bg-stone-950'
|
| 338 |
: 'h-[440px]',
|
| 339 |
)}>
|
| 340 |
-
|
| 341 |
-
{/* Graph toolbar */}
|
| 342 |
<div className="flex shrink-0 items-center gap-2 border-b border-surface-subtle px-3 py-2">
|
| 343 |
<span className="flex-1 text-xs font-semibold uppercase tracking-wide text-stone-400">
|
| 344 |
Knowledge Graph
|
|
@@ -348,7 +443,6 @@ export function ResultsPage() {
|
|
| 348 |
{graphNodes.length} nodes · {graphEdges.length} edges
|
| 349 |
</span>
|
| 350 |
)}
|
| 351 |
-
{/* Maximize / restore */}
|
| 352 |
<button
|
| 353 |
onClick={() => setGraphMaximized((m) => !m)}
|
| 354 |
title={graphMaximized ? 'Exit fullscreen' : 'Fullscreen'}
|
|
@@ -357,7 +451,6 @@ export function ResultsPage() {
|
|
| 357 |
>
|
| 358 |
{graphMaximized ? '⤡' : '⤢'}
|
| 359 |
</button>
|
| 360 |
-
{/* Collapse — desktop only; on mobile graph visibility is via tabs */}
|
| 361 |
<button
|
| 362 |
onClick={toggleGraphCollapsed}
|
| 363 |
title="Collapse graph"
|
|
@@ -368,7 +461,6 @@ export function ResultsPage() {
|
|
| 368 |
</button>
|
| 369 |
</div>
|
| 370 |
|
| 371 |
-
{/* Canvas — flex-1 fills remaining panel height */}
|
| 372 |
<KnowledgeGraph
|
| 373 |
nodes={graphNodes}
|
| 374 |
edges={graphEdges}
|
|
@@ -378,7 +470,6 @@ export function ResultsPage() {
|
|
| 378 |
className="flex-1"
|
| 379 |
/>
|
| 380 |
|
| 381 |
-
{/* Graph footer */}
|
| 382 |
<div className="flex shrink-0 items-center gap-2 border-t border-surface-subtle px-3 py-2">
|
| 383 |
{gState === 'done' && (
|
| 384 |
<button
|
|
@@ -392,7 +483,6 @@ export function ResultsPage() {
|
|
| 392 |
{gState === 'retrying' && <NetworkRetry attempt={retryCount + 1} />}
|
| 393 |
</div>
|
| 394 |
|
| 395 |
-
{/* Max-retries error bar */}
|
| 396 |
{gState === 'error' && (
|
| 397 |
<div className="flex shrink-0 items-center justify-between border-t border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-700 dark:bg-stone-800/40">
|
| 398 |
<span className="text-stone-500 dark:text-stone-400">
|
|
@@ -411,17 +501,14 @@ export function ResultsPage() {
|
|
| 411 |
</>
|
| 412 |
)}
|
| 413 |
|
| 414 |
-
{/* Tooltip — document-level fixed positioning */}
|
| 415 |
<GraphNodeTooltip node={hoveredNode} x={hoverPos.x} y={hoverPos.y} />
|
| 416 |
|
| 417 |
-
{/* Share modal */}
|
| 418 |
<ShareResults
|
| 419 |
query={currentQuery}
|
| 420 |
open={shareOpen}
|
| 421 |
onClose={() => setShareOpen(false)}
|
| 422 |
/>
|
| 423 |
|
| 424 |
-
{/* Node detail slide-in panel */}
|
| 425 |
<GraphNodeDetailPanel
|
| 426 |
node={selectedNode}
|
| 427 |
teamId={user?.team_id ?? 'default'}
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 2 |
+
import { useSearch, useNavigate } from '@tanstack/react-router'
|
| 3 |
import { useAuthStore } from '@/stores/authStore'
|
| 4 |
import { useUIStore } from '@/stores/uiStore'
|
| 5 |
import { useSSEStream } from '@/hooks/useSSEStream'
|
|
|
|
| 27 |
|
| 28 |
type Tab = 'graph' | 'answer'
|
| 29 |
|
| 30 |
+
const QR_PREFIX = 'gs_qr_'
|
| 31 |
+
|
| 32 |
+
interface Exchange {
|
| 33 |
+
id: string
|
| 34 |
+
query: string
|
| 35 |
+
answer: string
|
| 36 |
+
citations: RetrievedChunk[]
|
| 37 |
+
plan: AgentTask[]
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
export function ResultsPage() {
|
| 41 |
const user = useAuthStore((s) => s.user)
|
| 42 |
+
const navigate = useNavigate()
|
| 43 |
const { graphCollapsed, toggleGraphCollapsed } = useUIStore()
|
| 44 |
const sessionRef = useRef(crypto.randomUUID())
|
| 45 |
+
const savedRef = useRef(false)
|
| 46 |
+
const { q: initialQuery, qid: initialQid, fresh: forceRun } = useSearch({ from: '/query' })
|
| 47 |
|
| 48 |
+
// ── Conversation history ─────────────────────────────────────────────────────
|
| 49 |
+
const [exchanges, setExchanges] = useState<Exchange[]>([])
|
| 50 |
+
|
| 51 |
+
// ── SSE state (current streaming exchange) ───────────────────────────────────
|
| 52 |
const [plan, setPlan] = useState<AgentTask[]>([])
|
| 53 |
const [agentStatuses, setStatuses] = useState<Record<string, AgentStatus>>({})
|
| 54 |
const [answerText, setAnswerText] = useState('')
|
|
|
|
| 58 |
const [queryId, setQueryId] = useState('')
|
| 59 |
const [shareOpen, setShareOpen] = useState(false)
|
| 60 |
|
| 61 |
+
// Refs to read current values inside the completion effect without stale closure
|
| 62 |
+
const answerTextRef = useRef('')
|
| 63 |
+
const citationsRef = useRef<RetrievedChunk[]>([])
|
| 64 |
+
const planRef = useRef<AgentTask[]>([])
|
| 65 |
+
const currentQueryRef = useRef('')
|
| 66 |
+
|
| 67 |
+
// Keep refs in sync
|
| 68 |
+
useEffect(() => { answerTextRef.current = answerText }, [answerText])
|
| 69 |
+
useEffect(() => { citationsRef.current = citations }, [citations])
|
| 70 |
+
useEffect(() => { planRef.current = plan }, [plan])
|
| 71 |
+
useEffect(() => { currentQueryRef.current = currentQuery }, [currentQuery])
|
| 72 |
+
|
| 73 |
// ── Graph state ─────────────────────────────────────────────────────────────
|
| 74 |
const [graphNodes, setGraphNodes] = useState<GraphNode[]>([])
|
| 75 |
const [graphEdges, setGraphEdges] = useState<GraphEdge[]>([])
|
| 76 |
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null)
|
| 77 |
const [hoverPos, setHoverPos] = useState({ x: 0, y: 0 })
|
| 78 |
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
|
|
|
|
| 79 |
const nodesAccRef = useRef<GraphNode[]>([])
|
| 80 |
const edgesAccRef = useRef<GraphEdge[]>([])
|
| 81 |
|
| 82 |
// ── Mobile tab ──────────────────────────────────────────────────────────────
|
| 83 |
const [activeTab, setActiveTab] = useState<Tab>('graph')
|
|
|
|
| 84 |
const [graphMaximized, setGraphMaximized] = useState(false)
|
| 85 |
|
| 86 |
// ── Hooks ───────────────────────────────────────────────────────────────────
|
| 87 |
const { state, error, firstEventArrived, stream } = useSSEStream()
|
| 88 |
const { gState, retryCount, connect, disconnect } = useGraphStream()
|
| 89 |
|
| 90 |
+
useEffect(() => {
|
| 91 |
+
if (gState === 'error' && !graphCollapsed) toggleGraphCollapsed()
|
| 92 |
+
}, [gState]) // eslint-disable-line react-hooks/exhaustive-deps
|
| 93 |
+
|
| 94 |
const reconnectGraph = useCallback(() => {
|
| 95 |
nodesAccRef.current = []
|
| 96 |
edgesAccRef.current = []
|
|
|
|
| 114 |
// ── Query runner ─────────────────────────────────────────────────────────────
|
| 115 |
const runQuery = useCallback(
|
| 116 |
async (query: string) => {
|
| 117 |
+
// Save the current completed exchange to history before resetting
|
| 118 |
+
if (answerTextRef.current && currentQueryRef.current) {
|
| 119 |
+
setExchanges((prev) => [
|
| 120 |
+
...prev,
|
| 121 |
+
{
|
| 122 |
+
id: crypto.randomUUID(),
|
| 123 |
+
query: currentQueryRef.current,
|
| 124 |
+
answer: answerTextRef.current,
|
| 125 |
+
citations: citationsRef.current,
|
| 126 |
+
plan: planRef.current,
|
| 127 |
+
},
|
| 128 |
+
])
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
savedRef.current = false
|
| 132 |
setPlan([])
|
| 133 |
setStatuses({})
|
| 134 |
setAnswerText('')
|
|
|
|
| 137 |
setCurrentQuery(query)
|
| 138 |
setQueryId(crypto.randomUUID())
|
| 139 |
setShareOpen(false)
|
| 140 |
+
setActiveTab('graph')
|
| 141 |
setSelectedNode(null)
|
| 142 |
|
| 143 |
reconnectGraph()
|
|
|
|
| 193 |
setSelectedNode(node)
|
| 194 |
}, [])
|
| 195 |
|
|
|
|
| 196 |
const runQueryRef = useRef(runQuery)
|
| 197 |
runQueryRef.current = runQuery
|
| 198 |
+
const textKey = (q: string) => QR_PREFIX + 'q:' + q
|
| 199 |
+
|
| 200 |
+
const restoreFromCache = (raw: string): boolean => {
|
| 201 |
+
try {
|
| 202 |
+
const { query, answer, citations: cc } = JSON.parse(raw)
|
| 203 |
+
setCurrentQuery(query)
|
| 204 |
+
setAnswerText(answer)
|
| 205 |
+
setCitations(cc ?? [])
|
| 206 |
+
savedRef.current = true
|
| 207 |
+
return true
|
| 208 |
+
} catch { return false }
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const lastParamKeyRef = useRef('')
|
| 212 |
+
|
| 213 |
useEffect(() => {
|
| 214 |
+
const paramKey = `${initialQid ?? ''}|${initialQuery ?? ''}|${String(forceRun)}`
|
| 215 |
+
if (lastParamKeyRef.current === paramKey) return
|
| 216 |
+
lastParamKeyRef.current = paramKey
|
| 217 |
+
|
| 218 |
+
if (!forceRun) {
|
| 219 |
+
if (initialQid) {
|
| 220 |
+
try {
|
| 221 |
+
const raw = sessionStorage.getItem(QR_PREFIX + initialQid)
|
| 222 |
+
if (raw && restoreFromCache(raw)) return
|
| 223 |
+
} catch { /* ignore */ }
|
| 224 |
+
}
|
| 225 |
+
if (initialQuery) {
|
| 226 |
+
try {
|
| 227 |
+
const raw = sessionStorage.getItem(textKey(initialQuery))
|
| 228 |
+
if (raw && restoreFromCache(raw)) return
|
| 229 |
+
} catch { /* ignore */ }
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
if (initialQuery) runQueryRef.current(initialQuery)
|
| 233 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 234 |
+
}, [initialQuery, initialQid, forceRun])
|
| 235 |
+
|
| 236 |
+
// Cache completed result and update URL to ?qid=
|
| 237 |
+
useEffect(() => {
|
| 238 |
+
if (state !== 'complete' || !currentQuery || !answerText || savedRef.current) return
|
| 239 |
+
savedRef.current = true
|
| 240 |
+
const qid = sessionRef.current
|
| 241 |
+
const payload = JSON.stringify({ query: currentQuery, answer: answerText, citations })
|
| 242 |
+
try {
|
| 243 |
+
sessionStorage.setItem(QR_PREFIX + qid, payload)
|
| 244 |
+
sessionStorage.setItem(textKey(currentQuery), payload)
|
| 245 |
+
} catch { /* storage full */ }
|
| 246 |
+
navigate({ to: '/query', search: { qid, q: undefined, fresh: false }, replace: true })
|
| 247 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 248 |
+
}, [state])
|
| 249 |
+
|
| 250 |
+
// Scroll to bottom when new exchange added or answer streams in
|
| 251 |
+
const bottomRef = useRef<HTMLDivElement>(null)
|
| 252 |
+
useEffect(() => {
|
| 253 |
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
| 254 |
+
}, [exchanges.length, answerText])
|
| 255 |
|
| 256 |
// ── Derived ─────────────────────────────────────────────────────────────────
|
| 257 |
const isLoading = state === 'loading'
|
|
|
|
| 259 |
const isComplete = state === 'complete'
|
| 260 |
const isError = state === 'error'
|
| 261 |
const isActive = isLoading || isStreaming
|
| 262 |
+
const hasData = firstEventArrived.current
|
| 263 |
|
|
|
|
| 264 |
const graphHasContent = graphNodes.length > 0
|
| 265 |
const answerHasContent = !!answerText || plan.length > 0
|
| 266 |
|
| 267 |
return (
|
| 268 |
<div className="mx-auto flex min-h-screen max-w-7xl flex-col gap-6 px-4 py-8">
|
| 269 |
|
| 270 |
+
{/* Search bar */}
|
| 271 |
<SearchBox
|
| 272 |
+
key={currentQuery || initialQuery}
|
| 273 |
onSubmit={runQuery}
|
| 274 |
disabled={isActive}
|
| 275 |
+
defaultValue={currentQuery || (initialQuery ?? '')}
|
| 276 |
/>
|
| 277 |
|
| 278 |
+
{/* Idle home state */}
|
| 279 |
+
{state === 'idle' && !currentQuery && exchanges.length === 0 && (
|
| 280 |
<div className="flex flex-1 items-center justify-center">
|
| 281 |
<p className="text-sm text-stone-400">Ask a question to get started.</p>
|
| 282 |
</div>
|
| 283 |
)}
|
| 284 |
|
| 285 |
+
{/* ── Results layout ────────────────────��────────────────────────────── */}
|
| 286 |
+
{(currentQuery || exchanges.length > 0) && (
|
| 287 |
<>
|
| 288 |
+
{/* Mobile tab bar */}
|
|
|
|
| 289 |
<div className="flex rounded-xl border border-surface-subtle bg-stone-50 p-1 dark:bg-stone-800/50 lg:hidden">
|
|
|
|
| 290 |
<button
|
| 291 |
onClick={() => setActiveTab('graph')}
|
| 292 |
className={cn(
|
|
|
|
| 303 |
</span>
|
| 304 |
)}
|
| 305 |
</button>
|
|
|
|
|
|
|
| 306 |
<button
|
| 307 |
onClick={() => setActiveTab('answer')}
|
| 308 |
+
disabled={!hasData && exchanges.length === 0}
|
| 309 |
className={cn(
|
| 310 |
'flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-medium transition-colors',
|
| 311 |
+
!hasData && exchanges.length === 0
|
| 312 |
? 'cursor-not-allowed text-stone-300 dark:text-stone-600'
|
| 313 |
: activeTab === 'answer'
|
| 314 |
? 'bg-white text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
|
|
|
|
| 316 |
)}
|
| 317 |
>
|
| 318 |
Answer
|
|
|
|
| 319 |
{hasData && isActive && answerHasContent && (
|
| 320 |
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand" />
|
| 321 |
)}
|
| 322 |
</button>
|
| 323 |
</div>
|
| 324 |
|
| 325 |
+
{/* Two-column grid */}
|
| 326 |
<div className={cn(
|
| 327 |
'flex flex-col gap-6 lg:grid lg:items-start lg:gap-8',
|
| 328 |
graphCollapsed ? 'lg:grid-cols-1' : 'lg:grid-cols-[1fr_380px]',
|
| 329 |
)}>
|
| 330 |
|
| 331 |
+
{/* ── Left: conversation column ─────────────────────────────────── */}
|
| 332 |
<div className={cn(
|
| 333 |
+
'flex flex-col gap-8',
|
| 334 |
activeTab === 'graph' && 'hidden lg:flex',
|
| 335 |
)}>
|
|
|
|
|
|
|
| 336 |
{graphCollapsed && (
|
| 337 |
<button
|
| 338 |
onClick={toggleGraphCollapsed}
|
|
|
|
| 342 |
</button>
|
| 343 |
)}
|
| 344 |
|
| 345 |
+
{/* ── Completed past exchanges ─────────────────────────────── */}
|
| 346 |
+
{exchanges.map((ex) => (
|
| 347 |
+
<div key={ex.id} className="flex flex-col gap-4 border-b border-surface-subtle pb-8">
|
| 348 |
+
<p className="text-sm font-semibold text-stone-500">{ex.query}</p>
|
| 349 |
+
<Answer text={ex.answer} />
|
| 350 |
+
{ex.citations.length > 0 && <Citations chunks={ex.citations} />}
|
| 351 |
</div>
|
| 352 |
+
))}
|
| 353 |
+
|
| 354 |
+
{/* ── Current streaming exchange ────────────────────────────── */}
|
| 355 |
+
{currentQuery && (
|
| 356 |
+
<div className="flex flex-col gap-4">
|
| 357 |
+
{/* Show query heading only when there are prior exchanges */}
|
| 358 |
+
{exchanges.length > 0 && (
|
| 359 |
+
<p className="text-sm font-semibold text-stone-700 dark:text-stone-300">
|
| 360 |
+
{currentQuery}
|
| 361 |
+
</p>
|
| 362 |
+
)}
|
| 363 |
+
|
| 364 |
+
{isActive && !hasData && (
|
| 365 |
+
<div className="flex items-center gap-2 text-sm text-stone-400">
|
| 366 |
+
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-stone-200 border-t-stone-500" />
|
| 367 |
+
Thinking…
|
| 368 |
+
</div>
|
| 369 |
+
)}
|
| 370 |
+
|
| 371 |
+
{plan.length > 0 && (
|
| 372 |
+
<AgentBadges plan={plan} statuses={agentStatuses} />
|
| 373 |
+
)}
|
| 374 |
+
|
| 375 |
+
{answerText && <Answer text={answerText} />}
|
| 376 |
+
|
| 377 |
+
{isComplete && !answerText && citations.length === 0 && (
|
| 378 |
+
<NoResultsState query={currentQuery} />
|
| 379 |
+
)}
|
| 380 |
+
|
| 381 |
+
{guardrail?.escalate && <HallucinationWarning />}
|
| 382 |
+
|
| 383 |
+
{isError && !answerText && (
|
| 384 |
+
<TimeoutError
|
| 385 |
+
message={error ?? undefined}
|
| 386 |
+
onRetry={() => runQuery(currentQuery)}
|
| 387 |
+
/>
|
| 388 |
+
)}
|
| 389 |
+
|
| 390 |
+
{isError && answerText && error?.includes('incomplete') && (
|
| 391 |
+
<div className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
| 392 |
+
<span aria-hidden>⚠</span>
|
| 393 |
+
Answer may be incomplete — connection dropped mid-stream.
|
| 394 |
+
<button onClick={() => runQuery(currentQuery)} className="ml-auto shrink-0 underline">
|
| 395 |
+
Retry
|
| 396 |
+
</button>
|
| 397 |
+
</div>
|
| 398 |
+
)}
|
| 399 |
+
|
| 400 |
+
{citations.length > 0 && <Citations chunks={citations} />}
|
| 401 |
+
|
| 402 |
+
{isComplete && citations.length > 3 && (
|
| 403 |
+
<RelatedDocs chunks={citations.slice(3)} />
|
| 404 |
+
)}
|
| 405 |
+
|
| 406 |
+
{isComplete && (
|
| 407 |
+
<div className="flex items-center justify-between gap-4">
|
| 408 |
+
<QueryFeedback queryId={queryId} />
|
| 409 |
+
<button
|
| 410 |
+
onClick={() => setShareOpen(true)}
|
| 411 |
+
className="shrink-0 text-xs text-stone-400 underline hover:text-stone-600"
|
| 412 |
+
>
|
| 413 |
+
Share
|
| 414 |
+
</button>
|
| 415 |
+
</div>
|
| 416 |
+
)}
|
| 417 |
</div>
|
| 418 |
)}
|
| 419 |
|
| 420 |
+
<div ref={bottomRef} />
|
|
|
|
| 421 |
|
| 422 |
+
{/* Follow-up always visible at bottom once there's any content */}
|
| 423 |
+
{(isComplete || exchanges.length > 0) && (
|
| 424 |
+
<FollowUp onSubmit={runQuery} disabled={isActive} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
)}
|
|
|
|
|
|
|
| 426 |
</div>
|
| 427 |
|
| 428 |
{/* ── Right: graph panel ───────────────────────────────────────── */}
|
|
|
|
| 429 |
<div className={cn(
|
| 430 |
'flex flex-col overflow-hidden rounded-xl border border-surface-subtle',
|
|
|
|
| 431 |
activeTab === 'answer' ? 'hidden lg:flex' : 'flex',
|
| 432 |
graphCollapsed && 'lg:hidden',
|
|
|
|
| 433 |
graphMaximized
|
| 434 |
? 'fixed inset-0 z-50 rounded-none border-0 bg-white dark:bg-stone-950'
|
| 435 |
: 'h-[440px]',
|
| 436 |
)}>
|
|
|
|
|
|
|
| 437 |
<div className="flex shrink-0 items-center gap-2 border-b border-surface-subtle px-3 py-2">
|
| 438 |
<span className="flex-1 text-xs font-semibold uppercase tracking-wide text-stone-400">
|
| 439 |
Knowledge Graph
|
|
|
|
| 443 |
{graphNodes.length} nodes · {graphEdges.length} edges
|
| 444 |
</span>
|
| 445 |
)}
|
|
|
|
| 446 |
<button
|
| 447 |
onClick={() => setGraphMaximized((m) => !m)}
|
| 448 |
title={graphMaximized ? 'Exit fullscreen' : 'Fullscreen'}
|
|
|
|
| 451 |
>
|
| 452 |
{graphMaximized ? '⤡' : '⤢'}
|
| 453 |
</button>
|
|
|
|
| 454 |
<button
|
| 455 |
onClick={toggleGraphCollapsed}
|
| 456 |
title="Collapse graph"
|
|
|
|
| 461 |
</button>
|
| 462 |
</div>
|
| 463 |
|
|
|
|
| 464 |
<KnowledgeGraph
|
| 465 |
nodes={graphNodes}
|
| 466 |
edges={graphEdges}
|
|
|
|
| 470 |
className="flex-1"
|
| 471 |
/>
|
| 472 |
|
|
|
|
| 473 |
<div className="flex shrink-0 items-center gap-2 border-t border-surface-subtle px-3 py-2">
|
| 474 |
{gState === 'done' && (
|
| 475 |
<button
|
|
|
|
| 483 |
{gState === 'retrying' && <NetworkRetry attempt={retryCount + 1} />}
|
| 484 |
</div>
|
| 485 |
|
|
|
|
| 486 |
{gState === 'error' && (
|
| 487 |
<div className="flex shrink-0 items-center justify-between border-t border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-700 dark:bg-stone-800/40">
|
| 488 |
<span className="text-stone-500 dark:text-stone-400">
|
|
|
|
| 501 |
</>
|
| 502 |
)}
|
| 503 |
|
|
|
|
| 504 |
<GraphNodeTooltip node={hoveredNode} x={hoverPos.x} y={hoverPos.y} />
|
| 505 |
|
|
|
|
| 506 |
<ShareResults
|
| 507 |
query={currentQuery}
|
| 508 |
open={shareOpen}
|
| 509 |
onClose={() => setShareOpen(false)}
|
| 510 |
/>
|
| 511 |
|
|
|
|
| 512 |
<GraphNodeDetailPanel
|
| 513 |
node={selectedNode}
|
| 514 |
teamId={user?.team_id ?? 'default'}
|
frontend/src/components/workspace/QueryHistory.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState } from 'react'
|
| 2 |
import { useQuery } from '@tanstack/react-query'
|
| 3 |
import { apiFetch } from '@/lib/http'
|
| 4 |
import { LoadingSkeleton } from '@/components/common/LoadingSkeleton'
|
|
@@ -25,11 +25,13 @@ async function fetchHistory(page: number): Promise<HistoryResponse> {
|
|
| 25 |
|
| 26 |
interface Props {
|
| 27 |
onReplay?: (query: string) => void
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
-
export function QueryHistory({ onReplay }: Props) {
|
| 31 |
const [page, setPage] = useState(1)
|
| 32 |
-
const [expanded, setExpanded] = useState<string | null>(null)
|
|
|
|
| 33 |
|
| 34 |
const { data, isLoading } = useQuery({
|
| 35 |
queryKey: ['workspace-history', page],
|
|
@@ -37,6 +39,13 @@ export function QueryHistory({ onReplay }: Props) {
|
|
| 37 |
staleTime: 60_000,
|
| 38 |
})
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const totalPages = data ? Math.ceil(data.total / 20) : 1
|
| 41 |
|
| 42 |
return (
|
|
@@ -61,7 +70,11 @@ export function QueryHistory({ onReplay }: Props) {
|
|
| 61 |
{(data?.items ?? []).map((item) => (
|
| 62 |
<div
|
| 63 |
key={item.id}
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
>
|
| 66 |
<button
|
| 67 |
className="flex w-full items-start gap-3 p-4 text-left"
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from 'react'
|
| 2 |
import { useQuery } from '@tanstack/react-query'
|
| 3 |
import { apiFetch } from '@/lib/http'
|
| 4 |
import { LoadingSkeleton } from '@/components/common/LoadingSkeleton'
|
|
|
|
| 25 |
|
| 26 |
interface Props {
|
| 27 |
onReplay?: (query: string) => void
|
| 28 |
+
focusId?: string
|
| 29 |
}
|
| 30 |
|
| 31 |
+
export function QueryHistory({ onReplay, focusId }: Props) {
|
| 32 |
const [page, setPage] = useState(1)
|
| 33 |
+
const [expanded, setExpanded] = useState<string | null>(focusId ?? null)
|
| 34 |
+
const focusRef = useRef<HTMLDivElement>(null)
|
| 35 |
|
| 36 |
const { data, isLoading } = useQuery({
|
| 37 |
queryKey: ['workspace-history', page],
|
|
|
|
| 39 |
staleTime: 60_000,
|
| 40 |
})
|
| 41 |
|
| 42 |
+
// Scroll the focused item into view once data loads
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
if (focusId && focusRef.current) {
|
| 45 |
+
focusRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
| 46 |
+
}
|
| 47 |
+
}, [focusId, data])
|
| 48 |
+
|
| 49 |
const totalPages = data ? Math.ceil(data.total / 20) : 1
|
| 50 |
|
| 51 |
return (
|
|
|
|
| 70 |
{(data?.items ?? []).map((item) => (
|
| 71 |
<div
|
| 72 |
key={item.id}
|
| 73 |
+
ref={item.id === focusId ? focusRef : undefined}
|
| 74 |
+
className={cn(
|
| 75 |
+
'rounded-xl border border-surface-subtle transition-colors hover:border-stone-300 dark:hover:border-stone-600',
|
| 76 |
+
item.id === focusId && 'border-brand/50 dark:border-brand/40',
|
| 77 |
+
)}
|
| 78 |
>
|
| 79 |
<button
|
| 80 |
className="flex w-full items-start gap-3 p-4 text-left"
|
frontend/src/config/env.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
const val = (import.meta as any).env[key]
|
| 4 |
-
if (!val) throw new Error(`Missing env var: ${key}`)
|
| 5 |
-
return val
|
| 6 |
-
}
|
| 7 |
|
| 8 |
export const env = {
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 2 |
+
const _env = (import.meta as any).env
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export const env = {
|
| 5 |
+
// Empty string → relative URLs → works when frontend is served from FastAPI via ngrok
|
| 6 |
+
apiBaseUrl: _env.VITE_API_BASE_URL || '',
|
| 7 |
+
|
| 8 |
+
// Derived at runtime from page host so WebSocket works on any ngrok URL automatically
|
| 9 |
+
get wsBaseUrl(): string {
|
| 10 |
+
if (_env.VITE_WS_BASE_URL) return _env.VITE_WS_BASE_URL
|
| 11 |
+
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
| 12 |
+
return `${proto}//${window.location.host}`
|
| 13 |
+
},
|
| 14 |
+
}
|
frontend/src/config/routes.ts
CHANGED
|
@@ -65,7 +65,9 @@ export const queryRoute = createRoute({
|
|
| 65 |
beforeLoad: requireAuth,
|
| 66 |
component: QueryPage,
|
| 67 |
validateSearch: (s: Record<string, unknown>) => ({
|
| 68 |
-
q:
|
|
|
|
|
|
|
| 69 |
}),
|
| 70 |
})
|
| 71 |
|
|
@@ -88,6 +90,9 @@ export const workspaceRoute = createRoute({
|
|
| 88 |
path: '/workspace',
|
| 89 |
beforeLoad: requireAuth,
|
| 90 |
component: Workspace,
|
|
|
|
|
|
|
|
|
|
| 91 |
})
|
| 92 |
|
| 93 |
export const settingsRoute = createRoute({
|
|
|
|
| 65 |
beforeLoad: requireAuth,
|
| 66 |
component: QueryPage,
|
| 67 |
validateSearch: (s: Record<string, unknown>) => ({
|
| 68 |
+
q: typeof s.q === 'string' ? s.q : undefined,
|
| 69 |
+
qid: typeof s.qid === 'string' ? s.qid : undefined,
|
| 70 |
+
fresh: s.fresh === true || s.fresh === 'true',
|
| 71 |
}),
|
| 72 |
})
|
| 73 |
|
|
|
|
| 90 |
path: '/workspace',
|
| 91 |
beforeLoad: requireAuth,
|
| 92 |
component: Workspace,
|
| 93 |
+
validateSearch: (s: Record<string, unknown>) => ({
|
| 94 |
+
id: typeof s.id === 'string' ? s.id : undefined,
|
| 95 |
+
}),
|
| 96 |
})
|
| 97 |
|
| 98 |
export const settingsRoute = createRoute({
|
frontend/src/hooks/useGraphStream.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|
| 2 |
import { env } from '@/config/env'
|
| 3 |
import type { GraphNode, GraphEdge, GraphDoneEvent } from '@/types/api'
|
| 4 |
|
| 5 |
-
const MAX_RETRIES =
|
| 6 |
const BASE_DELAY_MS = 1000
|
| 7 |
|
| 8 |
type Callbacks = {
|
|
@@ -49,6 +49,11 @@ export function useGraphStream() {
|
|
| 49 |
activeRef.current = false
|
| 50 |
setGState('done')
|
| 51 |
callbacksRef.current?.onDone(msg as GraphDoneEvent)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
} catch {
|
| 54 |
// Non-JSON frame — ignore
|
|
|
|
| 2 |
import { env } from '@/config/env'
|
| 3 |
import type { GraphNode, GraphEdge, GraphDoneEvent } from '@/types/api'
|
| 4 |
|
| 5 |
+
const MAX_RETRIES = 1
|
| 6 |
const BASE_DELAY_MS = 1000
|
| 7 |
|
| 8 |
type Callbacks = {
|
|
|
|
| 49 |
activeRef.current = false
|
| 50 |
setGState('done')
|
| 51 |
callbacksRef.current?.onDone(msg as GraphDoneEvent)
|
| 52 |
+
} else if (msg.event === 'error') {
|
| 53 |
+
// Server signalled a terminal error — stop retrying
|
| 54 |
+
activeRef.current = false
|
| 55 |
+
setGState('error')
|
| 56 |
+
callbacksRef.current?.onError(msg.message ?? 'Graph unavailable')
|
| 57 |
}
|
| 58 |
} catch {
|
| 59 |
// Non-JSON frame — ignore
|
frontend/src/hooks/useNotifications.ts
CHANGED
|
@@ -21,9 +21,11 @@ export function useNotifications() {
|
|
| 21 |
|
| 22 |
ws.onmessage = (evt) => {
|
| 23 |
try {
|
| 24 |
-
const msg = JSON.parse(evt.data as string)
|
|
|
|
|
|
|
| 25 |
const notification: AppNotification = {
|
| 26 |
-
...msg,
|
| 27 |
id: crypto.randomUUID(),
|
| 28 |
read: false,
|
| 29 |
}
|
|
|
|
| 21 |
|
| 22 |
ws.onmessage = (evt) => {
|
| 23 |
try {
|
| 24 |
+
const msg = JSON.parse(evt.data as string)
|
| 25 |
+
// Skip keepalive pings and any frame without a real message
|
| 26 |
+
if (!msg.message || msg.type === 'ping') return
|
| 27 |
const notification: AppNotification = {
|
| 28 |
+
...(msg as Omit<AppNotification, 'id' | 'read'>),
|
| 29 |
id: crypto.randomUUID(),
|
| 30 |
read: false,
|
| 31 |
}
|
frontend/src/pages/AcceptInvitePage.tsx
CHANGED
|
@@ -93,7 +93,7 @@ export default function AcceptInvitePage() {
|
|
| 93 |
{...register('password')}
|
| 94 |
type="password"
|
| 95 |
autoComplete="new-password"
|
| 96 |
-
className="rounded border border-surface-subtle px-3 py-2 focus:outline-brand"
|
| 97 |
/>
|
| 98 |
{errors.password && <span className="text-xs text-red-600">{errors.password.message}</span>}
|
| 99 |
</label>
|
|
@@ -104,7 +104,7 @@ export default function AcceptInvitePage() {
|
|
| 104 |
{...register('confirm')}
|
| 105 |
type="password"
|
| 106 |
autoComplete="new-password"
|
| 107 |
-
className="rounded border border-surface-subtle px-3 py-2 focus:outline-brand"
|
| 108 |
/>
|
| 109 |
{errors.confirm && <span className="text-xs text-red-600">{errors.confirm.message}</span>}
|
| 110 |
</label>
|
|
|
|
| 93 |
{...register('password')}
|
| 94 |
type="password"
|
| 95 |
autoComplete="new-password"
|
| 96 |
+
className="rounded border border-surface-subtle bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:outline-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
|
| 97 |
/>
|
| 98 |
{errors.password && <span className="text-xs text-red-600">{errors.password.message}</span>}
|
| 99 |
</label>
|
|
|
|
| 104 |
{...register('confirm')}
|
| 105 |
type="password"
|
| 106 |
autoComplete="new-password"
|
| 107 |
+
className="rounded border border-surface-subtle bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:outline-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
|
| 108 |
/>
|
| 109 |
{errors.confirm && <span className="text-xs text-red-600">{errors.confirm.message}</span>}
|
| 110 |
</label>
|
frontend/src/pages/Home.tsx
CHANGED
|
@@ -6,7 +6,7 @@ export default function Home() {
|
|
| 6 |
const navigate = useNavigate()
|
| 7 |
|
| 8 |
const handleQuery = (query: string) => {
|
| 9 |
-
navigate({ to: '/query', search: { q: query } })
|
| 10 |
}
|
| 11 |
|
| 12 |
return (
|
|
|
|
| 6 |
const navigate = useNavigate()
|
| 7 |
|
| 8 |
const handleQuery = (query: string) => {
|
| 9 |
+
navigate({ to: '/query', search: { q: query, qid: undefined, fresh: false } })
|
| 10 |
}
|
| 11 |
|
| 12 |
return (
|
frontend/src/pages/LoginPage.tsx
CHANGED
|
@@ -44,7 +44,7 @@ export default function LoginPage() {
|
|
| 44 |
{...register('email')}
|
| 45 |
type="email"
|
| 46 |
autoComplete="email"
|
| 47 |
-
className="rounded border border-surface-subtle px-3 py-2 focus:outline-brand"
|
| 48 |
/>
|
| 49 |
{errors.email && <span className="text-red-600 text-xs">{errors.email.message}</span>}
|
| 50 |
</label>
|
|
@@ -55,7 +55,7 @@ export default function LoginPage() {
|
|
| 55 |
{...register('password')}
|
| 56 |
type="password"
|
| 57 |
autoComplete="current-password"
|
| 58 |
-
className="rounded border border-surface-subtle px-3 py-2 focus:outline-brand"
|
| 59 |
/>
|
| 60 |
{errors.password && <span className="text-red-600 text-xs">{errors.password.message}</span>}
|
| 61 |
</label>
|
|
|
|
| 44 |
{...register('email')}
|
| 45 |
type="email"
|
| 46 |
autoComplete="email"
|
| 47 |
+
className="rounded border border-surface-subtle bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:outline-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
|
| 48 |
/>
|
| 49 |
{errors.email && <span className="text-red-600 text-xs">{errors.email.message}</span>}
|
| 50 |
</label>
|
|
|
|
| 55 |
{...register('password')}
|
| 56 |
type="password"
|
| 57 |
autoComplete="current-password"
|
| 58 |
+
className="rounded border border-surface-subtle bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:outline-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
|
| 59 |
/>
|
| 60 |
{errors.password && <span className="text-red-600 text-xs">{errors.password.message}</span>}
|
| 61 |
</label>
|
frontend/src/pages/OAuthCallbackPage.tsx
CHANGED
|
@@ -15,7 +15,11 @@ export default function OAuthCallbackPage() {
|
|
| 15 |
const hasError = params.get('error') !== null
|
| 16 |
|
| 17 |
if (hasError) {
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
navigate({ to: '/login' })
|
| 20 |
return
|
| 21 |
}
|
|
|
|
| 15 |
const hasError = params.get('error') !== null
|
| 16 |
|
| 17 |
if (hasError) {
|
| 18 |
+
const error = params.get('error')
|
| 19 |
+
const message = error === 'not_invited'
|
| 20 |
+
? 'Your Google account is not linked to any workspace. Ask an admin to invite you first.'
|
| 21 |
+
: 'Google sign-in failed. Please try again.'
|
| 22 |
+
addToast({ type: 'error', message })
|
| 23 |
navigate({ to: '/login' })
|
| 24 |
return
|
| 25 |
}
|
frontend/src/pages/WorkspacePage.tsx
CHANGED
|
@@ -1,17 +1,18 @@
|
|
| 1 |
-
import { useNavigate } from '@tanstack/react-router'
|
| 2 |
import { QueryHistory } from '@/components/workspace/QueryHistory'
|
| 3 |
|
| 4 |
export default function WorkspacePage() {
|
| 5 |
const navigate = useNavigate()
|
|
|
|
| 6 |
|
| 7 |
const handleReplay = (query: string) => {
|
| 8 |
-
navigate({ to: '/query', search: { q: query } })
|
| 9 |
}
|
| 10 |
|
| 11 |
return (
|
| 12 |
<div className="mx-auto max-w-3xl px-4 py-8">
|
| 13 |
<h1 className="mb-6 text-2xl font-semibold">Workspace</h1>
|
| 14 |
-
<QueryHistory onReplay={handleReplay} />
|
| 15 |
</div>
|
| 16 |
)
|
| 17 |
}
|
|
|
|
| 1 |
+
import { useNavigate, useSearch } from '@tanstack/react-router'
|
| 2 |
import { QueryHistory } from '@/components/workspace/QueryHistory'
|
| 3 |
|
| 4 |
export default function WorkspacePage() {
|
| 5 |
const navigate = useNavigate()
|
| 6 |
+
const { id: focusId } = useSearch({ from: '/workspace' })
|
| 7 |
|
| 8 |
const handleReplay = (query: string) => {
|
| 9 |
+
navigate({ to: '/query', search: { q: query, qid: undefined, fresh: true } })
|
| 10 |
}
|
| 11 |
|
| 12 |
return (
|
| 13 |
<div className="mx-auto max-w-3xl px-4 py-8">
|
| 14 |
<h1 className="mb-6 text-2xl font-semibold">Workspace</h1>
|
| 15 |
+
<QueryHistory onReplay={handleReplay} focusId={focusId} />
|
| 16 |
</div>
|
| 17 |
)
|
| 18 |
}
|
frontend/src/types/api.ts
CHANGED
|
@@ -5,7 +5,7 @@ export interface QueryInput {
|
|
| 5 |
}
|
| 6 |
|
| 7 |
export interface AgentTask {
|
| 8 |
-
agent: 'doc_search' | 'ticket_lookup' | 'live_docs' | 'summariser'
|
| 9 |
input: string
|
| 10 |
depends_on: string[]
|
| 11 |
}
|
|
|
|
| 5 |
}
|
| 6 |
|
| 7 |
export interface AgentTask {
|
| 8 |
+
agent: 'doc_search' | 'ticket_lookup' | 'confluence_search' | 'slack_search' | 'live_docs' | 'summariser' | 'sql_query'
|
| 9 |
input: string
|
| 10 |
depends_on: string[]
|
| 11 |
}
|
graph_store/stream.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import logging
|
|
|
|
| 5 |
|
| 6 |
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
| 7 |
from neo4j import AsyncGraphDatabase
|
|
@@ -11,6 +12,31 @@ from graph_store.config import settings
|
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
router = APIRouter(tags=["graph"])
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
# ---------------------------------------------------------------------------
|
| 15 |
# Cypher queries
|
| 16 |
# ---------------------------------------------------------------------------
|
|
@@ -126,15 +152,25 @@ async def graph_stream(websocket: WebSocket):
|
|
| 126 |
else:
|
| 127 |
logger.info("graph_stream: no channel restriction — serving full graph")
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
driver = AsyncGraphDatabase.driver(
|
| 130 |
settings.neo4j_uri,
|
| 131 |
auth=(settings.neo4j_username, settings.neo4j_password),
|
| 132 |
max_connection_lifetime=300,
|
| 133 |
-
connection_acquisition_timeout=
|
| 134 |
keep_alive=True,
|
| 135 |
)
|
| 136 |
try:
|
| 137 |
-
# Fetch all records at once (same pattern as traverse — avoids async-for issues)
|
| 138 |
async with driver.session(database=settings.neo4j_database) as session:
|
| 139 |
if use_filtered:
|
| 140 |
result = await session.run(
|
|
@@ -190,10 +226,10 @@ async def graph_stream(websocket: WebSocket):
|
|
| 190 |
|
| 191 |
except WebSocketDisconnect:
|
| 192 |
logger.info("graph_stream: client disconnected")
|
| 193 |
-
except Exception:
|
| 194 |
-
|
| 195 |
try:
|
| 196 |
-
await websocket.send_json({"event": "error", "message": "
|
| 197 |
except Exception:
|
| 198 |
pass
|
| 199 |
finally:
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import logging
|
| 5 |
+
import time
|
| 6 |
|
| 7 |
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
| 8 |
from neo4j import AsyncGraphDatabase
|
|
|
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
router = APIRouter(tags=["graph"])
|
| 14 |
|
| 15 |
+
# After a DNS/connection failure, skip Neo4j for this many seconds before retrying
|
| 16 |
+
_NEO4J_COOLDOWN_S = 120
|
| 17 |
+
_neo4j_failed_at: float | None = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _neo4j_is_available() -> bool:
|
| 21 |
+
global _neo4j_failed_at
|
| 22 |
+
if _neo4j_failed_at is None:
|
| 23 |
+
return True
|
| 24 |
+
if time.monotonic() - _neo4j_failed_at > _NEO4J_COOLDOWN_S:
|
| 25 |
+
_neo4j_failed_at = None
|
| 26 |
+
return True
|
| 27 |
+
return False
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _mark_neo4j_failed() -> None:
|
| 31 |
+
global _neo4j_failed_at
|
| 32 |
+
if _neo4j_failed_at is None:
|
| 33 |
+
logger.warning(
|
| 34 |
+
"graph_stream: Neo4j unreachable (%s) — graph panel disabled for %ds",
|
| 35 |
+
settings.neo4j_uri,
|
| 36 |
+
_NEO4J_COOLDOWN_S,
|
| 37 |
+
)
|
| 38 |
+
_neo4j_failed_at = time.monotonic()
|
| 39 |
+
|
| 40 |
# ---------------------------------------------------------------------------
|
| 41 |
# Cypher queries
|
| 42 |
# ---------------------------------------------------------------------------
|
|
|
|
| 152 |
else:
|
| 153 |
logger.info("graph_stream: no channel restriction — serving full graph")
|
| 154 |
|
| 155 |
+
if not _neo4j_is_available():
|
| 156 |
+
try:
|
| 157 |
+
await websocket.send_json({"event": "error", "message": "Knowledge graph unavailable"})
|
| 158 |
+
except Exception:
|
| 159 |
+
pass
|
| 160 |
+
try:
|
| 161 |
+
await websocket.close()
|
| 162 |
+
except Exception:
|
| 163 |
+
pass
|
| 164 |
+
return
|
| 165 |
+
|
| 166 |
driver = AsyncGraphDatabase.driver(
|
| 167 |
settings.neo4j_uri,
|
| 168 |
auth=(settings.neo4j_username, settings.neo4j_password),
|
| 169 |
max_connection_lifetime=300,
|
| 170 |
+
connection_acquisition_timeout=10,
|
| 171 |
keep_alive=True,
|
| 172 |
)
|
| 173 |
try:
|
|
|
|
| 174 |
async with driver.session(database=settings.neo4j_database) as session:
|
| 175 |
if use_filtered:
|
| 176 |
result = await session.run(
|
|
|
|
| 226 |
|
| 227 |
except WebSocketDisconnect:
|
| 228 |
logger.info("graph_stream: client disconnected")
|
| 229 |
+
except Exception as exc:
|
| 230 |
+
_mark_neo4j_failed()
|
| 231 |
try:
|
| 232 |
+
await websocket.send_json({"event": "error", "message": "Knowledge graph unavailable"})
|
| 233 |
except Exception:
|
| 234 |
pass
|
| 235 |
finally:
|
ingestion/jobs/ingest_job.py
CHANGED
|
@@ -16,7 +16,11 @@ _SOURCE_REGISTRY: dict[str, Any] = {}
|
|
| 16 |
|
| 17 |
|
| 18 |
async def _run_graph_pipeline(embedded, doc) -> None:
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
if not graph_settings.neo4j_uri:
|
| 22 |
logger.warning("ingest_job: NEO4J_URI not set — skipping graph pipeline")
|
|
@@ -126,10 +130,10 @@ async def _run_ingest_async(job_id: str, payload: IngestSourcePayload) -> dict[s
|
|
| 126 |
for chunk in chunks:
|
| 127 |
chunk.text = mask_pii(chunk.text)
|
| 128 |
|
|
|
|
| 129 |
embedded = embed_chunks(chunks)
|
| 130 |
qdrant_upsert(embedded)
|
| 131 |
sb_upsert_chunks(chunks, client=sb)
|
| 132 |
-
upsert_document(doc, client=sb)
|
| 133 |
total_chunks += len(chunks)
|
| 134 |
logger.info("ingest_job: ingested %d chunks for doc_id=%s", len(chunks), doc.doc_id)
|
| 135 |
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
async def _run_graph_pipeline(embedded, doc) -> None:
|
| 19 |
+
try:
|
| 20 |
+
from graph_store.config import settings as graph_settings
|
| 21 |
+
except Exception:
|
| 22 |
+
logger.warning("ingest_job: graph_store not available — skipping graph pipeline")
|
| 23 |
+
return
|
| 24 |
|
| 25 |
if not graph_settings.neo4j_uri:
|
| 26 |
logger.warning("ingest_job: NEO4J_URI not set — skipping graph pipeline")
|
|
|
|
| 130 |
for chunk in chunks:
|
| 131 |
chunk.text = mask_pii(chunk.text)
|
| 132 |
|
| 133 |
+
upsert_document(doc, client=sb)
|
| 134 |
embedded = embed_chunks(chunks)
|
| 135 |
qdrant_upsert(embedded)
|
| 136 |
sb_upsert_chunks(chunks, client=sb)
|
|
|
|
| 137 |
total_chunks += len(chunks)
|
| 138 |
logger.info("ingest_job: ingested %d chunks for doc_id=%s", len(chunks), doc.doc_id)
|
| 139 |
|
ingestion/sources/github.py
CHANGED
|
@@ -37,7 +37,7 @@ class GithubSource(BaseSource):
|
|
| 37 |
self._team_id = team_id
|
| 38 |
self._repo_url = repo_url
|
| 39 |
self._supabase = supabase_client
|
| 40 |
-
self._path_filter = path_filter
|
| 41 |
self._branch = branch or settings.github_branch
|
| 42 |
self._token = token or settings.github_token
|
| 43 |
self._owner, self._repo = _parse_owner_repo(repo_url)
|
|
@@ -125,12 +125,15 @@ class GithubSource(BaseSource):
|
|
| 125 |
logger.exception("github: failed to fetch file tree")
|
| 126 |
return []
|
| 127 |
|
|
|
|
|
|
|
| 128 |
md_paths = [
|
| 129 |
item["path"]
|
| 130 |
for item in tree
|
| 131 |
if item["type"] == "blob"
|
| 132 |
-
and item["path"].endswith(
|
| 133 |
-
and item["path"].startswith(self._path_filter)
|
|
|
|
| 134 |
]
|
| 135 |
|
| 136 |
docs: list[RawDocument] = []
|
|
@@ -151,7 +154,7 @@ class GithubSource(BaseSource):
|
|
| 151 |
source_url = data.get("html_url", f"{self._repo_url}/blob/{self._branch}/{path}")
|
| 152 |
return RawDocument(
|
| 153 |
doc_id=doc_id,
|
| 154 |
-
title=path.split("/")[-1].
|
| 155 |
content=content,
|
| 156 |
source_url=source_url,
|
| 157 |
source_type="github",
|
|
|
|
| 37 |
self._team_id = team_id
|
| 38 |
self._repo_url = repo_url
|
| 39 |
self._supabase = supabase_client
|
| 40 |
+
self._path_filter = path_filter # empty string = no filter; caller sets explicitly
|
| 41 |
self._branch = branch or settings.github_branch
|
| 42 |
self._token = token or settings.github_token
|
| 43 |
self._owner, self._repo = _parse_owner_repo(repo_url)
|
|
|
|
| 125 |
logger.exception("github: failed to fetch file tree")
|
| 126 |
return []
|
| 127 |
|
| 128 |
+
INDEXABLE_EXTENSIONS = {".md", ".txt", ".rst", ".py", ".ts", ".tsx", ".js", ".yaml", ".yml"}
|
| 129 |
+
|
| 130 |
md_paths = [
|
| 131 |
item["path"]
|
| 132 |
for item in tree
|
| 133 |
if item["type"] == "blob"
|
| 134 |
+
and any(item["path"].endswith(ext) for ext in INDEXABLE_EXTENSIONS)
|
| 135 |
+
and (not self._path_filter or item["path"].startswith(self._path_filter))
|
| 136 |
+
and item.get("size", 0) <= 200_000
|
| 137 |
]
|
| 138 |
|
| 139 |
docs: list[RawDocument] = []
|
|
|
|
| 154 |
source_url = data.get("html_url", f"{self._repo_url}/blob/{self._branch}/{path}")
|
| 155 |
return RawDocument(
|
| 156 |
doc_id=doc_id,
|
| 157 |
+
title=path.split("/")[-1].rsplit(".", 1)[0],
|
| 158 |
content=content,
|
| 159 |
source_url=source_url,
|
| 160 |
source_type="github",
|
ingestion/storage/bm25_store.py
CHANGED
|
@@ -24,15 +24,17 @@ class BM25Store:
|
|
| 24 |
return []
|
| 25 |
return re.findall(r"\b\w+\b", text.lower())
|
| 26 |
|
| 27 |
-
def rebuild_index(self, chunk_ids: List[str], texts: List[str]) -> None:
|
| 28 |
if not chunk_ids or not texts:
|
| 29 |
logger.warning("BM25: empty corpus provided")
|
| 30 |
return
|
| 31 |
if len(chunk_ids) != len(texts):
|
| 32 |
raise ValueError(f"chunk_ids ({len(chunk_ids)}) != texts ({len(texts)})")
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
cleaned_ids, cleaned_texts, tokenized_corpus = [], [], []
|
| 35 |
-
for chunk_id, text in zip(chunk_ids, texts):
|
| 36 |
if not chunk_id or not text or not text.strip():
|
| 37 |
continue
|
| 38 |
tokens = self.tokenize(text)
|
|
@@ -41,12 +43,18 @@ class BM25Store:
|
|
| 41 |
cleaned_ids.append(chunk_id)
|
| 42 |
cleaned_texts.append(text)
|
| 43 |
tokenized_corpus.append(tokens)
|
|
|
|
|
|
|
| 44 |
|
| 45 |
if not tokenized_corpus:
|
| 46 |
raise ValueError("No valid documents to index")
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
with self.index_path.open("wb") as f:
|
| 49 |
-
pickle.dump(
|
| 50 |
|
| 51 |
logger.info("BM25 rebuilt with %d documents -> %s", len(cleaned_ids), self.index_path)
|
| 52 |
|
|
@@ -78,4 +86,5 @@ def rebuild_from_supabase() -> None:
|
|
| 78 |
store.rebuild_index(
|
| 79 |
chunk_ids=[r["chunk_id"] for r in rows],
|
| 80 |
texts=[r["text"] for r in rows],
|
|
|
|
| 81 |
)
|
|
|
|
| 24 |
return []
|
| 25 |
return re.findall(r"\b\w+\b", text.lower())
|
| 26 |
|
| 27 |
+
def rebuild_index(self, chunk_ids: List[str], texts: List[str], metadata: List[dict] | None = None) -> None:
|
| 28 |
if not chunk_ids or not texts:
|
| 29 |
logger.warning("BM25: empty corpus provided")
|
| 30 |
return
|
| 31 |
if len(chunk_ids) != len(texts):
|
| 32 |
raise ValueError(f"chunk_ids ({len(chunk_ids)}) != texts ({len(texts)})")
|
| 33 |
+
if metadata is not None and len(metadata) != len(chunk_ids):
|
| 34 |
+
raise ValueError(f"metadata ({len(metadata)}) != chunk_ids ({len(chunk_ids)})")
|
| 35 |
|
| 36 |
+
cleaned_ids, cleaned_texts, tokenized_corpus, cleaned_metadata = [], [], [], []
|
| 37 |
+
for i, (chunk_id, text) in enumerate(zip(chunk_ids, texts)):
|
| 38 |
if not chunk_id or not text or not text.strip():
|
| 39 |
continue
|
| 40 |
tokens = self.tokenize(text)
|
|
|
|
| 43 |
cleaned_ids.append(chunk_id)
|
| 44 |
cleaned_texts.append(text)
|
| 45 |
tokenized_corpus.append(tokens)
|
| 46 |
+
if metadata is not None:
|
| 47 |
+
cleaned_metadata.append(metadata[i])
|
| 48 |
|
| 49 |
if not tokenized_corpus:
|
| 50 |
raise ValueError("No valid documents to index")
|
| 51 |
|
| 52 |
+
payload = {"index": BM25Okapi(tokenized_corpus), "doc_ids": cleaned_ids, "corpus": cleaned_texts}
|
| 53 |
+
if metadata is not None:
|
| 54 |
+
payload["metadata"] = cleaned_metadata
|
| 55 |
+
|
| 56 |
with self.index_path.open("wb") as f:
|
| 57 |
+
pickle.dump(payload, f)
|
| 58 |
|
| 59 |
logger.info("BM25 rebuilt with %d documents -> %s", len(cleaned_ids), self.index_path)
|
| 60 |
|
|
|
|
| 86 |
store.rebuild_index(
|
| 87 |
chunk_ids=[r["chunk_id"] for r in rows],
|
| 88 |
texts=[r["text"] for r in rows],
|
| 89 |
+
metadata=[{"source": r.get("source", ""), "source_type": r.get("source_type", "internal")} for r in rows],
|
| 90 |
)
|
ingestion/storage/supabase_store.py
CHANGED
|
@@ -134,7 +134,7 @@ def update_cag_snapshot(team_id: str, snapshot: str, client: Optional[Client] =
|
|
| 134 |
def get_all_chunks(client: Optional[Client] = None) -> list[dict[str, Any]]:
|
| 135 |
sb = client or get_client()
|
| 136 |
try:
|
| 137 |
-
result = sb.table("chunks").select("chunk_id, text").execute()
|
| 138 |
return result.data or []
|
| 139 |
except Exception:
|
| 140 |
logger.exception("supabase_store: failed to fetch all chunks")
|
|
|
|
| 134 |
def get_all_chunks(client: Optional[Client] = None) -> list[dict[str, Any]]:
|
| 135 |
sb = client or get_client()
|
| 136 |
try:
|
| 137 |
+
result = sb.table("chunks").select("chunk_id, text, source, source_type").execute()
|
| 138 |
return result.data or []
|
| 139 |
except Exception:
|
| 140 |
logger.exception("supabase_store: failed to fetch all chunks")
|
main.py
CHANGED
|
@@ -77,7 +77,7 @@ app.include_router(tools_router)
|
|
| 77 |
|
| 78 |
|
| 79 |
# ---------------------------------------------------------------------------
|
| 80 |
-
# Health endpoint —
|
| 81 |
# ---------------------------------------------------------------------------
|
| 82 |
|
| 83 |
@app.get("/health", tags=["infra"])
|
|
@@ -130,3 +130,22 @@ async def health() -> dict:
|
|
| 130 |
results["status"] = "degraded"
|
| 131 |
|
| 132 |
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
# ---------------------------------------------------------------------------
|
| 80 |
+
# Health endpoint — must be registered BEFORE the SPA catch-all
|
| 81 |
# ---------------------------------------------------------------------------
|
| 82 |
|
| 83 |
@app.get("/health", tags=["infra"])
|
|
|
|
| 130 |
results["status"] = "degraded"
|
| 131 |
|
| 132 |
return results
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
# Serve React build — SPA catch-all MUST be last (catches everything else)
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
import os as _os
|
| 139 |
+
from fastapi.staticfiles import StaticFiles
|
| 140 |
+
from fastapi.responses import FileResponse as _FileResponse
|
| 141 |
+
|
| 142 |
+
_dist = _os.path.join(_os.path.dirname(__file__), "frontend", "dist")
|
| 143 |
+
if _os.path.exists(_dist):
|
| 144 |
+
app.mount("/assets", StaticFiles(directory=_os.path.join(_dist, "assets")), name="assets")
|
| 145 |
+
|
| 146 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 147 |
+
async def serve_spa(full_path: str):
|
| 148 |
+
file = _os.path.join(_dist, full_path)
|
| 149 |
+
if _os.path.isfile(file):
|
| 150 |
+
return _FileResponse(file)
|
| 151 |
+
return _FileResponse(_os.path.join(_dist, "index.html"))
|
migrate_qdrant.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Migrate local Qdrant collection to Qdrant Cloud. Run once before deploying."""
|
| 2 |
+
import asyncio, os, sys
|
| 3 |
+
|
| 4 |
+
COLLECTION = "knowledge_base"
|
| 5 |
+
CLOUD_URL = os.getenv("QDRANT_CLOUD_URL", "")
|
| 6 |
+
CLOUD_KEY = os.getenv("QDRANT_CLOUD_API_KEY", "")
|
| 7 |
+
|
| 8 |
+
if not CLOUD_URL or not CLOUD_KEY:
|
| 9 |
+
print("Set QDRANT_CLOUD_URL and QDRANT_CLOUD_API_KEY env vars first")
|
| 10 |
+
sys.exit(1)
|
| 11 |
+
|
| 12 |
+
async def migrate():
|
| 13 |
+
from qdrant_client import AsyncQdrantClient
|
| 14 |
+
from qdrant_client.models import PointStruct
|
| 15 |
+
|
| 16 |
+
src = AsyncQdrantClient(host="localhost", port=6333)
|
| 17 |
+
dst = AsyncQdrantClient(url=CLOUD_URL, api_key=CLOUD_KEY)
|
| 18 |
+
|
| 19 |
+
info = await src.get_collection(COLLECTION)
|
| 20 |
+
print(f"Source collection: {info.points_count} points")
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
await dst.delete_collection(COLLECTION)
|
| 24 |
+
except Exception:
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
# Create with both dense and sparse vector configs
|
| 28 |
+
await dst.create_collection(
|
| 29 |
+
COLLECTION,
|
| 30 |
+
vectors_config=info.config.params.vectors,
|
| 31 |
+
sparse_vectors_config=info.config.params.sparse_vectors,
|
| 32 |
+
)
|
| 33 |
+
print("Created cloud collection (dense + sparse)")
|
| 34 |
+
|
| 35 |
+
offset = None
|
| 36 |
+
total = 0
|
| 37 |
+
while True:
|
| 38 |
+
records, offset = await src.scroll(
|
| 39 |
+
COLLECTION, offset=offset, limit=50,
|
| 40 |
+
with_payload=True, with_vectors=True
|
| 41 |
+
)
|
| 42 |
+
if not records:
|
| 43 |
+
break
|
| 44 |
+
structs = [
|
| 45 |
+
PointStruct(id=r.id, vector=r.vector, payload=r.payload or {})
|
| 46 |
+
for r in records
|
| 47 |
+
if r.vector is not None
|
| 48 |
+
]
|
| 49 |
+
if structs:
|
| 50 |
+
await dst.upsert(COLLECTION, points=structs)
|
| 51 |
+
total += len(structs)
|
| 52 |
+
print(f"Migrated {total} points...")
|
| 53 |
+
if offset is None:
|
| 54 |
+
break
|
| 55 |
+
|
| 56 |
+
print(f"Done — {total} points migrated to {CLOUD_URL}")
|
| 57 |
+
|
| 58 |
+
asyncio.run(migrate())
|
requirements.txt
CHANGED
|
@@ -17,10 +17,13 @@
|
|
| 17 |
langgraph==0.2.76
|
| 18 |
langchain-core==0.3.86
|
| 19 |
langchain-google-genai==2.0.9
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
# ── Embeddings & reranking ────────────────────────────────────────────────────
|
| 23 |
-
FlagEmbedding==1.
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
# ── PII masking (local — zero egress) ────────────────────────────────────────
|
|
@@ -68,9 +71,10 @@ google-auth==2.37.0
|
|
| 68 |
numpy==1.26.4
|
| 69 |
# torch: CPU build. For GPU replace with the matching CUDA wheel:
|
| 70 |
# https://pytorch.org/get-started/locally/
|
| 71 |
-
# torch 2.6 is compatible with numpy 1.26.x and transformers
|
|
|
|
| 72 |
torch==2.6.0
|
| 73 |
-
transformers==
|
| 74 |
|
| 75 |
|
| 76 |
# ── Document parsing ─────────────────────────────────────────────────────────
|
|
@@ -135,7 +139,7 @@ watchdog==6.0.0
|
|
| 135 |
# Ubuntu: apt install tesseract-ocr
|
| 136 |
# Windows: https://github.com/UB-Mannheim/tesseract/wiki
|
| 137 |
pytesseract==0.3.13
|
| 138 |
-
Pillow==
|
| 139 |
|
| 140 |
|
| 141 |
# ── Optional: live web fetching (configure via .env) ─────────────────────────
|
|
@@ -143,5 +147,8 @@ Pillow==11.1.0
|
|
| 143 |
# firecrawl-py==1.9.0
|
| 144 |
# tavily-python==0.5.0
|
| 145 |
|
|
|
|
|
|
|
|
|
|
| 146 |
# ── NL-to-SQL agent — direct PostgreSQL connection ────────────────────────────
|
| 147 |
asyncpg>=0.29.0
|
|
|
|
| 17 |
langgraph==0.2.76
|
| 18 |
langchain-core==0.3.86
|
| 19 |
langchain-google-genai==2.0.9
|
| 20 |
+
langchain-openai==1.2.1
|
| 21 |
|
| 22 |
|
| 23 |
# ── Embeddings & reranking ────────────────────────────────────────────────────
|
| 24 |
+
FlagEmbedding==1.4.0
|
| 25 |
+
# CrossEncoder reranker — replaces BGE reranker (Python 3.14 compatible)
|
| 26 |
+
sentence-transformers==5.4.1
|
| 27 |
|
| 28 |
|
| 29 |
# ── PII masking (local — zero egress) ────────────────────────────────────────
|
|
|
|
| 71 |
numpy==1.26.4
|
| 72 |
# torch: CPU build. For GPU replace with the matching CUDA wheel:
|
| 73 |
# https://pytorch.org/get-started/locally/
|
| 74 |
+
# torch 2.6 is compatible with numpy 1.26.x and transformers 5.1.x.
|
| 75 |
+
# transformers is constrained to <5.2.0 by gliner 0.2.x.
|
| 76 |
torch==2.6.0
|
| 77 |
+
transformers==5.1.0
|
| 78 |
|
| 79 |
|
| 80 |
# ── Document parsing ─────────────────────────────────────────────────────────
|
|
|
|
| 139 |
# Ubuntu: apt install tesseract-ocr
|
| 140 |
# Windows: https://github.com/UB-Mannheim/tesseract/wiki
|
| 141 |
pytesseract==0.3.13
|
| 142 |
+
Pillow==12.2.0
|
| 143 |
|
| 144 |
|
| 145 |
# ── Optional: live web fetching (configure via .env) ─────────────────────────
|
|
|
|
| 147 |
# firecrawl-py==1.9.0
|
| 148 |
# tavily-python==0.5.0
|
| 149 |
|
| 150 |
+
# ── Auth — password hashing ───────────────────────────────────────────────────
|
| 151 |
+
bcrypt==5.0.0
|
| 152 |
+
|
| 153 |
# ── NL-to-SQL agent — direct PostgreSQL connection ────────────────────────────
|
| 154 |
asyncpg>=0.29.0
|
src/admin/router.py
CHANGED
|
@@ -31,24 +31,26 @@ async def _redis() -> aioredis.Redis:
|
|
| 31 |
def _default_sources() -> list[dict]:
|
| 32 |
"""Build source list from env vars that are set."""
|
| 33 |
sources = []
|
| 34 |
-
|
|
|
|
| 35 |
sources.append({
|
| 36 |
"id": "jira-default",
|
| 37 |
"type": "jira",
|
| 38 |
"name": "Jira",
|
| 39 |
-
"url":
|
| 40 |
-
"enabled": bool(settings.integrations.jira_api_token),
|
| 41 |
"last_sync": None,
|
| 42 |
"sync_status": "idle",
|
| 43 |
"error_msg": None,
|
| 44 |
})
|
| 45 |
-
|
|
|
|
| 46 |
sources.append({
|
| 47 |
"id": "confluence-default",
|
| 48 |
"type": "confluence",
|
| 49 |
"name": "Confluence",
|
| 50 |
-
"url":
|
| 51 |
-
"enabled": bool(os.getenv("CONFLUENCE_API_TOKEN")),
|
| 52 |
"last_sync": None,
|
| 53 |
"sync_status": "idle",
|
| 54 |
"error_msg": None,
|
|
|
|
| 31 |
def _default_sources() -> list[dict]:
|
| 32 |
"""Build source list from env vars that are set."""
|
| 33 |
sources = []
|
| 34 |
+
jira_url = os.getenv("JIRA_BASE_URL") or os.getenv("JIRA_INSTANCE_URL") or settings.integrations.jira_instance_url
|
| 35 |
+
if jira_url:
|
| 36 |
sources.append({
|
| 37 |
"id": "jira-default",
|
| 38 |
"type": "jira",
|
| 39 |
"name": "Jira",
|
| 40 |
+
"url": jira_url,
|
| 41 |
+
"enabled": bool(os.getenv("JIRA_API_TOKEN") or settings.integrations.jira_api_token),
|
| 42 |
"last_sync": None,
|
| 43 |
"sync_status": "idle",
|
| 44 |
"error_msg": None,
|
| 45 |
})
|
| 46 |
+
confluence_url = os.getenv("CONFLUENCE_BASE_URL") or os.getenv("CONFLUENCE_URL")
|
| 47 |
+
if confluence_url:
|
| 48 |
sources.append({
|
| 49 |
"id": "confluence-default",
|
| 50 |
"type": "confluence",
|
| 51 |
"name": "Confluence",
|
| 52 |
+
"url": confluence_url,
|
| 53 |
+
"enabled": bool(os.getenv("CONFLUENCE_TOKEN") or os.getenv("CONFLUENCE_API_TOKEN")),
|
| 54 |
"last_sync": None,
|
| 55 |
"sync_status": "idle",
|
| 56 |
"error_msg": None,
|
src/admin/users_api.py
CHANGED
|
@@ -52,7 +52,7 @@ async def list_users(user=Depends(require_role("admin", "org_admin"))) -> dict:
|
|
| 52 |
result = (
|
| 53 |
_client()
|
| 54 |
.table("users")
|
| 55 |
-
.select("id, email, name, role, is_active
|
| 56 |
.eq("workspace_id", DEFAULT_WORKSPACE_ID)
|
| 57 |
.order("name")
|
| 58 |
.execute()
|
|
|
|
| 52 |
result = (
|
| 53 |
_client()
|
| 54 |
.table("users")
|
| 55 |
+
.select("id, email, name, role, is_active")
|
| 56 |
.eq("workspace_id", DEFAULT_WORKSPACE_ID)
|
| 57 |
.order("name")
|
| 58 |
.execute()
|
src/auth/email.py
CHANGED
|
@@ -13,23 +13,26 @@ logger = logging.getLogger(__name__)
|
|
| 13 |
|
| 14 |
def send_email(to: str, subject: str, html: str) -> None:
|
| 15 |
"""Fire-and-forget — logs on failure, never raises."""
|
| 16 |
-
|
| 17 |
-
logger.warning("email_not_configured — skipping send to %s", to)
|
| 18 |
-
return
|
| 19 |
|
| 20 |
-
def _send():
|
| 21 |
-
try:
|
| 22 |
-
msg = MIMEMultipart("alternative")
|
| 23 |
-
msg["Subject"] = subject
|
| 24 |
-
msg["From"] = settings.smtp_from
|
| 25 |
-
msg["To"] = to
|
| 26 |
-
msg.attach(MIMEText(html, "html"))
|
| 27 |
-
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as s:
|
| 28 |
-
s.starttls()
|
| 29 |
-
s.login(settings.smtp_user, settings.smtp_password)
|
| 30 |
-
s.sendmail(settings.smtp_from, to, msg.as_string())
|
| 31 |
-
logger.info("email_sent to=%s subject=%s", to, subject)
|
| 32 |
-
except Exception:
|
| 33 |
-
logger.exception("email_send_failed to=%s", to)
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
def send_email(to: str, subject: str, html: str) -> None:
|
| 15 |
"""Fire-and-forget — logs on failure, never raises."""
|
| 16 |
+
threading.Thread(target=lambda: send_email_sync(to, subject, html), daemon=True).start()
|
|
|
|
|
|
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
def send_email_sync(to: str, subject: str, html: str) -> bool:
|
| 20 |
+
"""Synchronous send — returns True if sent, False if skipped or failed."""
|
| 21 |
+
if not settings.smtp_host or not settings.smtp_user:
|
| 22 |
+
logger.warning("email_not_configured — skipping send to %s", to)
|
| 23 |
+
return False
|
| 24 |
+
try:
|
| 25 |
+
msg = MIMEMultipart("alternative")
|
| 26 |
+
msg["Subject"] = subject
|
| 27 |
+
msg["From"] = settings.smtp_from
|
| 28 |
+
msg["To"] = to
|
| 29 |
+
msg.attach(MIMEText(html, "html"))
|
| 30 |
+
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as s:
|
| 31 |
+
s.starttls()
|
| 32 |
+
s.login(settings.smtp_user, settings.smtp_password)
|
| 33 |
+
s.sendmail(settings.smtp_from, to, msg.as_string())
|
| 34 |
+
logger.info("email_sent to=%s subject=%s", to, subject)
|
| 35 |
+
return True
|
| 36 |
+
except Exception:
|
| 37 |
+
logger.exception("email_send_failed to=%s", to)
|
| 38 |
+
return False
|
src/auth/router.py
CHANGED
|
@@ -432,10 +432,11 @@ async def send_invite(
|
|
| 432 |
f"<p><a href=\"{invite_url}\">Accept your invitation</a></p>"
|
| 433 |
f"<p>This link expires in 7 days.</p>"
|
| 434 |
)
|
| 435 |
-
|
|
|
|
| 436 |
|
| 437 |
-
logger.info("invite_sent to=%s by=%s role=%s", email, caller["id"], body.role)
|
| 438 |
-
return {"ok": True, "email": email}
|
| 439 |
|
| 440 |
|
| 441 |
@router.get("/invite/{token}")
|
|
|
|
| 432 |
f"<p><a href=\"{invite_url}\">Accept your invitation</a></p>"
|
| 433 |
f"<p>This link expires in 7 days.</p>"
|
| 434 |
)
|
| 435 |
+
from src.auth.email import send_email_sync
|
| 436 |
+
email_sent = send_email_sync(to=email, subject="You're invited to GodSpeed", html=html)
|
| 437 |
|
| 438 |
+
logger.info("invite_sent to=%s by=%s role=%s email_sent=%s", email, caller["id"], body.role, email_sent)
|
| 439 |
+
return {"ok": True, "email": email, "invite_url": invite_url, "email_sent": email_sent}
|
| 440 |
|
| 441 |
|
| 442 |
@router.get("/invite/{token}")
|
src/confluence_agent/tasks.py
CHANGED
|
@@ -15,7 +15,7 @@ def confluence_process_page(self, page_id: str, space_key: str = "", team_id: st
|
|
| 15 |
"""Webhook-triggered single-page ingestion."""
|
| 16 |
try:
|
| 17 |
from src.confluence_agent.pipeline import ingest_page
|
| 18 |
-
count = asyncio.
|
| 19 |
ingest_page(page_id, space_key, team_id or confluence_config.team_id)
|
| 20 |
)
|
| 21 |
return {"page_id": page_id, "chunks_stored": count}
|
|
@@ -29,7 +29,7 @@ def confluence_sync_space(self, space_key: str, team_id: str = "") -> dict:
|
|
| 29 |
"""Full space sync."""
|
| 30 |
try:
|
| 31 |
from src.confluence_agent.pipeline import ingest_space
|
| 32 |
-
count = asyncio.
|
| 33 |
ingest_space(space_key, team_id or confluence_config.team_id)
|
| 34 |
)
|
| 35 |
return {"space_key": space_key, "chunks_stored": count}
|
|
@@ -65,6 +65,6 @@ def confluence_periodic_sync() -> dict:
|
|
| 65 |
results[space_key] = len(docs)
|
| 66 |
return total
|
| 67 |
|
| 68 |
-
total = asyncio.
|
| 69 |
logger.info("confluence_periodic_sync: synced %d pages, %d total chunks", sum(results.values()), total)
|
| 70 |
return {"spaces": results, "total_chunks": total}
|
|
|
|
| 15 |
"""Webhook-triggered single-page ingestion."""
|
| 16 |
try:
|
| 17 |
from src.confluence_agent.pipeline import ingest_page
|
| 18 |
+
count = asyncio.run(
|
| 19 |
ingest_page(page_id, space_key, team_id or confluence_config.team_id)
|
| 20 |
)
|
| 21 |
return {"page_id": page_id, "chunks_stored": count}
|
|
|
|
| 29 |
"""Full space sync."""
|
| 30 |
try:
|
| 31 |
from src.confluence_agent.pipeline import ingest_space
|
| 32 |
+
count = asyncio.run(
|
| 33 |
ingest_space(space_key, team_id or confluence_config.team_id)
|
| 34 |
)
|
| 35 |
return {"space_key": space_key, "chunks_stored": count}
|
|
|
|
| 65 |
results[space_key] = len(docs)
|
| 66 |
return total
|
| 67 |
|
| 68 |
+
total = asyncio.run(_sync_all())
|
| 69 |
logger.info("confluence_periodic_sync: synced %d pages, %d total chunks", sum(results.values()), total)
|
| 70 |
return {"spaces": results, "total_chunks": total}
|
src/jira_agent/tasks.py
CHANGED
|
@@ -14,7 +14,7 @@ def jira_process_issue(self, issue_key: str, team_id: str = "") -> dict:
|
|
| 14 |
"""Webhook-triggered single-issue ingestion."""
|
| 15 |
try:
|
| 16 |
from src.jira_agent.pipeline import ingest_issue
|
| 17 |
-
count = asyncio.
|
| 18 |
ingest_issue(issue_key, team_id or jira_config.team_id)
|
| 19 |
)
|
| 20 |
return {"issue_key": issue_key, "chunks_stored": count}
|
|
@@ -28,7 +28,7 @@ def jira_sync_project(self, project_key: str, team_id: str = "") -> dict:
|
|
| 28 |
"""Full project sync — intended for manual trigger or scheduled use."""
|
| 29 |
try:
|
| 30 |
from src.jira_agent.pipeline import ingest_project
|
| 31 |
-
count = asyncio.
|
| 32 |
ingest_project(project_key, team_id or jira_config.team_id)
|
| 33 |
)
|
| 34 |
return {"project_key": project_key, "chunks_stored": count}
|
|
|
|
| 14 |
"""Webhook-triggered single-issue ingestion."""
|
| 15 |
try:
|
| 16 |
from src.jira_agent.pipeline import ingest_issue
|
| 17 |
+
count = asyncio.run(
|
| 18 |
ingest_issue(issue_key, team_id or jira_config.team_id)
|
| 19 |
)
|
| 20 |
return {"issue_key": issue_key, "chunks_stored": count}
|
|
|
|
| 28 |
"""Full project sync — intended for manual trigger or scheduled use."""
|
| 29 |
try:
|
| 30 |
from src.jira_agent.pipeline import ingest_project
|
| 31 |
+
count = asyncio.run(
|
| 32 |
ingest_project(project_key, team_id or jira_config.team_id)
|
| 33 |
)
|
| 34 |
return {"project_key": project_key, "chunks_stored": count}
|
start-ngrok.sh
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Start GodSpeed backend and ngrok tunnel
|
| 3 |
+
|
| 4 |
+
# Kill any existing ngrok / uvicorn
|
| 5 |
+
pkill -f "ngrok http" 2>/dev/null
|
| 6 |
+
pkill -f "uvicorn" 2>/dev/null
|
| 7 |
+
sleep 1
|
| 8 |
+
|
| 9 |
+
echo "Starting ngrok..."
|
| 10 |
+
ngrok http 8000 --log=stdout > /tmp/ngrok.log 2>&1 &
|
| 11 |
+
NGROK_PID=$!
|
| 12 |
+
|
| 13 |
+
# Wait for ngrok to be ready
|
| 14 |
+
for i in $(seq 1 15); do
|
| 15 |
+
NGROK_URL=$(curl -s http://localhost:4040/api/tunnels 2>/dev/null | python3 -c "
|
| 16 |
+
import sys, json
|
| 17 |
+
try:
|
| 18 |
+
data = json.load(sys.stdin)
|
| 19 |
+
for t in data.get('tunnels', []):
|
| 20 |
+
if t.get('proto') == 'https':
|
| 21 |
+
print(t['public_url'])
|
| 22 |
+
break
|
| 23 |
+
except: pass
|
| 24 |
+
" 2>/dev/null)
|
| 25 |
+
[ -n "$NGROK_URL" ] && break
|
| 26 |
+
sleep 1
|
| 27 |
+
done
|
| 28 |
+
|
| 29 |
+
if [ -z "$NGROK_URL" ]; then
|
| 30 |
+
echo "ERROR: Could not get ngrok URL. Is ngrok authenticated? Run: ngrok config add-authtoken <token>"
|
| 31 |
+
kill $NGROK_PID 2>/dev/null
|
| 32 |
+
exit 1
|
| 33 |
+
fi
|
| 34 |
+
|
| 35 |
+
echo "Starting backend with FRONTEND_URL=$NGROK_URL ..."
|
| 36 |
+
FRONTEND_URL=$NGROK_URL .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 &
|
| 37 |
+
BACKEND_PID=$!
|
| 38 |
+
|
| 39 |
+
sleep 2
|
| 40 |
+
|
| 41 |
+
echo ""
|
| 42 |
+
echo "========================================="
|
| 43 |
+
echo " GodSpeed is live at: $NGROK_URL"
|
| 44 |
+
echo " Share this URL with your friends!"
|
| 45 |
+
echo "========================================="
|
| 46 |
+
echo ""
|
| 47 |
+
echo "Press Ctrl+C to stop everything"
|
| 48 |
+
|
| 49 |
+
trap "kill $BACKEND_PID $NGROK_PID 2>/dev/null; exit" INT
|
| 50 |
+
wait
|