Compare commits
8 commits
main
...
add-groq-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e33368a03c | ||
|
|
403b4e8c5f | ||
|
|
e374440c67 | ||
|
|
9298de1a49 | ||
|
|
d999d6af4a | ||
|
|
6a23cf80d5 | ||
|
|
3512c615c9 | ||
|
|
614a109378 |
11 changed files with 306 additions and 142 deletions
|
|
@ -31,7 +31,7 @@ class Config(metaclass=ConfigMeta):
|
|||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# PROVIDER CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Provider: "local" (Ollama) or "openai" (OpenAI API)
|
||||
# Provider: "local" (Ollama), "openai" (OpenAI API), or "groq" (Groq API)
|
||||
PROVIDER: str = os.getenv("CLAUDE_OS_PROVIDER", "local")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -49,6 +49,13 @@ class Config(metaclass=ConfigMeta):
|
|||
OPENAI_EMBED_MODEL: str = os.getenv("OPENAI_EMBED_MODEL", "text-embedding-3-small")
|
||||
OPENAI_EMBED_DIMENSIONS: int = 1536 # OpenAI text-embedding-3-small dimensions
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# GROQ CONFIGURATION (for groq provider)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Groq has no embeddings API - embeddings always stay on Ollama/OpenAI when using this provider.
|
||||
GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "")
|
||||
GROQ_MODEL: str = os.getenv("GROQ_MODEL", "openai/gpt-oss-120b")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# MODEL CONFIGURATION (auto-selected based on provider)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -217,9 +224,9 @@ class Config(metaclass=ConfigMeta):
|
|||
|
||||
@classmethod
|
||||
def get_provider(cls) -> str:
|
||||
"""Get the active provider (local or openai)."""
|
||||
"""Get the active provider (local, openai, or groq)."""
|
||||
provider = os.getenv("CLAUDE_OS_PROVIDER", "local").lower()
|
||||
if provider not in ("local", "openai", "custom"):
|
||||
if provider not in ("local", "openai", "groq", "custom"):
|
||||
return "local"
|
||||
return provider
|
||||
|
||||
|
|
@ -233,6 +240,11 @@ class Config(metaclass=ConfigMeta):
|
|||
"""Check if using OpenAI provider."""
|
||||
return cls.get_provider() == "openai"
|
||||
|
||||
@classmethod
|
||||
def is_groq_provider(cls) -> bool:
|
||||
"""Check if using Groq provider."""
|
||||
return cls.get_provider() == "groq"
|
||||
|
||||
@classmethod
|
||||
def get_active_llm_model(cls) -> str:
|
||||
"""Get the active LLM model based on provider."""
|
||||
|
|
@ -243,6 +255,8 @@ class Config(metaclass=ConfigMeta):
|
|||
|
||||
if cls.is_openai_provider():
|
||||
return cls.OPENAI_LLM_MODEL
|
||||
if cls.is_groq_provider():
|
||||
return cls.GROQ_MODEL
|
||||
return cls.OLLAMA_MODEL
|
||||
|
||||
@classmethod
|
||||
|
|
@ -271,6 +285,12 @@ class Config(metaclass=ConfigMeta):
|
|||
key = os.getenv("OPENAI_API_KEY", "")
|
||||
return key if key else None
|
||||
|
||||
@classmethod
|
||||
def get_groq_api_key(cls) -> Optional[str]:
|
||||
"""Get the Groq API key if configured."""
|
||||
key = os.getenv("GROQ_API_KEY", "")
|
||||
return key if key else None
|
||||
|
||||
@classmethod
|
||||
def validate_provider_config(cls) -> List[str]:
|
||||
"""Validate provider-specific configuration. Returns list of errors."""
|
||||
|
|
@ -281,6 +301,10 @@ class Config(metaclass=ConfigMeta):
|
|||
if not cls.get_openai_api_key():
|
||||
errors.append("OPENAI_API_KEY is required when using OpenAI provider")
|
||||
|
||||
elif provider == "groq":
|
||||
if not cls.get_groq_api_key():
|
||||
errors.append("GROQ_API_KEY is required when using Groq provider")
|
||||
|
||||
elif provider == "local":
|
||||
# Could add Ollama connectivity check here
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -326,6 +326,8 @@ def ingest_documents(
|
|||
# Directories to always skip during ingestion
|
||||
SKIP_DIRECTORIES = {
|
||||
'node_modules',
|
||||
'venv',
|
||||
'.venv',
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
|
|
|
|||
|
|
@ -102,9 +102,12 @@ class InsightExtractor:
|
|||
# Build prompt
|
||||
prompt = EXTRACTION_PROMPT.format(transcript=session_summary)
|
||||
|
||||
# Call Ollama
|
||||
# Call the configured LLM provider
|
||||
try:
|
||||
insights = await self._call_ollama(prompt)
|
||||
if Config.is_groq_provider():
|
||||
insights = await self._call_groq(prompt)
|
||||
else:
|
||||
insights = await self._call_ollama(prompt)
|
||||
|
||||
# Filter by requested types
|
||||
filtered_insights = [
|
||||
|
|
@ -152,25 +155,7 @@ class InsightExtractor:
|
|||
result = response.json()
|
||||
response_text = result.get("response", "")
|
||||
|
||||
# Parse JSON response
|
||||
insights_data = self._parse_llm_response(response_text)
|
||||
|
||||
# Convert to Insight objects
|
||||
insights = []
|
||||
for item in insights_data.get("insights", []):
|
||||
try:
|
||||
insight = Insight(
|
||||
type=item.get("type", "pattern"),
|
||||
title=item.get("title", "Untitled"),
|
||||
content=item.get("content", ""),
|
||||
confidence=float(item.get("confidence", 0.5))
|
||||
)
|
||||
insights.append(insight)
|
||||
except (ValueError, KeyError) as e:
|
||||
logger.warning(f"Failed to parse insight item: {e}")
|
||||
continue
|
||||
|
||||
return insights
|
||||
return self._insights_from_response_text(response_text)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Ollama API error: {e}")
|
||||
|
|
@ -179,6 +164,65 @@ class InsightExtractor:
|
|||
logger.error(f"Unexpected error calling Ollama: {e}")
|
||||
raise
|
||||
|
||||
async def _call_groq(self, prompt: str) -> List[Insight]:
|
||||
"""
|
||||
Call Groq's OpenAI-compatible chat completions API to extract insights.
|
||||
|
||||
Args:
|
||||
prompt: Extraction prompt with session transcript
|
||||
|
||||
Returns:
|
||||
List of Insight objects
|
||||
"""
|
||||
url = "https://api.groq.com/openai/v1/chat/completions"
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.3, # Lower temperature for more focused extraction
|
||||
"max_tokens": 2048, # Allow longer responses for multiple insights
|
||||
"top_p": 0.9,
|
||||
"response_format": {"type": "json_object"}, # Request JSON response
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {Config.GROQ_API_KEY}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
response_text = result["choices"][0]["message"]["content"]
|
||||
|
||||
return self._insights_from_response_text(response_text)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Groq API error: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error calling Groq: {e}")
|
||||
raise
|
||||
|
||||
def _insights_from_response_text(self, response_text: str) -> List[Insight]:
|
||||
"""Parse a raw LLM response string into Insight objects."""
|
||||
insights_data = self._parse_llm_response(response_text)
|
||||
|
||||
insights = []
|
||||
for item in insights_data.get("insights", []):
|
||||
try:
|
||||
insight = Insight(
|
||||
type=item.get("type", "pattern"),
|
||||
title=item.get("title", "Untitled"),
|
||||
content=item.get("content", ""),
|
||||
confidence=float(item.get("confidence", 0.5))
|
||||
)
|
||||
insights.append(insight)
|
||||
except (ValueError, KeyError) as e:
|
||||
logger.warning(f"Failed to parse insight item: {e}")
|
||||
continue
|
||||
|
||||
return insights
|
||||
|
||||
def _parse_llm_response(self, response_text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse LLM response JSON.
|
||||
|
|
|
|||
|
|
@ -343,10 +343,25 @@ Source documents:
|
|||
Write the consolidated document:"""
|
||||
|
||||
try:
|
||||
if Config.is_groq_provider():
|
||||
response = requests.post(
|
||||
"https://api.groq.com/openai/v1/chat/completions",
|
||||
json={
|
||||
"model": Config.get_active_llm_model(),
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2048
|
||||
},
|
||||
headers={"Authorization": f"Bearer {Config.GROQ_API_KEY}"},
|
||||
timeout=120
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["choices"][0]["message"]["content"]
|
||||
|
||||
response = requests.post(
|
||||
f"{Config.OLLAMA_HOST}/api/generate",
|
||||
json={
|
||||
"model": Config.OLLAMA_MODEL,
|
||||
"model": Config.get_active_llm_model(),
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from llama_index.core.tools import QueryEngineTool
|
|||
from llama_index.embeddings.ollama import OllamaEmbedding
|
||||
from llama_index.llms.ollama import Ollama
|
||||
from llama_index.core.postprocessor import SentenceTransformerRerank, SimilarityPostprocessor
|
||||
from llama_index.core.schema import TextNode
|
||||
from llama_index.core.schema import TextNode, NodeWithScore
|
||||
|
||||
# BM25 retriever - needs to be installed separately
|
||||
try:
|
||||
|
|
@ -85,25 +85,33 @@ class RAGEngine:
|
|||
"""
|
||||
self.collection_name = collection_name
|
||||
|
||||
# Initialize Ollama LLM optimized for Apple M4 Pro (14 cores, 48GB RAM)
|
||||
# Using native Ollama with Metal GPU acceleration
|
||||
self.llm = Ollama(
|
||||
model="llama3.1:latest", # 8B model runs great on M4 Pro
|
||||
base_url="http://localhost:11434", # Native Ollama (not Docker)
|
||||
request_timeout=120.0, # Increased to 120s for LLM generation
|
||||
context_window=8192, # Larger context with 48GB RAM
|
||||
num_ctx=8192, # Match context_window
|
||||
temperature=0.1, # Very low for factual RAG responses
|
||||
num_predict=512, # Can handle more tokens with M4 Pro
|
||||
top_k=40, # Better quality sampling
|
||||
top_p=0.9, # Nucleus sampling for better quality
|
||||
num_thread=12, # Use most cores (leave 2 for system)
|
||||
num_gpu=99, # Use all GPU layers on Apple Silicon
|
||||
repeat_penalty=1.1, # Avoid repetition
|
||||
num_batch=512, # Larger batch size for M4 Pro
|
||||
use_mmap=True, # Memory-map model for faster loading
|
||||
use_mlock=True, # Lock model in RAM (plenty available)
|
||||
)
|
||||
if Config.is_groq_provider():
|
||||
from llama_index.llms.groq import Groq
|
||||
self.llm = Groq(
|
||||
model=Config.get_active_llm_model(),
|
||||
api_key=Config.GROQ_API_KEY,
|
||||
temperature=0.1, # Very low for factual RAG responses
|
||||
)
|
||||
else:
|
||||
# Initialize Ollama LLM optimized for Apple M4 Pro (14 cores, 48GB RAM)
|
||||
# Using native Ollama with Metal GPU acceleration
|
||||
self.llm = Ollama(
|
||||
model="llama3.1:latest", # 8B model runs great on M4 Pro
|
||||
base_url="http://localhost:11434", # Native Ollama (not Docker)
|
||||
request_timeout=120.0, # Increased to 120s for LLM generation
|
||||
context_window=8192, # Larger context with 48GB RAM
|
||||
num_ctx=8192, # Match context_window
|
||||
temperature=0.1, # Very low for factual RAG responses
|
||||
num_predict=512, # Can handle more tokens with M4 Pro
|
||||
top_k=40, # Better quality sampling
|
||||
top_p=0.9, # Nucleus sampling for better quality
|
||||
num_thread=12, # Use most cores (leave 2 for system)
|
||||
num_gpu=99, # Use all GPU layers on Apple Silicon
|
||||
repeat_penalty=1.1, # Avoid repetition
|
||||
num_batch=512, # Larger batch size for M4 Pro
|
||||
use_mmap=True, # Memory-map model for faster loading
|
||||
use_mlock=True, # Lock model in RAM (plenty available)
|
||||
)
|
||||
Settings.llm = self.llm
|
||||
|
||||
# Initialize Ollama embeddings
|
||||
|
|
@ -244,7 +252,7 @@ class RAGEngine:
|
|||
|
||||
# Synthesize answer
|
||||
synthesizer = get_response_synthesizer()
|
||||
response = synthesizer.synthesize(question, nodes=fused_nodes)
|
||||
response = synthesizer.synthesize(question, nodes=self._to_scored_nodes(fused_nodes))
|
||||
|
||||
return self._format_response(response, fused_nodes)
|
||||
|
||||
|
|
@ -328,7 +336,7 @@ class RAGEngine:
|
|||
try:
|
||||
# Synthesize answer using LLM
|
||||
synthesizer = get_response_synthesizer()
|
||||
response = synthesizer.synthesize(question, nodes=filtered_nodes)
|
||||
response = synthesizer.synthesize(question, nodes=self._to_scored_nodes(filtered_nodes))
|
||||
|
||||
logger.info(f"Generated answer: {str(response)[:200]}")
|
||||
|
||||
|
|
@ -361,6 +369,13 @@ class RAGEngine:
|
|||
answer_parts.append(f"\n\nFor complete information, see the sources below.")
|
||||
return "".join(answer_parts)
|
||||
|
||||
def _to_scored_nodes(self, nodes: List[TextNode]) -> List[NodeWithScore]:
|
||||
"""Wrap raw TextNodes as NodeWithScore, which response synthesizers require."""
|
||||
return [
|
||||
NodeWithScore(node=node, score=(node.metadata or {}).get("similarity_score", 0.0))
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
def _format_response(self, response, source_nodes) -> Dict[str, any]:
|
||||
"""Format query response for return."""
|
||||
sources = []
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
const API_BASE = 'http://localhost:8051';
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8051';
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
response: string;
|
||||
|
|
@ -30,7 +35,7 @@ export async function chat(
|
|||
): Promise<ChatResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/kb/${kbName}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
use_hybrid: options?.useHybrid ?? false,
|
||||
|
|
@ -47,7 +52,7 @@ export async function chat(
|
|||
}
|
||||
|
||||
export async function getKBStats(kbName: string): Promise<KBStats> {
|
||||
const res = await fetch(`${API_BASE}/api/kb/${kbName}/stats`);
|
||||
const res = await fetch(`${API_BASE}/api/kb/${kbName}/stats`, { headers: authHeaders() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get KB stats: ${res.statusText}`);
|
||||
}
|
||||
|
|
@ -55,7 +60,7 @@ export async function getKBStats(kbName: string): Promise<KBStats> {
|
|||
}
|
||||
|
||||
export async function listDocuments(kbName: string): Promise<Document[]> {
|
||||
const res = await fetch(`${API_BASE}/api/kb/${kbName}/documents`);
|
||||
const res = await fetch(`${API_BASE}/api/kb/${kbName}/documents`, { headers: authHeaders() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to list documents: ${res.statusText}`);
|
||||
}
|
||||
|
|
@ -69,6 +74,7 @@ export async function uploadDocument(kbName: string, file: File): Promise<void>
|
|||
|
||||
const res = await fetch(`${API_BASE}/api/kb/${kbName}/upload`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
|
@ -80,6 +86,7 @@ export async function uploadDocument(kbName: string, file: File): Promise<void>
|
|||
export async function deleteDocument(kbName: string, filename: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/api/kb/${kbName}/documents/${encodeURIComponent(filename)}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = 'http://localhost:8051';
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8051';
|
||||
|
||||
// Every component that talks to the API via axios imports the same default
|
||||
// instance, so registering the interceptor once here attaches the stored
|
||||
// token everywhere - without it, auth only ever covers the two fetch() calls
|
||||
// in this file, and every axios-based request (projects, kanban, hooks,
|
||||
// most of the app) goes out unauthenticated.
|
||||
axios.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ ALGORITHM = "HS256"
|
|||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Security
|
||||
security = HTTPBearer()
|
||||
# auto_error=False: let get_current_user itself decide whether a missing
|
||||
# token is OK (auth not configured -> guest) or a 401 (auth configured).
|
||||
# The HTTPBearer default (auto_error=True) rejects any request with no
|
||||
# Authorization header before get_current_user's body ever runs, which
|
||||
# breaks the "auth disabled by default" behavior entirely.
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
|
|
@ -101,7 +106,7 @@ def decode_access_token(token: str) -> Optional[dict]:
|
|||
return None
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Security(security)) -> dict:
|
||||
def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Security(security)) -> dict:
|
||||
"""
|
||||
Dependency to get the current authenticated user.
|
||||
Use in FastAPI routes: current_user = Depends(get_current_user)
|
||||
|
|
@ -112,7 +117,23 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Security(securi
|
|||
if not creds:
|
||||
return {"email": "guest", "auth_disabled": True}
|
||||
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
# Service token: lets trusted local integrations (e.g. the code-forge
|
||||
# stdio MCP server) call the API without a user login. Only relevant
|
||||
# once auth is actually enabled - with no CLAUDE_OS_EMAIL set, everyone
|
||||
# already gets the guest bypass above regardless of this.
|
||||
service_token = os.getenv("CLAUDE_OS_SERVICE_TOKEN")
|
||||
if service_token and secrets.compare_digest(token, service_token):
|
||||
return {"email": "service", "service_account": True}
|
||||
|
||||
payload = decode_access_token(token)
|
||||
|
||||
if payload is None:
|
||||
|
|
|
|||
|
|
@ -32,12 +32,27 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
# This process is launched directly (e.g. over SSH as a stdio MCP server),
|
||||
# not necessarily with claude-os's own .env sourced into its environment or
|
||||
# its working directory set to the project root - so load it explicitly by
|
||||
# path rather than relying on load_dotenv()'s cwd-relative default search.
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
|
||||
|
||||
# Configuration
|
||||
API_BASE = os.environ.get("CLAUDE_OS_API", "http://localhost:8051")
|
||||
SERVICE_TOKEN = os.environ.get("CLAUDE_OS_SERVICE_TOKEN")
|
||||
|
||||
|
||||
def _auth_headers() -> dict:
|
||||
"""Auth header for calling claude-os's own API. No-op unless a service
|
||||
token is configured, which only matters once CLAUDE_OS_EMAIL/PASSWORD
|
||||
are set - the API allows all requests through as a guest otherwise."""
|
||||
return {"Authorization": f"Bearer {SERVICE_TOKEN}"} if SERVICE_TOKEN else {}
|
||||
|
||||
# Health cache — tracks last health check time per KB and any warnings
|
||||
HEALTH_CACHE: dict[str, dict] = {}
|
||||
|
|
@ -78,7 +93,7 @@ async def _maybe_check_health(kb_name: str) -> list[str]:
|
|||
|
||||
# Run health check
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(headers=_auth_headers()) as client:
|
||||
resp = await client.get(api_url(f"/api/kb/{kb_name}/lifecycle/health"), timeout=15.0)
|
||||
resp.raise_for_status()
|
||||
health = resp.json()
|
||||
|
|
@ -109,7 +124,7 @@ def api_url(path: str) -> str:
|
|||
|
||||
async def api_get(path: str) -> dict:
|
||||
"""Make GET request to API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(headers=_auth_headers()) as client:
|
||||
response = await client.get(api_url(path), timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
|
@ -117,7 +132,7 @@ async def api_get(path: str) -> dict:
|
|||
|
||||
async def api_post(path: str, data: dict = None) -> dict:
|
||||
"""Make POST request to API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(headers=_auth_headers()) as client:
|
||||
response = await client.post(api_url(path), json=data or {}, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
|
@ -125,7 +140,7 @@ async def api_post(path: str, data: dict = None) -> dict:
|
|||
|
||||
async def api_delete(path: str) -> dict:
|
||||
"""Make DELETE request to API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(headers=_auth_headers()) as client:
|
||||
response = await client.delete(api_url(path), timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
|
@ -133,7 +148,7 @@ async def api_delete(path: str) -> dict:
|
|||
|
||||
async def api_put(path: str, data: dict = None) -> dict:
|
||||
"""Make PUT request to API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(headers=_auth_headers()) as client:
|
||||
response = await client.put(api_url(path), json=data or {}, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||
|
||||
from fastapi import FastAPI, Request, UploadFile, File, HTTPException, Depends, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from mcp_server.auth import (
|
||||
authenticate_user,
|
||||
create_access_token,
|
||||
is_auth_enabled,
|
||||
get_current_user
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import uvicorn
|
||||
|
|
@ -564,14 +570,14 @@ class SearchAllRequest(BaseModel):
|
|||
|
||||
# REST API Endpoints for UI
|
||||
@app.get("/api/kb")
|
||||
async def api_list_knowledge_bases():
|
||||
async def api_list_knowledge_bases(current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: List all knowledge bases."""
|
||||
kbs = await list_knowledge_bases()
|
||||
return {"knowledge_bases": kbs}
|
||||
|
||||
|
||||
@app.post("/api/kb")
|
||||
async def api_create_knowledge_base(request: CreateKBRequest):
|
||||
async def api_create_knowledge_base(request: CreateKBRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Create a new knowledge base."""
|
||||
result = await create_knowledge_base(
|
||||
name=request.name,
|
||||
|
|
@ -585,7 +591,7 @@ async def api_create_knowledge_base(request: CreateKBRequest):
|
|||
|
||||
@app.post("/api/kb/search-all")
|
||||
@limiter.limit("10/minute")
|
||||
async def api_search_all(request: Request, body: SearchAllRequest):
|
||||
async def api_search_all(request: Request, body: SearchAllRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Search across multiple knowledge bases."""
|
||||
try:
|
||||
from llama_index.embeddings.ollama import OllamaEmbedding
|
||||
|
|
@ -634,7 +640,7 @@ async def api_search_all(request: Request, body: SearchAllRequest):
|
|||
|
||||
|
||||
@app.delete("/api/kb/{kb_name}")
|
||||
async def api_delete_knowledge_base(kb_name: str):
|
||||
async def api_delete_knowledge_base(kb_name: str, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Delete a knowledge base."""
|
||||
try:
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -654,7 +660,7 @@ async def api_delete_knowledge_base(kb_name: str):
|
|||
|
||||
|
||||
@app.get("/api/kb/{kb_name}/stats")
|
||||
async def api_get_kb_stats(kb_name: str):
|
||||
async def api_get_kb_stats(kb_name: str, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Get knowledge base statistics."""
|
||||
stats = await get_kb_stats(kb_name)
|
||||
if "error" in stats:
|
||||
|
|
@ -663,7 +669,7 @@ async def api_get_kb_stats(kb_name: str):
|
|||
|
||||
|
||||
@app.get("/api/kb/{kb_name}/documents")
|
||||
async def api_list_documents(kb_name: str):
|
||||
async def api_list_documents(kb_name: str, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: List documents in a knowledge base."""
|
||||
docs = await list_documents(kb_name)
|
||||
return {"documents": docs}
|
||||
|
|
@ -671,7 +677,7 @@ async def api_list_documents(kb_name: str):
|
|||
|
||||
@app.post("/api/kb/{kb_name}/chat")
|
||||
@limiter.limit("20/minute") # Rate limit: 20 queries per minute per IP
|
||||
async def api_chat(request: Request, kb_name: str, chat_request: ChatRequest):
|
||||
async def api_chat(request: Request, kb_name: str, chat_request: ChatRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Chat with a knowledge base."""
|
||||
result = await search_knowledge_base(
|
||||
kb_name=kb_name,
|
||||
|
|
@ -686,7 +692,7 @@ async def api_chat(request: Request, kb_name: str, chat_request: ChatRequest):
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/upload")
|
||||
async def api_upload_document(kb_name: str, file: UploadFile = File(...)):
|
||||
async def api_upload_document(kb_name: str, file: UploadFile = File(...), current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Upload a single document to a knowledge base."""
|
||||
import tempfile
|
||||
import os
|
||||
|
|
@ -733,7 +739,7 @@ class ContentUploadRequest(BaseModel):
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/documents/content")
|
||||
async def api_upload_content(kb_name: str, request: ContentUploadRequest):
|
||||
async def api_upload_content(kb_name: str, request: ContentUploadRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Upload raw text content directly to a knowledge base (no file needed)."""
|
||||
import tempfile
|
||||
import os as _os
|
||||
|
|
@ -777,7 +783,7 @@ async def api_upload_content(kb_name: str, request: ContentUploadRequest):
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/import")
|
||||
async def api_import_directory(kb_name: str, directory_path: str):
|
||||
async def api_import_directory(kb_name: str, directory_path: str, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Import all files from a directory into a knowledge base."""
|
||||
from app.core.ingestion import ingest_directory
|
||||
|
||||
|
|
@ -810,7 +816,7 @@ async def api_import_directory(kb_name: str, directory_path: str):
|
|||
|
||||
|
||||
@app.delete("/api/kb/{kb_name}/documents/{filename}")
|
||||
async def api_delete_document(kb_name: str, filename: str):
|
||||
async def api_delete_document(kb_name: str, filename: str, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Delete a document from a knowledge base by filename."""
|
||||
db_manager = get_sqlite_manager()
|
||||
|
||||
|
|
@ -886,7 +892,7 @@ class SemanticIndexRequest(BaseModel):
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/index-structural")
|
||||
async def api_index_structural(kb_name: str, request: StructuralIndexRequest):
|
||||
async def api_index_structural(kb_name: str, request: StructuralIndexRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Fast structural indexing using tree-sitter.
|
||||
Phase 1 of hybrid indexing - completes in ~30 seconds for 10k files.
|
||||
|
|
@ -994,7 +1000,7 @@ def _run_semantic_indexing_background(job_id: str, kb_name: str, project_path: s
|
|||
Updates INDEXING_JOBS with progress.
|
||||
"""
|
||||
from app.core.tree_sitter_indexer import TreeSitterIndexer
|
||||
from app.core.ingestion import ingest_directory, ingest_file
|
||||
from app.core.ingestion import ingest_directory, ingest_file, should_skip_path
|
||||
|
||||
def update_job(status: str, progress: int = 0, message: str = "", error: str = None):
|
||||
with INDEXING_JOBS_LOCK:
|
||||
|
|
@ -1031,7 +1037,9 @@ def _run_semantic_indexing_background(job_id: str, kb_name: str, project_path: s
|
|||
docs_patterns = ["*.md", "*.txt", "*.rst"]
|
||||
doc_files = []
|
||||
for pattern in docs_patterns:
|
||||
doc_files.extend(Path(project_path).rglob(pattern))
|
||||
doc_files.extend(
|
||||
f for f in Path(project_path).rglob(pattern) if not should_skip_path(f)
|
||||
)
|
||||
|
||||
all_files = list(set(important_files + [str(f.relative_to(project_path)) for f in doc_files]))
|
||||
total_files = len(all_files)
|
||||
|
|
@ -1085,7 +1093,7 @@ def _run_semantic_indexing_sync(kb_name: str, project_path: str, selective: bool
|
|||
Returns the final result dict.
|
||||
"""
|
||||
from app.core.tree_sitter_indexer import TreeSitterIndexer
|
||||
from app.core.ingestion import ingest_directory, ingest_file
|
||||
from app.core.ingestion import ingest_directory, ingest_file, should_skip_path
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
|
@ -1102,7 +1110,9 @@ def _run_semantic_indexing_sync(kb_name: str, project_path: str, selective: bool
|
|||
docs_patterns = ["*.md", "*.txt", "*.rst"]
|
||||
doc_files = []
|
||||
for pattern in docs_patterns:
|
||||
doc_files.extend(Path(project_path).rglob(pattern))
|
||||
doc_files.extend(
|
||||
f for f in Path(project_path).rglob(pattern) if not should_skip_path(f)
|
||||
)
|
||||
|
||||
all_files = list(set(important_files + [str(f.relative_to(project_path)) for f in doc_files]))
|
||||
|
||||
|
|
@ -1145,7 +1155,7 @@ def _run_semantic_indexing_sync(kb_name: str, project_path: str, selective: bool
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/index-semantic")
|
||||
async def api_index_semantic(kb_name: str, request: SemanticIndexRequest, background_tasks: BackgroundTasks):
|
||||
async def api_index_semantic(kb_name: str, request: SemanticIndexRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Semantic indexing with selective embedding.
|
||||
Phase 2 of hybrid indexing.
|
||||
|
|
@ -1224,7 +1234,7 @@ async def api_index_semantic(kb_name: str, request: SemanticIndexRequest, backgr
|
|||
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
async def api_get_job_status(job_id: str):
|
||||
async def api_get_job_status(job_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Get the status of a background indexing job."""
|
||||
with INDEXING_JOBS_LOCK:
|
||||
if job_id not in INDEXING_JOBS:
|
||||
|
|
@ -1233,7 +1243,7 @@ async def api_get_job_status(job_id: str):
|
|||
|
||||
|
||||
@app.get("/api/jobs")
|
||||
async def api_list_jobs():
|
||||
async def api_list_jobs(current_user: dict = Depends(get_current_user)):
|
||||
"""List all indexing jobs (recent first)."""
|
||||
with INDEXING_JOBS_LOCK:
|
||||
jobs = list(INDEXING_JOBS.values())
|
||||
|
|
@ -1248,7 +1258,7 @@ async def api_get_repo_map(
|
|||
token_budget: int = 1024,
|
||||
project_path: Optional[str] = None,
|
||||
personalization: Optional[str] = None # JSON string
|
||||
):
|
||||
, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Get compact repo map for Claude's prompt context.
|
||||
Returns the most important symbols fitting within token budget.
|
||||
|
|
@ -1361,7 +1371,7 @@ class ProjectDocumentIngestRequest(BaseModel):
|
|||
|
||||
|
||||
@app.get("/api/projects")
|
||||
async def api_list_projects():
|
||||
async def api_list_projects(current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: List all projects."""
|
||||
try:
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -1373,7 +1383,7 @@ async def api_list_projects():
|
|||
|
||||
|
||||
@app.post("/api/projects")
|
||||
async def api_create_project(request: ProjectRequest):
|
||||
async def api_create_project(request: ProjectRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Create a new project with 4 required MCPs."""
|
||||
try:
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -1429,7 +1439,7 @@ async def api_create_project(request: ProjectRequest):
|
|||
|
||||
|
||||
@app.get("/api/projects/{project_id}")
|
||||
async def api_get_project(project_id: int):
|
||||
async def api_get_project(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Get project details with MCP assignments."""
|
||||
try:
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -1456,7 +1466,7 @@ async def api_get_project(project_id: int):
|
|||
|
||||
|
||||
@app.get("/api/projects/{project_id}/mcps")
|
||||
async def api_get_project_mcps(project_id: int):
|
||||
async def api_get_project_mcps(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Get detailed MCP info for a project (KB names and IDs)."""
|
||||
try:
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -1495,7 +1505,7 @@ async def api_get_project_mcps(project_id: int):
|
|||
|
||||
|
||||
@app.post("/api/projects/{project_id}/folders")
|
||||
async def api_set_kb_folder(project_id: int, request: ProjectKBFolderRequest):
|
||||
async def api_set_kb_folder(project_id: int, request: ProjectKBFolderRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Set the folder path for a project's MCP KB."""
|
||||
try:
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -1580,7 +1590,7 @@ async def api_set_kb_folder(project_id: int, request: ProjectKBFolderRequest):
|
|||
|
||||
|
||||
@app.get("/api/projects/{project_id}/folders")
|
||||
async def api_get_kb_folders(project_id: int):
|
||||
async def api_get_kb_folders(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Get folder configurations for a project's MCPs."""
|
||||
try:
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -1606,7 +1616,7 @@ async def api_get_kb_folders(project_id: int):
|
|||
|
||||
|
||||
@app.post("/api/projects/{project_id}/ingest-document")
|
||||
async def api_ingest_document(project_id: int, request: ProjectDocumentIngestRequest):
|
||||
async def api_ingest_document(project_id: int, request: ProjectDocumentIngestRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Ingest a document directly into a project's MCP KB."""
|
||||
try:
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -1669,7 +1679,7 @@ async def api_ingest_document(project_id: int, request: ProjectDocumentIngestReq
|
|||
|
||||
|
||||
@app.delete("/api/projects/{project_id}")
|
||||
async def api_delete_project(project_id: int):
|
||||
async def api_delete_project(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Delete a project and its associated KBs."""
|
||||
try:
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -1734,7 +1744,7 @@ class SyncRequest(BaseModel):
|
|||
|
||||
|
||||
@app.post("/api/projects/{project_id}/hooks/{mcp_type}/enable")
|
||||
async def api_enable_hook(project_id: int, mcp_type: str, request: HookRequest):
|
||||
async def api_enable_hook(project_id: int, mcp_type: str, request: HookRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Enable automatic KB synchronization for a folder."""
|
||||
try:
|
||||
hook = get_project_hook(project_id)
|
||||
|
|
@ -1759,7 +1769,7 @@ async def api_enable_hook(project_id: int, mcp_type: str, request: HookRequest):
|
|||
|
||||
|
||||
@app.post("/api/projects/{project_id}/hooks/{mcp_type}/disable")
|
||||
async def api_disable_hook(project_id: int, mcp_type: str):
|
||||
async def api_disable_hook(project_id: int, mcp_type: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Disable automatic KB synchronization."""
|
||||
try:
|
||||
hook = get_project_hook(project_id)
|
||||
|
|
@ -1777,7 +1787,7 @@ async def api_disable_hook(project_id: int, mcp_type: str):
|
|||
|
||||
|
||||
@app.post("/api/projects/{project_id}/hooks/sync")
|
||||
async def api_sync_hooks(project_id: int, request: Optional[SyncRequest] = None):
|
||||
async def api_sync_hooks(project_id: int, request: Optional[SyncRequest] = None, current_user: dict = Depends(get_current_user)):
|
||||
"""Manually sync KB folders for a project."""
|
||||
try:
|
||||
hook = get_project_hook(project_id)
|
||||
|
|
@ -1803,7 +1813,7 @@ async def api_sync_hooks(project_id: int, request: Optional[SyncRequest] = None)
|
|||
|
||||
|
||||
@app.get("/api/projects/{project_id}/hooks")
|
||||
async def api_get_hooks_status(project_id: int):
|
||||
async def api_get_hooks_status(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Get hook status for a project."""
|
||||
try:
|
||||
hook = get_project_hook(project_id)
|
||||
|
|
@ -1824,7 +1834,7 @@ async def api_get_hooks_status(project_id: int):
|
|||
# ============================================================================
|
||||
|
||||
@app.post("/api/watcher/start/{project_id}")
|
||||
async def api_start_watcher(project_id: int):
|
||||
async def api_start_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Start file watcher for a project."""
|
||||
try:
|
||||
watcher = get_global_watcher()
|
||||
|
|
@ -1842,7 +1852,7 @@ async def api_start_watcher(project_id: int):
|
|||
|
||||
|
||||
@app.post("/api/watcher/stop/{project_id}")
|
||||
async def api_stop_watcher(project_id: int):
|
||||
async def api_stop_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Stop file watcher for a project."""
|
||||
try:
|
||||
watcher = get_global_watcher()
|
||||
|
|
@ -1860,7 +1870,7 @@ async def api_stop_watcher(project_id: int):
|
|||
|
||||
|
||||
@app.post("/api/watcher/restart/{project_id}")
|
||||
async def api_restart_watcher(project_id: int):
|
||||
async def api_restart_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Restart file watcher for a project."""
|
||||
try:
|
||||
watcher = get_global_watcher()
|
||||
|
|
@ -1878,7 +1888,7 @@ async def api_restart_watcher(project_id: int):
|
|||
|
||||
|
||||
@app.get("/api/watcher/status")
|
||||
async def api_watcher_status():
|
||||
async def api_watcher_status(current_user: dict = Depends(get_current_user)):
|
||||
"""Get file watcher status."""
|
||||
try:
|
||||
watcher = get_global_watcher()
|
||||
|
|
@ -1893,7 +1903,7 @@ async def api_watcher_status():
|
|||
# ============================================================================
|
||||
|
||||
@app.post("/api/spec-watcher/start/{project_id}")
|
||||
async def api_start_spec_watcher(project_id: int):
|
||||
async def api_start_spec_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Start spec watcher for a project."""
|
||||
try:
|
||||
from app.core.spec_watcher import get_global_spec_watcher
|
||||
|
|
@ -1918,7 +1928,7 @@ async def api_start_spec_watcher(project_id: int):
|
|||
|
||||
|
||||
@app.post("/api/spec-watcher/stop/{project_id}")
|
||||
async def api_stop_spec_watcher(project_id: int):
|
||||
async def api_stop_spec_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Stop spec watcher for a project."""
|
||||
try:
|
||||
from app.core.spec_watcher import get_global_spec_watcher
|
||||
|
|
@ -1937,7 +1947,7 @@ async def api_stop_spec_watcher(project_id: int):
|
|||
|
||||
|
||||
@app.post("/api/spec-watcher/start-all")
|
||||
async def api_start_all_spec_watchers():
|
||||
async def api_start_all_spec_watchers(current_user: dict = Depends(get_current_user)):
|
||||
"""Start spec watchers for all projects."""
|
||||
try:
|
||||
from app.core.spec_watcher import get_global_spec_watcher
|
||||
|
|
@ -1955,7 +1965,7 @@ async def api_start_all_spec_watchers():
|
|||
|
||||
|
||||
@app.get("/api/spec-watcher/status")
|
||||
async def api_spec_watcher_status():
|
||||
async def api_spec_watcher_status(current_user: dict = Depends(get_current_user)):
|
||||
"""Get spec watcher status."""
|
||||
try:
|
||||
from app.core.spec_watcher import get_global_spec_watcher
|
||||
|
|
@ -1972,7 +1982,7 @@ async def api_spec_watcher_status():
|
|||
# ============================================================================
|
||||
|
||||
@app.get("/api/projects/{project_id}/specs")
|
||||
async def api_get_project_specs(project_id: int):
|
||||
async def api_get_project_specs(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Get all specs for a project."""
|
||||
try:
|
||||
manager = SpecManager()
|
||||
|
|
@ -1984,7 +1994,7 @@ async def api_get_project_specs(project_id: int):
|
|||
|
||||
|
||||
@app.get("/api/specs/{spec_id}/tasks")
|
||||
async def api_get_spec_tasks(spec_id: int):
|
||||
async def api_get_spec_tasks(spec_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Get all tasks for a spec."""
|
||||
try:
|
||||
manager = SpecManager()
|
||||
|
|
@ -2001,7 +2011,7 @@ class TaskStatusUpdate(BaseModel):
|
|||
|
||||
|
||||
@app.patch("/api/tasks/{task_id}/status")
|
||||
async def api_update_task_status(task_id: int, update: TaskStatusUpdate):
|
||||
async def api_update_task_status(task_id: int, update: TaskStatusUpdate, current_user: dict = Depends(get_current_user)):
|
||||
"""Update a task's status."""
|
||||
try:
|
||||
manager = SpecManager()
|
||||
|
|
@ -2023,7 +2033,7 @@ async def api_update_task_status(task_id: int, update: TaskStatusUpdate):
|
|||
|
||||
|
||||
@app.post("/api/projects/{project_id}/specs/sync")
|
||||
async def api_sync_project_specs(project_id: int):
|
||||
async def api_sync_project_specs(project_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Sync specs from project's agent-os folder."""
|
||||
try:
|
||||
# Get project path
|
||||
|
|
@ -2049,7 +2059,7 @@ async def api_sync_project_specs(project_id: int):
|
|||
|
||||
|
||||
@app.get("/api/projects/{project_id}/kanban")
|
||||
async def api_get_kanban_view(project_id: int, include_archived: bool = False):
|
||||
async def api_get_kanban_view(project_id: int, include_archived: bool = False, current_user: dict = Depends(get_current_user)):
|
||||
"""Get Kanban board view for a project."""
|
||||
try:
|
||||
manager = SpecManager()
|
||||
|
|
@ -2100,7 +2110,7 @@ async def api_get_kanban_view(project_id: int, include_archived: bool = False):
|
|||
|
||||
|
||||
@app.post("/api/specs/{spec_id}/archive")
|
||||
async def api_archive_spec(spec_id: int):
|
||||
async def api_archive_spec(spec_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Archive a spec."""
|
||||
try:
|
||||
manager = SpecManager()
|
||||
|
|
@ -2112,7 +2122,7 @@ async def api_archive_spec(spec_id: int):
|
|||
|
||||
|
||||
@app.post("/api/specs/{spec_id}/unarchive")
|
||||
async def api_unarchive_spec(spec_id: int):
|
||||
async def api_unarchive_spec(spec_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Unarchive a spec."""
|
||||
try:
|
||||
manager = SpecManager()
|
||||
|
|
@ -2147,7 +2157,7 @@ class SessionExtractRequest(BaseModel):
|
|||
|
||||
|
||||
@app.get("/api/sessions/list")
|
||||
async def api_list_sessions(project_path: str, limit: int = 50):
|
||||
async def api_list_sessions(project_path: str, limit: int = 50, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: List Claude Code session files for a project.
|
||||
|
||||
|
|
@ -2177,7 +2187,7 @@ async def api_list_sessions(project_path: str, limit: int = 50):
|
|||
|
||||
|
||||
@app.post("/api/sessions/extract")
|
||||
async def api_extract_session_insights(request: SessionExtractRequest):
|
||||
async def api_extract_session_insights(request: SessionExtractRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Extract insights from a Claude Code session file.
|
||||
|
||||
|
|
@ -2325,7 +2335,7 @@ class SkillInstallRequest(BaseModel):
|
|||
|
||||
|
||||
@app.get("/api/skills")
|
||||
async def api_list_skills(project_path: Optional[str] = None, include_content: bool = False):
|
||||
async def api_list_skills(project_path: Optional[str] = None, include_content: bool = False, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: List all skills (global and project).
|
||||
|
||||
|
|
@ -2352,7 +2362,7 @@ async def api_list_skills(project_path: Optional[str] = None, include_content: b
|
|||
|
||||
|
||||
@app.get("/api/skills/templates")
|
||||
async def api_list_skill_templates(category: Optional[str] = None):
|
||||
async def api_list_skill_templates(category: Optional[str] = None, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: List available skill templates.
|
||||
|
||||
|
|
@ -2378,7 +2388,7 @@ async def api_list_skill_templates(category: Optional[str] = None):
|
|||
|
||||
|
||||
@app.get("/api/skills/{scope}/{name}")
|
||||
async def api_get_skill(scope: str, name: str, project_path: Optional[str] = None):
|
||||
async def api_get_skill(scope: str, name: str, project_path: Optional[str] = None, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Get skill details including content.
|
||||
|
||||
|
|
@ -2412,7 +2422,7 @@ async def api_get_skill(scope: str, name: str, project_path: Optional[str] = Non
|
|||
|
||||
|
||||
@app.post("/api/skills/install")
|
||||
async def api_install_skill_template(request: SkillInstallRequest, project_path: str):
|
||||
async def api_install_skill_template(request: SkillInstallRequest, project_path: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Install a skill template to a project.
|
||||
|
||||
|
|
@ -2446,7 +2456,7 @@ async def api_install_skill_template(request: SkillInstallRequest, project_path:
|
|||
|
||||
|
||||
@app.post("/api/skills")
|
||||
async def api_create_skill(request: SkillCreateRequest, project_path: str):
|
||||
async def api_create_skill(request: SkillCreateRequest, project_path: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Create a custom skill for a project.
|
||||
|
||||
|
|
@ -2483,7 +2493,7 @@ async def api_create_skill(request: SkillCreateRequest, project_path: str):
|
|||
|
||||
|
||||
@app.put("/api/skills/{name}")
|
||||
async def api_update_skill(name: str, request: SkillUpdateRequest, project_path: str):
|
||||
async def api_update_skill(name: str, request: SkillUpdateRequest, project_path: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Update an existing project skill.
|
||||
|
||||
|
|
@ -2520,7 +2530,7 @@ async def api_update_skill(name: str, request: SkillUpdateRequest, project_path:
|
|||
|
||||
|
||||
@app.delete("/api/skills/{name}")
|
||||
async def api_delete_skill(name: str, project_path: str):
|
||||
async def api_delete_skill(name: str, project_path: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Delete a project skill.
|
||||
|
||||
|
|
@ -2562,7 +2572,7 @@ class CommunitySkillInstallRequest(BaseModel):
|
|||
|
||||
|
||||
@app.get("/api/skills/community/sources")
|
||||
async def api_list_community_sources():
|
||||
async def api_list_community_sources(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: List available community skill sources.
|
||||
|
||||
|
|
@ -2582,7 +2592,7 @@ async def api_list_community_sources():
|
|||
|
||||
|
||||
@app.get("/api/skills/community")
|
||||
async def api_list_community_skills(source: Optional[str] = None):
|
||||
async def api_list_community_skills(source: Optional[str] = None, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: List skills from community repositories.
|
||||
|
||||
|
|
@ -2620,7 +2630,7 @@ async def api_list_community_skills(source: Optional[str] = None):
|
|||
async def api_install_community_skill(
|
||||
request: CommunitySkillInstallRequest,
|
||||
project_path: str
|
||||
):
|
||||
, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
REST API: Install a community skill to a project.
|
||||
|
||||
|
|
@ -2665,7 +2675,7 @@ async def api_install_community_skill(
|
|||
# ============================================================================
|
||||
|
||||
@app.get("/api/ollama/models")
|
||||
async def api_list_ollama_models():
|
||||
async def api_list_ollama_models(current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: List available Ollama models."""
|
||||
try:
|
||||
response = requests.get(f"{Config.OLLAMA_HOST}/api/tags", timeout=2)
|
||||
|
|
@ -2767,7 +2777,7 @@ def check_port_listening(port: int) -> bool:
|
|||
|
||||
|
||||
@app.get("/api/services/status")
|
||||
async def api_get_services_status():
|
||||
async def api_get_services_status(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Get status of all Claude OS services.
|
||||
|
||||
|
|
@ -2905,7 +2915,7 @@ async def api_get_services_status():
|
|||
|
||||
|
||||
@app.post("/api/services/{service_type}/control")
|
||||
async def api_control_service(service_type: str, request: ServiceControlRequest):
|
||||
async def api_control_service(service_type: str, request: ServiceControlRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Control a service (start/stop/restart).
|
||||
|
||||
|
|
@ -2966,7 +2976,7 @@ async def api_control_service(service_type: str, request: ServiceControlRequest)
|
|||
|
||||
|
||||
@app.get("/api/browse-directory")
|
||||
async def api_browse_directory(path: str = None):
|
||||
async def api_browse_directory(path: str = None, current_user: dict = Depends(get_current_user)):
|
||||
"""REST API: Browse directories and return subdirectories."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
|
@ -3052,19 +3062,19 @@ async def api_browse_directory(path: str = None):
|
|||
|
||||
# MCP Protocol Endpoints
|
||||
@app.post("/")
|
||||
async def mcp_root(request: Request):
|
||||
async def mcp_root(request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Main MCP endpoint - handles JSON-RPC requests."""
|
||||
return await handle_mcp_request(request)
|
||||
|
||||
|
||||
@app.post("/mcp")
|
||||
async def mcp_endpoint(request: Request):
|
||||
async def mcp_endpoint(request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Global MCP endpoint - exposes all KBs."""
|
||||
return await handle_mcp_request(request, kb_filter=None)
|
||||
|
||||
|
||||
@app.post("/mcp/kb/{kb_slug}")
|
||||
async def mcp_kb_endpoint(kb_slug: str, request: Request):
|
||||
async def mcp_kb_endpoint(kb_slug: str, request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""KB-specific MCP endpoint - only exposes tools for this KB."""
|
||||
# Get KB name from slug
|
||||
db_manager = get_sqlite_manager()
|
||||
|
|
@ -3263,12 +3273,6 @@ async def handle_mcp_request(request: Request, kb_filter: Optional[str] = None)
|
|||
# Authentication Endpoints
|
||||
# ============================================================================
|
||||
|
||||
from mcp_server.auth import (
|
||||
authenticate_user,
|
||||
create_access_token,
|
||||
is_auth_enabled,
|
||||
get_current_user
|
||||
)
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
|
|
@ -3489,7 +3493,7 @@ def _run_consolidation_background(job_id: str, kb_name: str, doc_ids: list, new_
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/lifecycle/dedup-scan")
|
||||
async def api_lifecycle_dedup_scan(kb_name: str, request: DedupScanRequest, background_tasks: BackgroundTasks):
|
||||
async def api_lifecycle_dedup_scan(kb_name: str, request: DedupScanRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)):
|
||||
"""Scan KB for duplicate/near-duplicate documents."""
|
||||
import uuid
|
||||
|
||||
|
|
@ -3520,7 +3524,7 @@ async def api_lifecycle_dedup_scan(kb_name: str, request: DedupScanRequest, back
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/lifecycle/dedup-merge")
|
||||
async def api_lifecycle_dedup_merge(kb_name: str, request: DedupMergeRequest):
|
||||
async def api_lifecycle_dedup_merge(kb_name: str, request: DedupMergeRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Merge duplicate documents by keeping one and removing the rest."""
|
||||
db_manager = get_sqlite_manager()
|
||||
if not db_manager.collection_exists(kb_name):
|
||||
|
|
@ -3532,7 +3536,7 @@ async def api_lifecycle_dedup_merge(kb_name: str, request: DedupMergeRequest):
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/lifecycle/consolidate")
|
||||
async def api_lifecycle_consolidate(kb_name: str, request: ConsolidateRequest, background_tasks: BackgroundTasks):
|
||||
async def api_lifecycle_consolidate(kb_name: str, request: ConsolidateRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)):
|
||||
"""Consolidate related documents into a single merged document using LLM."""
|
||||
import uuid
|
||||
|
||||
|
|
@ -3559,7 +3563,7 @@ async def api_lifecycle_consolidate(kb_name: str, request: ConsolidateRequest, b
|
|||
|
||||
|
||||
@app.get("/api/kb/{kb_name}/lifecycle/health")
|
||||
async def api_lifecycle_health(kb_name: str):
|
||||
async def api_lifecycle_health(kb_name: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Get comprehensive health report for a knowledge base."""
|
||||
db_manager = get_sqlite_manager()
|
||||
if not db_manager.collection_exists(kb_name):
|
||||
|
|
@ -3570,7 +3574,7 @@ async def api_lifecycle_health(kb_name: str):
|
|||
|
||||
|
||||
@app.get("/api/kb/{kb_name}/lifecycle/growth")
|
||||
async def api_lifecycle_growth(kb_name: str, granularity: str = "month"):
|
||||
async def api_lifecycle_growth(kb_name: str, granularity: str = "month", current_user: dict = Depends(get_current_user)):
|
||||
"""Get document growth timeline for a knowledge base."""
|
||||
db_manager = get_sqlite_manager()
|
||||
if not db_manager.collection_exists(kb_name):
|
||||
|
|
@ -3581,7 +3585,7 @@ async def api_lifecycle_growth(kb_name: str, granularity: str = "month"):
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/lifecycle/archive")
|
||||
async def api_lifecycle_archive(kb_name: str, request: ArchiveRequest):
|
||||
async def api_lifecycle_archive(kb_name: str, request: ArchiveRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Archive documents in a knowledge base."""
|
||||
db_manager = get_sqlite_manager()
|
||||
if not db_manager.collection_exists(kb_name):
|
||||
|
|
@ -3592,7 +3596,7 @@ async def api_lifecycle_archive(kb_name: str, request: ArchiveRequest):
|
|||
|
||||
|
||||
@app.post("/api/kb/{kb_name}/lifecycle/restore")
|
||||
async def api_lifecycle_restore(kb_name: str, request: RestoreRequest):
|
||||
async def api_lifecycle_restore(kb_name: str, request: RestoreRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Restore archived documents in a knowledge base."""
|
||||
db_manager = get_sqlite_manager()
|
||||
if not db_manager.collection_exists(kb_name):
|
||||
|
|
@ -3603,7 +3607,7 @@ async def api_lifecycle_restore(kb_name: str, request: RestoreRequest):
|
|||
|
||||
|
||||
@app.get("/api/kb/{kb_name}/lifecycle/archived")
|
||||
async def api_lifecycle_archived(kb_name: str):
|
||||
async def api_lifecycle_archived(kb_name: str, current_user: dict = Depends(get_current_user)):
|
||||
"""List archived documents in a knowledge base."""
|
||||
db_manager = get_sqlite_manager()
|
||||
if not db_manager.collection_exists(kb_name):
|
||||
|
|
@ -3614,7 +3618,7 @@ async def api_lifecycle_archived(kb_name: str):
|
|||
|
||||
|
||||
@app.get("/api/kb/{kb_name}/lifecycle/stale")
|
||||
async def api_lifecycle_stale(kb_name: str, stale_days: int = 90):
|
||||
async def api_lifecycle_stale(kb_name: str, stale_days: int = 90, current_user: dict = Depends(get_current_user)):
|
||||
"""Find stale documents older than the specified threshold."""
|
||||
db_manager = get_sqlite_manager()
|
||||
if not db_manager.collection_exists(kb_name):
|
||||
|
|
@ -3625,7 +3629,7 @@ async def api_lifecycle_stale(kb_name: str, stale_days: int = 90):
|
|||
|
||||
|
||||
@app.get("/api/kb/{kb_name}/lifecycle/logs")
|
||||
async def api_lifecycle_logs(kb_name: str, operation_type: Optional[str] = None, limit: int = 50):
|
||||
async def api_lifecycle_logs(kb_name: str, operation_type: Optional[str] = None, limit: int = 50, current_user: dict = Depends(get_current_user)):
|
||||
"""Get lifecycle operation logs for a knowledge base."""
|
||||
db_manager = get_sqlite_manager()
|
||||
if not db_manager.collection_exists(kb_name):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
llama-index>=0.10.0
|
||||
llama-index-llms-ollama>=0.1.0
|
||||
llama-index-embeddings-ollama>=0.1.0
|
||||
llama-index-llms-groq>=0.5.0
|
||||
|
||||
# Vector Database (SQLite-based)
|
||||
sqlite-vec>=0.1.0
|
||||
|
|
@ -25,7 +26,9 @@ sentence-transformers>=2.2.0
|
|||
rank-bm25>=0.2.2
|
||||
|
||||
# MCP Server
|
||||
mcp>=1.0.0
|
||||
# Pinned <2.0.0: claude_code_mcp.py uses the low-level Server API
|
||||
# (@server.list_tools() etc.) which mcp 2.0.0 removed/renamed.
|
||||
mcp>=1.0.0,<2.0.0
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
pydantic>=2.0.0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue