Compare commits
10 Commits
d11f3dd00c
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c44df3a5c | ||
|
|
7be24d8884 | ||
|
|
5d8ecdc8e9 | ||
|
|
b4f79ee052 | ||
|
|
cc927e2a90 | ||
|
|
f5aeb9d956 | ||
|
|
6131f27142 | ||
|
|
341bd08176 | ||
|
|
7cc34ceb0b | ||
|
|
5adc826cfb |
15
GEMINI.md
15
GEMINI.md
@@ -43,9 +43,22 @@ The frontend is a modern SPA (Single Page Application) designed for data-heavy i
|
||||
- **Real-time Visualization**: Supports streaming text responses and immediate rendering of base64-encoded or binary-retrieved analysis plots.
|
||||
|
||||
## Documentation
|
||||
- **[README](./README.md)**: Main project documentation and setup guide.
|
||||
- **[Backend Guide](./backend/GEMINI.md)**: Detailed information about the backend architecture, migration goals, and implementation steps.
|
||||
- **[Frontend Guide](./frontend/GEMINI.md)**: Frontend development guide and technology stack.
|
||||
- **LangChain Docs**: See the `langchain-docs/` folder for local LangChain and LangGraph documentation.
|
||||
|
||||
## Verification Strategy
|
||||
When making changes, always verify using the following commands:
|
||||
|
||||
### Backend
|
||||
- **Test**: `cd backend && uv run pytest`
|
||||
- **Lint/Format**: `cd backend && uv run ruff check .`
|
||||
- **Type Check**: `cd backend && uv run mypy .` (if configured)
|
||||
|
||||
### Frontend
|
||||
- **Test**: `cd frontend && npm run test`
|
||||
- **Lint**: `cd frontend && npm run lint`
|
||||
- **Build**: `cd frontend && npm run build` (to ensure no compilation errors)
|
||||
|
||||
## Git Operations
|
||||
- All new feature and bug-fix branches must be created from the `develop` branch except hot-fix.
|
||||
|
||||
77
README.md
Normal file
77
README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Election Analytics Chatbot
|
||||
|
||||
A stateful, graph-based chatbot for election data analysis, built with LangGraph, FastAPI, and React.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Intelligent Query Analysis**: Automatically determines if a query needs data analysis, web research, or clarification.
|
||||
- **Automated Data Analysis**: Generates and executes Python code to analyze election datasets and produce visualizations.
|
||||
- **Web Research**: Integrates web search capabilities for general election-related questions.
|
||||
- **Stateful Conversations**: Maintains context across multiple turns using LangGraph's persistent checkpointing.
|
||||
- **Real-time Streaming**: Streams reasoning steps, code execution outputs, and plots to the UI.
|
||||
- **Secure Authentication**: Traditional login and OIDC/SSO support with HttpOnly cookies.
|
||||
- **History Management**: Persistent storage and management of chat history and generated artifacts.
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
- `backend/`: Python FastAPI application using LangGraph.
|
||||
- `frontend/`: React SPA built with TypeScript, Vite, and Tailwind CSS.
|
||||
|
||||
## 🛠️ Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- PostgreSQL
|
||||
- Docker (optional, for Postgres/PgAdmin)
|
||||
- API Keys: OpenAI/Google Gemini, Google Search (if using research tools).
|
||||
|
||||
## 📥 Getting Started
|
||||
|
||||
### Backend Setup
|
||||
|
||||
1. Navigate to the backend directory:
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
3. Set up environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration and API keys
|
||||
```
|
||||
4. Run database migrations:
|
||||
```bash
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
5. Start the server:
|
||||
```bash
|
||||
uv run python -m ea_chatbot.api.main
|
||||
```
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **[Top-level GEMINI.md](./GEMINI.md)**: General project overview.
|
||||
- **[Backend Guide](./backend/GEMINI.md)**: Detailed backend architecture and implementation details.
|
||||
- **[Frontend Guide](./frontend/GEMINI.md)**: Frontend development guide and technology stack.
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
@@ -8,11 +8,13 @@ DATA_STATE=new_jersey
|
||||
LOG_LEVEL=INFO
|
||||
DEV_MODE=true
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
API_V1_STR=/api/v1
|
||||
|
||||
# Security & JWT Configuration
|
||||
SECRET_KEY=change-me-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# Voter Database Configuration
|
||||
DB_HOST=localhost
|
||||
|
||||
@@ -1,162 +1,63 @@
|
||||
# Election Analytics Chatbot - Backend Guide
|
||||
|
||||
## Overview
|
||||
This document serves as a guide for the backend implementation of the Election Analytics Chatbot, specifically focusing on the transition from the "BambooAI" based system to a modern, stateful, and graph-based architecture using **LangGraph**.
|
||||
The backend is a Python-based FastAPI application that leverages **LangGraph** to provide a stateful, agentic workflow for election data analysis. It handles complex queries by decomposing them into tasks such as data analysis, web research, or user clarification.
|
||||
|
||||
## 1. Migration Goals
|
||||
- **Framework Switch**: Move from the custom linear `ChatBot` class (in `src/ea_chatbot/bambooai/core/chatbot.py`) to `LangGraph`.
|
||||
- **State Management**: explicit state management using LangGraph's `StateGraph`.
|
||||
- **Modularity**: Break down monolithic methods (`pd_agent_converse`, `execute_code`) into distinct Nodes.
|
||||
- **Observability**: Easier debugging of the decision process (Routing -> Planning -> Coding -> Executing).
|
||||
## 1. Architecture Overview
|
||||
- **Framework**: LangGraph for workflow orchestration and state management.
|
||||
- **API**: FastAPI for providing REST and streaming (SSE) endpoints.
|
||||
- **State Management**: Persistent state using LangGraph's `StateGraph` with a PostgreSQL checkpointer.
|
||||
- **Database**: PostgreSQL.
|
||||
- Application data: Uses `users` table for local and OIDC users (String IDs).
|
||||
- History: Persists chat history and artifacts.
|
||||
- Election Data: Structured datasets for analysis.
|
||||
|
||||
## 2. Architecture Proposal
|
||||
## 2. Core Components
|
||||
|
||||
### 2.1. The Graph State
|
||||
The state will track the conversation and execution context.
|
||||
|
||||
```python
|
||||
from typing import TypedDict, Annotated, List, Dict, Any, Optional
|
||||
from langchain_core.messages import BaseMessage
|
||||
import operator
|
||||
|
||||
class AgentState(TypedDict):
|
||||
# Conversation history
|
||||
messages: Annotated[List[BaseMessage], operator.add]
|
||||
|
||||
# Task context
|
||||
question: str
|
||||
|
||||
# Query Analysis (Decomposition results)
|
||||
analysis: Optional[Dict[str, Any]]
|
||||
# Expected keys: "requires_dataset", "expert", "data", "unknown", "condition"
|
||||
|
||||
# Step-by-step reasoning
|
||||
plan: Optional[str]
|
||||
|
||||
# Code execution context
|
||||
code: Optional[str]
|
||||
code_output: Optional[str]
|
||||
error: Optional[str]
|
||||
|
||||
# Artifacts (for UI display)
|
||||
plots: List[Figure] # Matplotlib figures
|
||||
dfs: Dict[str, DataFrame] # Pandas DataFrames
|
||||
|
||||
# Control flow
|
||||
iterations: int
|
||||
next_action: str # Routing hint: "clarify", "plan", "research", "end"
|
||||
```
|
||||
### 2.1. The Graph State (`src/ea_chatbot/graph/state.py`)
|
||||
The state tracks the conversation context, plan, generated code, execution results, and artifacts.
|
||||
|
||||
### 2.2. Nodes (The Actors)
|
||||
We will map existing logic to these nodes:
|
||||
Located in `src/ea_chatbot/graph/nodes/`:
|
||||
|
||||
1. **`query_analyzer_node`** (Router & Refiner):
|
||||
* **Logic**: Replaces `Expert Selector` and `Analyst Selector`.
|
||||
* **Function**:
|
||||
1. Decomposes the user's query into key elements (Data, Unknowns, Conditions).
|
||||
2. Determines if the query is ambiguous or missing critical information.
|
||||
* **Output**: Updates `messages`. Returns routing decision:
|
||||
* `clarification_node` (if ambiguous).
|
||||
* `planner_node` (if clear data task).
|
||||
* `researcher_node` (if general/web task).
|
||||
|
||||
2. **`clarification_node`** (Human-in-the-loop):
|
||||
* **Logic**: Replaces `Theorist-Clarification`.
|
||||
* **Function**: Formulates a specific question to ask the user for missing details.
|
||||
* **Output**: Returns a message to the user and **interrupts** the graph execution to await user input.
|
||||
|
||||
3. **`researcher_node`** (Theorist):
|
||||
* **Logic**: Handles general queries or web searches.
|
||||
* **Function**: Uses `GoogleSearch` tool if necessary.
|
||||
* **Output**: Final answer.
|
||||
|
||||
4. **`planner_node`**:
|
||||
* **Logic**: Replaces `Planner`.
|
||||
* **Function**: Generates a step-by-step plan based on the decomposed query elements and dataframe ontology.
|
||||
* **Output**: Updates `plan`.
|
||||
|
||||
5. **`coder_node`**:
|
||||
* **Logic**: Replaces `Code Generator` & `Error Corrector`.
|
||||
* **Function**: Generates Python code. If `error` exists in state, it attempts to fix it.
|
||||
* **Output**: Updates `code`.
|
||||
|
||||
6. **`executor_node`**:
|
||||
* **Logic**: Replaces `Code Executor`.
|
||||
* **Function**: Executes the Python code in a safe(r) environment. It needs access to the `DBClient`.
|
||||
* **Output**: Updates `code_output`, `plots`, `dfs`. If exception, updates `error`.
|
||||
|
||||
7. **`summarizer_node`**:
|
||||
* **Logic**: Replaces `Solution Summarizer`.
|
||||
* **Function**: Interprets the code output and generates a natural language response.
|
||||
* **Output**: Final response message.
|
||||
- **`query_analyzer`**: Analyzes the user query to determine the intent and required data.
|
||||
- **`planner`**: Creates a step-by-step plan for data analysis.
|
||||
- **`coder`**: Generates Python code based on the plan and dataset metadata.
|
||||
- **`executor`**: Safely executes the generated code and captures outputs (dataframes, plots).
|
||||
- **`error_corrector`**: Fixes code if execution fails.
|
||||
- **`researcher`**: Performs web searches for general election information.
|
||||
- **`summarizer`**: Generates a natural language response based on the analysis results.
|
||||
- **`clarification`**: Asks the user for more information if the query is ambiguous.
|
||||
|
||||
### 2.3. The Workflow (Graph)
|
||||
The graph connects these nodes with conditional edges, allowing for iterative refinement and error correction.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Start --> QueryAnalyzer
|
||||
QueryAnalyzer -->|Ambiguous| Clarification
|
||||
Clarification -->|User Input| QueryAnalyzer
|
||||
QueryAnalyzer -->|General/Web| Researcher
|
||||
QueryAnalyzer -->|Data Analysis| Planner
|
||||
Planner --> Coder
|
||||
Coder --> Executor
|
||||
Executor -->|Success| Summarizer
|
||||
Executor -->|Error| Coder
|
||||
Researcher --> End
|
||||
Summarizer --> End
|
||||
## 3. Key Modules
|
||||
|
||||
- **`src/ea_chatbot/api/`**: Contains FastAPI routers for authentication, conversation management, and the agent streaming endpoint.
|
||||
- **`src/ea_chatbot/graph/`**: Core LangGraph logic, including state definitions, node implementations, and the workflow graph.
|
||||
- **`src/ea_chatbot/history/`**: Manages persistent chat history and message mapping between application models and LangGraph state.
|
||||
- **`src/ea_chatbot/utils/`**: Utility functions for database inspection, LLM factory, and logging.
|
||||
|
||||
## 4. Development & Execution
|
||||
|
||||
### Entry Point
|
||||
The main entry point for the API is `src/ea_chatbot/api/main.py`.
|
||||
|
||||
### Running the API
|
||||
```bash
|
||||
cd backend
|
||||
uv run python -m ea_chatbot.api.main
|
||||
```
|
||||
|
||||
## 3. Implementation Steps
|
||||
|
||||
### Step 1: Dependencies
|
||||
Add the following packages to `pyproject.toml`:
|
||||
* `langgraph`
|
||||
* `langchain`
|
||||
* `langchain-openai`
|
||||
* `langchain-google-genai`
|
||||
* `langchain-community`
|
||||
|
||||
### Step 2: Directory Structure
|
||||
Create a new package for the graph logic to keep it separate from the old one during migration.
|
||||
|
||||
```
|
||||
src/ea_chatbot/
|
||||
├── graph/
|
||||
│ ├── __init__.py
|
||||
│ ├── state.py # State definition
|
||||
│ ├── nodes/ # Individual node implementations
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py
|
||||
│ │ ├── planner.py
|
||||
│ │ ├── coder.py
|
||||
│ │ ├── executor.py
|
||||
│ │ └── ...
|
||||
│ ├── workflow.py # Graph construction
|
||||
│ └── tools/ # DB and Search tools wrapped for LangChain
|
||||
└── ...
|
||||
### Database Migrations
|
||||
Handled by Alembic.
|
||||
```bash
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
### Step 3: Tool Wrapping
|
||||
Wrap the existing `DBClient` (from `src/ea_chatbot/bambooai/utils/db_client.py`) into a structure accessible by the `executor_node`. The `executor_node` will likely keep the existing `exec()` based approach initially for compatibility with the generated code, but structured as a graph node.
|
||||
|
||||
### Step 4: Prompt Migration
|
||||
Port the prompts from `data/PROMPT_TEMPLATES.json` or `src/ea_chatbot/bambooai/prompts/strings.py` into the respective nodes. Use LangChain's `ChatPromptTemplate` for better management.
|
||||
|
||||
### Step 5: Integration
|
||||
Update `src/ea_chatbot/app.py` to use the new `workflow.compile()` runnable.
|
||||
* Instead of `chatbot.pd_agent_converse(...)`, use `app.stream(...)` (LangGraph app).
|
||||
* Handle the streaming output to update the UI progressively.
|
||||
|
||||
## 4. Key Considerations for Refactoring
|
||||
|
||||
* **Database Connection**: Ensure `DBClient` is initialized once and passed to the `Executor` node efficiently (e.g., via `configurable` parameters or closure).
|
||||
* **Prompt Templating**: The current system uses simple `format` strings. Switching to LangChain templates allows for easier model switching and partial formatting.
|
||||
* **Token Management**: LangGraph provides built-in tracing (if LangSmith is enabled), but we should ensure the `OutputManager` logic (printing costs/tokens) is preserved or adapted if still needed for the CLI/Logs.
|
||||
* **Vector DB**: The current system has `PineconeWrapper` for RAG. This should be integrated into the `Planner` or `Coder` node to fetch few-shot examples or context.
|
||||
|
||||
## 5. Next Actions
|
||||
1. **Initialize**: Create the folder structure.
|
||||
2. **Define State**: Create `src/ea_chatbot/graph/state.py`.
|
||||
3. **Implement Router**: Create the first node to replicate `Expert Selector` logic.
|
||||
4. **Implement Executor**: Port the `exec()` logic to a node.
|
||||
### Testing
|
||||
Tests are located in the `tests/` directory and use `pytest`.
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
@@ -39,6 +39,14 @@ async def get_current_user(request: Request, token: str = Depends(oauth2_scheme)
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Security Fix: Reject refresh tokens for standard API access
|
||||
if payload.get("type") == "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Cannot use refresh token for this endpoint",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from ea_chatbot.api.utils import create_access_token, create_refresh_token, settings
|
||||
from ea_chatbot.api.utils import create_access_token, create_refresh_token, decode_access_token, settings
|
||||
from ea_chatbot.api.dependencies import history_manager, oidc_client, get_current_user
|
||||
from ea_chatbot.history.models import User as UserDB
|
||||
from ea_chatbot.api.schemas import Token, UserCreate, UserResponse, ThemeUpdate
|
||||
@@ -101,7 +101,6 @@ async def refresh(request: Request, response: Response):
|
||||
detail="Refresh token missing"
|
||||
)
|
||||
|
||||
from ea_chatbot.api.utils import decode_access_token # Using decode_access_token for both
|
||||
payload = decode_access_token(refresh_token)
|
||||
|
||||
if not payload:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Any, List
|
||||
from jose import JWTError, jwt
|
||||
@@ -56,7 +57,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"iss": "ea-chatbot-api"
|
||||
"iss": "ea-chatbot-api",
|
||||
"type": "access",
|
||||
"jti": str(uuid.uuid4())
|
||||
})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
return encoded_jwt
|
||||
@@ -84,7 +87,8 @@ def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None)
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"iss": "ea-chatbot-api",
|
||||
"type": "refresh"
|
||||
"type": "refresh",
|
||||
"jti": str(uuid.uuid4())
|
||||
})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
return encoded_jwt
|
||||
|
||||
@@ -18,11 +18,17 @@ def test_refresh_token_success(client):
|
||||
# 2. Set the cookie manually in the client
|
||||
client.cookies.set("refresh_token", refresh_token)
|
||||
|
||||
import time
|
||||
time.sleep(1.1) # Wait to ensure iat is different
|
||||
|
||||
# 3. Call the refresh endpoint with mock
|
||||
with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm:
|
||||
with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm, \
|
||||
patch("ea_chatbot.api.utils.datetime") as mock_datetime:
|
||||
|
||||
# Mock datetime to ensure the second token has a different timestamp
|
||||
from datetime import datetime, timezone, timedelta
|
||||
base_now = datetime.now(timezone.utc)
|
||||
# First call (inside refresh) gets base_now + 1 second
|
||||
mock_datetime.now.return_value = base_now + timedelta(seconds=1)
|
||||
mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs)
|
||||
|
||||
mock_hm.get_user_by_id.return_value = User(id=user_id, username="test@test.com")
|
||||
|
||||
response = client.post("/api/v1/auth/refresh")
|
||||
@@ -54,3 +60,17 @@ def test_refresh_token_wrong_type(client):
|
||||
response = client.post("/api/v1/auth/refresh")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Invalid token type"
|
||||
|
||||
def test_protected_endpoint_rejects_refresh_token(client):
|
||||
"""Regression test: Ensure refresh tokens cannot be used to access protected endpoints."""
|
||||
user_id = "test-user-id"
|
||||
refresh_token = create_refresh_token({"sub": user_id})
|
||||
|
||||
# Attempt to access /auth/me with a refresh token in the cookie
|
||||
client.cookies.set("access_token", refresh_token)
|
||||
|
||||
response = client.get("/api/v1/auth/me")
|
||||
|
||||
# Should be rejected with 401
|
||||
assert response.status_code == 401
|
||||
assert "Cannot use refresh token for this endpoint" in response.json()["detail"]
|
||||
|
||||
@@ -15,11 +15,13 @@ This document serves as a guide for the frontend implementation of the Election
|
||||
## Project Structure
|
||||
- `src/components/`:
|
||||
- `auth/`: Login, Registration, and OIDC callback forms/pages.
|
||||
- `chat/`: Core chat interface components, including message list and plot rendering.
|
||||
- `layout/`: Main application layout including the sidebar navigation.
|
||||
- `ui/`: Reusable primitive components (buttons, cards, inputs, etc.) via Shadcn.
|
||||
- `src/services/`:
|
||||
- `api.ts`: Axios instance configuration with `/api/v1` base URL and interceptors.
|
||||
- `auth.ts`: Authentication logic (Login, Logout, OIDC, User Profile).
|
||||
- `chat.ts`: Service for interacting with the agent streaming endpoint.
|
||||
- `src/lib/`:
|
||||
- `validations/`: Zod schemas for form validation.
|
||||
- `utils.ts`: Core utility functions.
|
||||
@@ -42,3 +44,7 @@ The frontend communicates with the backend's `/api/v1` endpoints:
|
||||
- `npm run dev`: Start development server.
|
||||
- `npm run build`: Build for production.
|
||||
- `npm run test`: Run Vitest unit tests.
|
||||
|
||||
## Documentation
|
||||
- **[README](../README.md)**: Main project documentation and setup guide.
|
||||
- **[Backend Guide](../backend/GEMINI.md)**: Backend implementation details.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -9,7 +9,9 @@ import { ChatService, type MessageResponse } from "./services/chat"
|
||||
import { type Conversation } from "./components/layout/HistorySidebar"
|
||||
import { registerUnauthorizedCallback } from "./services/api"
|
||||
import { Button } from "./components/ui/button"
|
||||
import { ThemeProvider, useTheme } from "./components/theme-provider"
|
||||
import { ThemeProvider } from "./components/theme-provider"
|
||||
import { useTheme } from "./components/theme-context"
|
||||
import { useSilentRefresh } from "./hooks/use-silent-refresh"
|
||||
|
||||
// --- Auth Context ---
|
||||
interface AuthContextType {
|
||||
@@ -52,12 +54,23 @@ function AppContent() {
|
||||
const { setThemeLocal } = useTheme()
|
||||
const { isAuthenticated, setIsAuthenticated, user, setUser } = useAuth()
|
||||
|
||||
useSilentRefresh(isAuthenticated)
|
||||
|
||||
const [authMode, setAuthMode] = useState<"login" | "register">("login")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
|
||||
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||
const [threadMessages, setThreadMessages] = useState<Record<string, MessageResponse[]>>({})
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const history = await ChatService.listConversations()
|
||||
setConversations(history)
|
||||
} catch (err) {
|
||||
console.error("Failed to load conversation history:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
registerUnauthorizedCallback(() => {
|
||||
setIsAuthenticated(false)
|
||||
@@ -86,16 +99,7 @@ function AppContent() {
|
||||
}
|
||||
|
||||
initAuth()
|
||||
}, [setIsAuthenticated, setThemeLocal, setUser])
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const history = await ChatService.listConversations()
|
||||
setConversations(history)
|
||||
} catch (err) {
|
||||
console.error("Failed to load conversation history:", err)
|
||||
}
|
||||
}, [])
|
||||
}, [setIsAuthenticated, setThemeLocal, setUser, loadHistory])
|
||||
|
||||
const handleAuthSuccess = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { useTheme } from "@/components/theme-context"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
20
frontend/src/components/theme-context.tsx
Normal file
20
frontend/src/components/theme-context.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
export type Theme = "light" | "dark"
|
||||
|
||||
export interface ThemeContextType {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
setThemeLocal: (theme: Theme) => void
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,16 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from "react"
|
||||
import { useEffect, useState, useCallback, useMemo } from "react"
|
||||
import { AuthService } from "@/services/auth"
|
||||
|
||||
export type Theme = "light" | "dark"
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
setThemeLocal: (theme: Theme) => void
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
import { type Theme, ThemeContext } from "./theme-context"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
@@ -61,11 +51,3 @@ export function ThemeProvider({
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
47
frontend/src/hooks/use-silent-refresh.ts
Normal file
47
frontend/src/hooks/use-silent-refresh.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useCallback, useRef } from 'react'
|
||||
import { AuthService } from '@/services/auth'
|
||||
|
||||
/**
|
||||
* Hook to handle silent token refresh in the background.
|
||||
* It proactively refreshes the session to prevent expiration while the user is active.
|
||||
*/
|
||||
export function useSilentRefresh(isAuthenticated: boolean) {
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
console.debug('[Auth] Proactively refreshing session...')
|
||||
await AuthService.refreshSession()
|
||||
console.debug('[Auth] Silent refresh successful.')
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Silent refresh failed:', error)
|
||||
// We don't force logout here; the reactive interceptor in api.ts
|
||||
// will handle it if a subsequent data request returns a 401.
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh every 25 minutes (access token defaults to 30 mins)
|
||||
const REFRESH_INTERVAL = 25 * 60 * 1000
|
||||
|
||||
// Clear existing timer if any
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
|
||||
timerRef.current = setInterval(refresh, REFRESH_INTERVAL)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, refresh])
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { BrowserRouter } from "react-router-dom"
|
||||
import { ThemeProvider } from "./components/theme-provider"
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from "axios"
|
||||
import axios, { type InternalAxiosRequestConfig } from "axios"
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ""
|
||||
|
||||
@@ -7,29 +7,98 @@ const api = axios.create({
|
||||
withCredentials: true, // Crucial for HttpOnly cookies
|
||||
})
|
||||
|
||||
// Optional callback for unauthorized errors
|
||||
// Optional callback for unauthorized errors (fallback if refresh fails)
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
|
||||
export const registerUnauthorizedCallback = (callback: () => void) => {
|
||||
onUnauthorized = callback
|
||||
}
|
||||
|
||||
// State to manage multiple concurrent refreshes
|
||||
let isRefreshing = false
|
||||
let refreshSubscribers: ((token: string) => void)[] = []
|
||||
let refreshErrorSubscribers: ((error: unknown) => void)[] = []
|
||||
|
||||
const subscribeTokenRefresh = (onSuccess: (token: string) => void, onError: (error: unknown) => void) => {
|
||||
refreshSubscribers.push(onSuccess)
|
||||
refreshErrorSubscribers.push(onError)
|
||||
}
|
||||
|
||||
const onRefreshed = (token: string) => {
|
||||
refreshSubscribers.forEach((callback) => callback(token))
|
||||
refreshSubscribers = []
|
||||
refreshErrorSubscribers = []
|
||||
}
|
||||
|
||||
const onRefreshFailed = (error: unknown) => {
|
||||
refreshErrorSubscribers.forEach((callback) => callback(error))
|
||||
refreshSubscribers = []
|
||||
refreshErrorSubscribers = []
|
||||
}
|
||||
|
||||
// Add a response interceptor to handle 401s
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Only handle if it's not an auth endpoint
|
||||
// This prevents loops during bootstrap and allows login form to show errors
|
||||
const isAuthEndpoint = /^\/auth\//.test(error.config?.url)
|
||||
async (error) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error.response?.status === 401 && !isAuthEndpoint) {
|
||||
// Unauthorized - session likely expired on a protected data route
|
||||
if (onUnauthorized) {
|
||||
onUnauthorized()
|
||||
// Only handle 401 if it's not already a retry and not an auth endpoint
|
||||
// We allow /auth/login and /auth/register to fail normally to show errors
|
||||
const isAuthEndpoint = /^\/auth\/(login|register|oidc\/callback)/.test(originalRequest.url || "")
|
||||
const isRefreshEndpoint = /^\/auth\/refresh/.test(originalRequest.url || "")
|
||||
|
||||
if (error.response?.status === 401 && !isAuthEndpoint && !isRefreshEndpoint && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
// Wait for the current refresh to complete
|
||||
return new Promise((resolve, reject) => {
|
||||
subscribeTokenRefresh(
|
||||
() => resolve(api(originalRequest)),
|
||||
(err) => reject(err)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
console.log("Reactive refresh: Access token expired, attempting to refresh...")
|
||||
// Call refresh endpoint
|
||||
const response = await api.post("/auth/refresh")
|
||||
const { access_token } = response.data
|
||||
|
||||
isRefreshing = false
|
||||
onRefreshed(access_token)
|
||||
|
||||
// Retry the original request
|
||||
return api(originalRequest)
|
||||
} catch (refreshError: unknown) {
|
||||
isRefreshing = false
|
||||
onRefreshFailed(refreshError)
|
||||
console.error("Reactive refresh failed:", refreshError)
|
||||
|
||||
// Final failure - session is dead
|
||||
handleUnauthorized()
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a 401 on an endpoint we don't/can't refresh (like refresh itself or login)
|
||||
if (error.response?.status === 401) {
|
||||
handleUnauthorized()
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared helper to trigger logout/unauthorized cleanup.
|
||||
*/
|
||||
function handleUnauthorized() {
|
||||
if (onUnauthorized) {
|
||||
onUnauthorized()
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -83,4 +83,19 @@ describe("AuthService", () => {
|
||||
await AuthService.logout()
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/auth/logout")
|
||||
})
|
||||
|
||||
it("successfully refreshes session", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
access_token: "new-fake-token",
|
||||
token_type: "bearer",
|
||||
},
|
||||
}
|
||||
mockedApi.post.mockResolvedValueOnce(mockResponse)
|
||||
|
||||
const result = await AuthService.refreshSession()
|
||||
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/auth/refresh")
|
||||
expect(result.access_token).toBe("new-fake-token")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import api from "./api"
|
||||
import type { Theme } from "@/components/theme-provider"
|
||||
import type { Theme } from "@/components/theme-context"
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string
|
||||
@@ -52,6 +52,11 @@ export const AuthService = {
|
||||
await api.post("/auth/logout")
|
||||
},
|
||||
|
||||
async refreshSession(): Promise<AuthResponse> {
|
||||
const response = await api.post<AuthResponse>("/auth/refresh")
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateTheme(theme: Theme): Promise<UserResponse> {
|
||||
const response = await api.patch<UserResponse>("/auth/theme", { theme })
|
||||
return response.data
|
||||
|
||||
Reference in New Issue
Block a user