Compare commits

..

No commits in common. "main" and "worktree-agent-a751d770" have entirely different histories.

42 changed files with 997 additions and 2743 deletions

View file

@ -1,47 +0,0 @@
{
"permissions": {
"allow": [
"WebFetch(domain:docs.cloud.llamaindex.ai)",
"WebFetch(domain:developers.llamaindex.ai)",
"WebFetch(domain:docs.llamaindex.ai)",
"mcp__context7__resolve-library-id",
"WebFetch(domain:pypi.org)",
"WebFetch(domain:llamaindex.ai)",
"Bash(chmod +x /Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/scripts/*.sh)",
"Bash(git diff:*)",
"Bash(docker volume:*)",
"Bash(bash -n /Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/scripts/1_backup.sh && echo \"1_backup.sh: OK\" && bash -n /Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/scripts/2_deploy.sh && echo \"2_deploy.sh: OK\" && bash -n /Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/scripts/3_cleanup.sh && echo \"3_cleanup.sh: OK\")",
"Bash(bash -n /Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/scripts/2_deploy.sh && echo \"OK\")",
"Bash(cd /Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/frontend && node_modules/.bin/tsc --noEmit 2>&1 | head -60)",
"Bash(WORKTREE=/Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/.claude/worktrees/agent-afd85acc)",
"Bash(MAIN=/Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs)",
"Bash(__NEW_LINE_eabd8745768f7cdf__ diff:*)",
"Read(//Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/.claude/worktrees/agent-afd85acc/$WORKTREE/backend/src/api/routes/**)",
"Read(//Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/.claude/worktrees/agent-afd85acc/$WORKTREE/backend/src/notebookllama/**)",
"Bash(diff /Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/.claude/worktrees/agent-afd85acc/frontend/src/app/notebooks/\\\\[id\\\\]/page.tsx /Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/frontend/src/app/notebooks/\\\\[id\\\\]/page.tsx | head -5)",
"Read(//private/tmp/**)",
"Bash(cp '/Volumes/SSD/Downloads/OLIVER Master PPT Template.pptx' template.pptx)",
"Bash(unzip -o template.pptx -d template_pptx)",
"Bash(cd:*)",
"Bash(git merge:*)",
"Bash(git:*)",
"Bash(WT_PAGES=/Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs/.claude/worktrees/agent-a53b822b\nROOT=/Volumes/SSD/Projects/Oliver/sandbox-notebookllamalm-nextjs\n\ncp \"$WT_PAGES/frontend/src/app/notebooks/[id]/page.tsx\" \"$ROOT/frontend/src/app/notebooks/[id]/page.tsx\"\necho \"Done\")",
"Bash(diff:*)",
"Bash(cd frontend:*)",
"Bash(node_modules/.bin/tsc --noEmit 2>&1 | head -50)",
"Bash(npm run:*)",
"Bash(./node_modules/.bin/next build:*)",
"Bash(git log:*)",
"Bash(pip3 install:*)",
"Bash(ssh optical-web-1:*)",
"Read(//usr/**)",
"Read(//opt/**)",
"Read(//Users/aimpress/**)",
"Bash(pip show:*)",
"WebSearch",
"WebFetch(domain:python-pptx.readthedocs.io)",
"Bash(gh issue:*)",
"Bash(which uv:*)"
]
}
}

View file

@ -1,8 +0,0 @@
.git
.claude
Old Readmes
scripts
*.md
!README.md
.vscode
.DS_Store

View file

@ -47,29 +47,22 @@ frontend/ (Next.js 15 App Router, React 19, TypeScript, Tailwind 4)
**Server:** `optical-web-1` at `/opt/sandbox-notebookllamalm-nextjs`
```bash
# Standard deploy (git pull + build + up + health check)
ssh michael_clervi@optical-web-1
cd /opt/sandbox-notebookllamalm-nextjs
sudo bash scripts/deploy.sh
# Full rebuild and deploy
git pull origin main
docker compose build backend # or 'frontend' or both
docker compose up -d
# Rebuild only backend or frontend
sudo bash scripts/deploy.sh --backend-only
sudo bash scripts/deploy.sh --frontend-only
# Rebuild only backend (faster, no frontend rebuild needed)
docker compose build backend && docker compose up -d backend
# Restart without rebuild (env-only change)
sudo bash scripts/deploy.sh --no-build
# Rollback to previous SHA
sudo bash scripts/rollback.sh abc1234
# Manual: check logs
# Check logs
docker compose logs backend --tail=50
docker compose logs frontend --tail=50
# Run Python in backend container
docker compose exec backend /app/.venv/bin/python -c "..."
# DB migration (auto-runs in deploy.sh; manual fallback)
# DB migration (run when new columns added)
docker compose exec backend /app/.venv/bin/python -c \
"import sys; sys.path.insert(0, '/app/src/notebookllama'); from database import run_studio_migration; run_studio_migration(); print('Done')"
```
@ -79,8 +72,6 @@ docker compose exec backend /app/.venv/bin/python -c \
- Health endpoint: `GET /api/health` (not `/health`)
- Backend uses venv at `/app/.venv/` — always call `/app/.venv/bin/python`
- Frontend env vars (`NEXT_PUBLIC_*`) are baked into the build — frontend rebuild needed if they change
- `deploy.sh` runs DB migration automatically (idempotent)
- Repo is on **Bitbucket**: `git@bitbucket.org:zlalani/sandbox-notebookllamalm-nextjs.git`
---
@ -131,23 +122,14 @@ All results are stored as JSON in `notebooks.studio_data` TEXT column. Download
## AI Models
Model IDs are configurable via env vars in `backend/.env` (no rebuild needed to change them):
| Alias key | Env var | Default model ID | Provider |
|-----------|---------|-----------------|----------|
| `gpt54-exp` | `OPENAI_CHAT_MODEL` | `gpt-5.4-2026-03-05` | OpenAI (default for new notebooks) |
| `claude46-exp` | `ANTHROPIC_CHAT_MODEL` | `claude-sonnet-4-6` | Anthropic |
| `gemini31-exp` | `GEMINI_CHAT_MODEL` | `gemini-3.1-pro-preview` | Google |
| `gemini31-flash` | `GEMINI_FLASH_MODEL` | `gemini-3-flash-preview` | Google (fastest/cheapest) |
| `gpt4o` | _(hardcoded)_ | `gpt-4o` | OpenAI stable |
| `gpt4` | _(hardcoded)_ | `gpt-4` | OpenAI legacy |
Additional env overrides:
- `OPENAI_LEGACY_MODEL` — model used for podcast script + LlamaCloud query helper (defaults to `OPENAI_CHAT_MODEL`)
- `LLM_TIMEOUT_SECONDS` — LLM call timeout in seconds (default: `900`)
- `LLAMA_QUERY_TIMEOUT` — LlamaCloud `aquery` timeout in seconds (default: `120`)
- `CHAT_QUERY_TIMEOUT` — WebSocket chat query timeout in seconds (default: `130`)
- `TTS_TIMEOUT` — ElevenLabs TTS timeout in seconds (default: `300`)
| ID | Provider | Notes |
|----|----------|-------|
| `gpt5-exp` | OpenAI GPT-5 | Default |
| `claude45-exp` | Anthropic Claude 4.5 | |
| `gemini25-exp` | Google Gemini 2.5 Pro | |
| `openai` | GPT-4o | |
| `gemini` | Gemini 2.0 Flash | |
| `gpt4` | GPT-4 | |
---
@ -192,9 +174,3 @@ docker compose up -d postgres redis # starts only DB + cache
| `git pull` fails with unstaged changes | `git stash && git pull` |
| Frontend shows old UI after deploy | Frontend container wasn't rebuilt — run `docker compose build frontend && docker compose up -d frontend` |
| Health check fails in deploy script | Endpoint is `/api/health`, not `/health` |
## Knowledge Wiki
A cross-project knowledge base is maintained automatically from all Claude Code sessions.
- **Index:** `/Users/aimpress/Library/Mobile Documents/iCloud~md~obsidian/Documents/VadymSamoilenko/wiki/index.md`
- **Query:** `cd ~/.claude/memory-compiler && uv run python scripts/query.py "your question"`
- Every session in this project automatically feeds the knowledge base.

View file

@ -107,11 +107,10 @@ docker compose exec backend /app/.venv/bin/python -c \
- **Flashcards** — 15-20 study cards with 3D flip animation
- **Quiz** — 10-12 multiple choice questions with scoring
- **Mind Map** — SVG radial tree visualization
- **Slide Deck** — 8-12 slide presentation with PPTX download; slide diagrams (flowcharts, bar charts) rendered in preview and exported; supports custom .pptx template upload (Claude analyzes and rebuilds it) and per-slide AI editing without regenerating the full deck
- **Slide Deck** — 8-12 slide presentation with PPTX download
- **Report** — executive summary + sections + conclusions with PDF download
- **Infographic** — visual blocks with stats and emojis
- **Data Table** — structured comparison table
- All Studio modules accept a **custom prompt** to guide content generation and style
### Chat
- **Real-time WebSocket Chat** — ask questions across all documents
@ -154,7 +153,7 @@ docker compose exec backend /app/.venv/bin/python -c \
### Tech Stack
**Frontend:** Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS 4, React Query, Zustand, MSAL, WebSocket, CSS custom property theme system (light/dark)
**Frontend:** Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS 4, React Query, Zustand, MSAL, WebSocket
**Backend:** FastAPI, SQLAlchemy, Python 3.13, uv package manager
@ -193,9 +192,6 @@ Full docs: `http://localhost:9000/docs`
- `POST /api/notebooks/{id}/studio/{type}` — generate (flashcards / quiz / mindmap / slides / report / infographic / datatable)
- `GET /api/notebooks/{id}/studio/slides/download` — PPTX file
- `GET /api/notebooks/{id}/studio/report/download` — PDF file
- `GET /api/notebooks/{id}/studio/mindmap/download` — SVG file
- `POST /api/notebooks/{id}/studio/slides/from-template` — generate PPTX from uploaded template (multipart)
- `POST /api/notebooks/{id}/studio/slides/edit/{index}` — regenerate single slide via prompt
**Documents:**
- `POST /api/documents/upload/{notebookId}` — upload file
@ -212,8 +208,6 @@ Full docs: `http://localhost:9000/docs`
- `POST /api/auth/login` — login
- `POST /api/auth/microsoft` — SSO login
> **Note:** All download endpoints require the JWT token via `Authorization: Bearer` header. The frontend uses `fetch()` with the auth header for all binary downloads.
---
## 🔐 Microsoft SSO Setup
@ -333,14 +327,4 @@ sandbox-notebookllamalm-nextjs/
---
---
## 🎨 Theme System
All pages support **light and dark mode**. The toggle is in the top navigation bar (☀ / ☾).
The theme is built on CSS custom properties (`--bg`, `--fg`, `--primary`, `--border`, etc.) defined in `globals.css`. The selected theme is persisted to `localStorage` and applied before hydration to prevent flash.
---
**Version:** 3.1.0 | **Updated:** March 15, 2026 | **Status:** Production
**Version:** 3.0.0 | **Updated:** March 2026 | **Status:** Production

View file

@ -1,4 +0,0 @@
# AI Cost Tracker
COST_TRACKER_BASE_URL=https://optical-dev.oliver.solutions/cost-tracker/v1
COST_TRACKER_API_KEY=<generate at https://optical-dev.oliver.solutions/cost-tracker/ → API Keys → + New Key>
COST_TRACKER_SOURCE_APP=Sandbox-NotebookLM

View file

@ -21,8 +21,4 @@ RUN mkdir -p conversations failed_uploads
EXPOSE 9000
CMD ["uv", "run", "uvicorn", "src.api.main:app", \
"--host", "0.0.0.0", "--port", "9000", \
"--proxy-headers", "--forwarded-allow-ips", "*", \
"--timeout-keep-alive", "65", \
"--ws-ping-interval", "20", "--ws-ping-timeout", "20"]
CMD ["uv", "run", "uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "9000"]

View file

@ -7,8 +7,8 @@ requires-python = ">=3.13"
dependencies = [
"audioop-lts>=0.2.1",
"bcrypt>=4.0.1",
"elevenlabs>=2.44.0",
"fastapi>=0.136.1",
"elevenlabs>=2.5.0",
"fastapi>=0.110.0",
"fastmcp>=2.9.2",
"ffprobe>=0.5",
"llama-cloud>=0.1.29",
@ -16,11 +16,11 @@ dependencies = [
"llama-index-core>=0.12.44",
"llama-index-embeddings-openai>=0.3.1",
"llama-index-indices-managed-llama-cloud>=0.6.11",
"llama-index-llms-anthropic>=0.11.3",
"llama-index-llms-google-genai>=0.9.1",
"llama-index-llms-openai>=0.7.5,<0.8",
"google-genai>=1.73.1",
"anthropic>=0.97.0",
"llama-index-llms-anthropic>=0.3.0",
"llama-index-llms-google-genai>=0.1.0",
"llama-index-llms-openai>=0.4.7",
"google-genai>=1.45.0",
"anthropic>=0.40.0",
"llama-index-observability-otel>=0.1.1",
"llama-index-tools-mcp>=0.2.5",
"llama-index-workflows>=1.0.1",
@ -42,9 +42,9 @@ dependencies = [
"pyvis>=0.3.2",
"sqlalchemy>=2.0.0",
"streamlit>=1.46.1",
"uvicorn[standard]>=0.46.0",
"uvicorn[standard]>=0.27.0",
"websockets>=12.0",
"python-pptx>=0.6.21,<1.0.0",
"python-pptx>=1.0.0",
"weasyprint>=62.0",
"matplotlib>=3.8.0"
]

View file

@ -3,8 +3,6 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
from pydantic import BaseModel
from typing import List, Optional
import asyncio
import os
import sys
from pathlib import Path
import json
@ -74,20 +72,6 @@ async def chat_websocket(websocket: WebSocket, notebook_id: int, session_id: int
await websocket.close()
return
# resolve user email for cost tracking
_ct_user_email = ""
try:
from database import get_db_session, User as _User
_db = get_db_session()
try:
_u = _db.query(_User).filter(_User.id == session.user_id).first()
if _u and _u.email:
_ct_user_email = _u.email
finally:
_db.close()
except Exception as _ex:
print(f"[CT] user lookup failed: {_ex}")
await websocket.send_json({
"type": "connected",
"notebook_id": notebook_id,
@ -98,11 +82,6 @@ async def chat_websocket(websocket: WebSocket, notebook_id: int, session_id: int
while True:
# Receive message from frontend
data = await websocket.receive_json()
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
continue
question = data.get("question", "").strip()
if not question:
@ -118,24 +97,12 @@ async def chat_websocket(websocket: WebSocket, notebook_id: int, session_id: int
# Query pipeline
# Pass notebook_id for metadata filtering (used with shared pipeline)
try:
chat_timeout = int(os.getenv("CHAT_QUERY_TIMEOUT", "130"))
try:
response = await asyncio.wait_for(
query_notebook_pipeline(
notebook.pipeline_id,
question,
notebook.model_type,
notebook_id=notebook_id,
user_external_id=_ct_user_email,
),
timeout=chat_timeout,
)
except asyncio.TimeoutError:
await websocket.send_json({
"type": "error",
"message": f"Request timed out after {chat_timeout}s. Please try again.",
})
continue
response = await query_notebook_pipeline(
notebook.pipeline_id,
question,
notebook.model_type,
notebook_id=notebook_id
)
if not response:
await websocket.send_json({

View file

@ -1,17 +1,16 @@
"""Notebook CRUD routes for FastAPI"""
from fastapi import APIRouter, HTTPException, Query, Depends, UploadFile, File, Form
from fastapi import APIRouter, HTTPException, Query, Depends
from pydantic import BaseModel
from typing import Optional, List
import sys
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
# Add paths to import backend modules
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "notebookllama"))
from cost_tracker import set_user_ctx # noqa: E402
logger = logging.getLogger(__name__)
# Import JWT authentication
sys.path.insert(0, str(Path(__file__).parent.parent))
@ -412,14 +411,12 @@ async def generate_podcast(
final_voice1_id = request.voice1_id or DEFAULT_VOICE1_ID
final_voice2_id = request.voice2_id or DEFAULT_VOICE2_ID
set_user_ctx(current_user.email)
params = {
'target_length': request.target_length,
'custom_theme': request.custom_theme,
'custom_prompt': None,
'voice1_id': final_voice1_id,
'voice2_id': final_voice2_id,
'user_email': current_user.email,
'voice2_id': final_voice2_id
}
# Log podcast request with voice selection details
@ -799,7 +796,6 @@ async def gen_flashcards(notebook_id: int, opts: StudioGenerateRequest = None, c
docs = _get_doc_data(notebook_id)
if not docs:
raise HTTPException(status_code=400, detail="No documents with summaries found")
set_user_ctx(current_user.email)
result = await generate_flashcards(docs, nb.model_type, opts.dict() if opts else None)
data = result.model_dump()
_save_studio_key(notebook_id, "flashcards", data)
@ -815,7 +811,6 @@ async def gen_quiz(notebook_id: int, opts: StudioGenerateRequest = None, current
docs = _get_doc_data(notebook_id)
if not docs:
raise HTTPException(status_code=400, detail="No documents with summaries found")
set_user_ctx(current_user.email)
result = await generate_quiz(docs, nb.model_type, opts.dict() if opts else None)
data = result.model_dump()
_save_studio_key(notebook_id, "quiz", data)
@ -831,7 +826,6 @@ async def gen_mindmap(notebook_id: int, opts: StudioGenerateRequest = None, curr
docs = _get_doc_data(notebook_id)
if not docs:
raise HTTPException(status_code=400, detail="No documents with summaries found")
set_user_ctx(current_user.email)
result = await generate_mindmap(docs, nb.model_type, opts.dict() if opts else None)
data = result.model_dump()
_save_studio_key(notebook_id, "mindmap", data)
@ -847,7 +841,6 @@ async def gen_slides(notebook_id: int, opts: StudioGenerateRequest = None, curre
docs = _get_doc_data(notebook_id)
if not docs:
raise HTTPException(status_code=400, detail="No documents with summaries found")
set_user_ctx(current_user.email)
result = await generate_slides(docs, nb.model_type, opts.dict() if opts else None)
data = result.model_dump()
_save_studio_key(notebook_id, "slides", data)
@ -863,7 +856,6 @@ async def gen_report(notebook_id: int, opts: StudioGenerateRequest = None, curre
docs = _get_doc_data(notebook_id)
if not docs:
raise HTTPException(status_code=400, detail="No documents with summaries found")
set_user_ctx(current_user.email)
result = await generate_report(docs, nb.model_type, opts.dict() if opts else None)
data = result.model_dump()
_save_studio_key(notebook_id, "report", data)
@ -879,7 +871,6 @@ async def gen_infographic(notebook_id: int, opts: StudioGenerateRequest = None,
docs = _get_doc_data(notebook_id)
if not docs:
raise HTTPException(status_code=400, detail="No documents with summaries found")
set_user_ctx(current_user.email)
result = await generate_infographic(docs, nb.model_type, opts.dict() if opts else None)
data = result.model_dump()
_save_studio_key(notebook_id, "infographic", data)
@ -895,7 +886,6 @@ async def gen_datatable(notebook_id: int, opts: StudioGenerateRequest = None, cu
docs = _get_doc_data(notebook_id)
if not docs:
raise HTTPException(status_code=400, detail="No documents with summaries found")
set_user_ctx(current_user.email)
result = await generate_datatable(docs, nb.model_type, opts.dict() if opts else None)
data = result.model_dump()
_save_studio_key(notebook_id, "datatable", data)
@ -946,133 +936,3 @@ async def download_mindmap(notebook_id: int, current_user: User = Depends(get_cu
headers={"Content-Disposition": f"attachment; filename=notebook_{notebook_id}_mindmap.svg"}
)
class SlideEditRequest(BaseModel):
custom_prompt: str
@router.post("/{notebook_id}/studio/slides/from-template")
async def gen_slides_from_template(
notebook_id: int,
template: UploadFile = File(...),
custom_prompt: str = Form(""),
current_user: User = Depends(get_current_user),
):
"""Generate a PPTX presentation using a user-uploaded template analyzed by Claude."""
import tempfile, os
from fastapi.responses import Response
from studio_generators import generate_pptx_from_template
nb = get_notebook_by_id(notebook_id)
if not nb:
raise HTTPException(status_code=404, detail="Notebook not found")
docs = _get_doc_data(notebook_id)
if not docs:
raise HTTPException(status_code=400, detail="No documents with summaries found")
# Save uploaded template to a temp file
raw_suffix = os.path.splitext(template.filename or "")[1].lower()
if raw_suffix == ".ppt":
raise HTTPException(
status_code=400,
detail="Legacy .ppt format is not supported. Please convert to .pptx first."
)
suffix = raw_suffix if raw_suffix else ".pptx"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_in:
tmp_in.write(await template.read())
template_path = tmp_in.name
with tempfile.NamedTemporaryFile(delete=False, suffix=".pptx") as tmp_out:
output_path = tmp_out.name
try:
await generate_pptx_from_template(
template_path=template_path,
doc_summaries=docs,
model_type=nb.model_type,
custom_prompt=custom_prompt or "",
output_path=output_path,
)
with open(output_path, "rb") as f:
pptx_bytes = f.read()
except Exception as exc:
import traceback as _tb
print(f"[from-template] ERROR: {_tb.format_exc()}", flush=True)
raise HTTPException(status_code=422, detail=str(exc))
finally:
for p in (template_path, output_path):
try:
os.unlink(p)
except Exception:
pass
# Store base64 of the PPTX so the user can re-download later
import base64
from studio_generators import extract_slides_json_from_pptx
_save_studio_key(notebook_id, "slides_template_pptx", {"data": base64.b64encode(pptx_bytes).decode()})
# Extract slide content as JSON for in-browser preview via SlidesView
try:
slides_json = extract_slides_json_from_pptx(pptx_bytes)
except Exception:
slides_json = {"presentation_title": "Presentation from template", "subtitle": "", "slides": []}
_save_studio_key(notebook_id, "slides", slides_json)
return {"slides": slides_json, "is_template": True}
@router.get("/{notebook_id}/studio/slides/template/download")
async def download_template_slides(notebook_id: int, current_user: User = Depends(get_current_user)):
"""Download the PPTX generated from a user template."""
from fastapi.responses import Response
import base64
studio = _get_studio_data(notebook_id)
entry = studio.get("slides_template_pptx")
if not entry or "data" not in entry:
raise HTTPException(status_code=404, detail="No template presentation found")
pptx_bytes = base64.b64decode(entry["data"])
return Response(
content=pptx_bytes,
media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
headers={"Content-Disposition": f"attachment; filename=notebook_{notebook_id}_from_template.pptx"},
)
@router.post("/{notebook_id}/studio/slides/edit/{slide_index}")
async def edit_single_slide(
notebook_id: int,
slide_index: int,
req: SlideEditRequest,
current_user: User = Depends(get_current_user),
):
"""Regenerate a single slide (0-based index) using an AI prompt without rebuilding the whole deck."""
from studio_generators import regenerate_single_slide
nb = get_notebook_by_id(notebook_id)
if not nb:
raise HTTPException(status_code=404, detail="Notebook not found")
studio = _get_studio_data(notebook_id)
if "slides" not in studio or "slides" not in studio["slides"]:
raise HTTPException(status_code=404, detail="Slides not generated yet")
slides = studio["slides"]["slides"]
if slide_index < 0 or slide_index >= len(slides):
raise HTTPException(status_code=400, detail=f"Slide index {slide_index} out of range (0{len(slides)-1})")
docs = _get_doc_data(notebook_id)
updated_slide = await regenerate_single_slide(
current_slide=slides[slide_index],
slide_index=slide_index,
total_slides=len(slides),
doc_summaries=docs or [],
model_type=nb.model_type,
custom_prompt=req.custom_prompt,
)
slides[slide_index] = updated_slide
studio["slides"]["slides"] = slides
_save_studio_key(notebook_id, "slides", studio["slides"])
return updated_slide

View file

@ -86,9 +86,6 @@ class PodcastGenerator(BaseModel):
)
]
)
from cost_tracker import record, get_user_ctx, model_id_for, extract_llama_tokens
inp, out = extract_llama_tokens(response)
await record(model=model_id_for("podcast"), user_external_id=get_user_ctx(), input_tokens=inp, output_tokens=out)
return MultiTurnConversation.model_validate_json(response.message.content)
@log_api_call("ELEVENLABS", "generate_audio")
@ -104,10 +101,6 @@ class PodcastGenerator(BaseModel):
logger.info(f"🔊 [TTS] Starting audio generation for {len(conversation.conversation)} segments")
logger.info(f"🔊 [TTS] Voice assignment: speaker1={voice1_name} (id={voice1_id}), speaker2={voice2_name} (id={voice2_id})")
from cost_tracker import record, get_user_ctx
total_chars = sum(len(t.content) for t in conversation.conversation)
await record(model="eleven_turbo_v2_5", user_external_id=get_user_ctx(), chars=total_chars)
files: List[str] = []
for i, turn in enumerate(conversation.conversation):
if turn.speaker == "speaker1":
@ -171,9 +164,8 @@ class PodcastGenerator(BaseModel):
load_dotenv()
if os.getenv("ELEVENLABS_API_KEY", None) and os.getenv("OPENAI_API_KEY", None):
from llm_factory import OPENAI_LEGACY_MODEL
SLLM = OpenAIResponses(
model=OPENAI_LEGACY_MODEL, api_key=os.getenv("OPENAI_API_KEY")
model="gpt-4.1", api_key=os.getenv("OPENAI_API_KEY")
).as_structured_llm(MultiTurnConversation)
EL_CLIENT = AsyncElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY"))
PODCAST_GEN = PodcastGenerator(llm=SLLM, client=EL_CLIENT)

View file

@ -621,13 +621,13 @@ async def execute_document_processing_task(task_id: int):
)
extract_duration = time.time() - extract_start
if extraction_output and extraction_output.data:
if extraction_output:
notebook_data = extraction_output.data
logger.info(f" ✓ [LLAMAEXTRACT] aextract → Success ({extract_duration:.1f}s)")
else:
logger.warning(f" [LLAMAEXTRACT] aextract → No data returned ({extract_duration:.1f}s), falling back to LLM extraction")
from llm_extraction import extract_with_llm
notebook_data = await extract_with_llm(text, original_filename, notebook.model_type)
logger.error(f" [LLAMAEXTRACT] aextract → No data returned ({extract_duration:.1f}s)")
update_task_status(task_id, TaskStatus.FAILED, error="LlamaExtract failed")
return
except (httpx.RemoteProtocolError, httpx.ReadTimeout, httpx.ConnectError) as e:
# Network errors during extraction - provide helpful error message
logger.error(f"✗ Network error during extraction: {e}")
@ -700,8 +700,6 @@ async def execute_podcast_task(task_id: int):
return
params = json.loads(task.parameters) if task.parameters else {}
from cost_tracker import set_user_ctx
set_user_ctx(params.get('user_email', str(task.user_id)))
notebook_id = task.notebook_id
target_length = params.get('target_length', 10)
custom_theme = params.get('custom_theme')
@ -897,20 +895,9 @@ def get_notebook_processing_tasks(notebook_id: int) -> list:
def retry_pending_tasks():
"""On startup: mark orphaned IN_PROGRESS tasks as FAILED, then retry PENDING ones."""
"""Retry all PENDING document processing tasks on startup"""
db = get_db_session()
try:
# Tasks left IN_PROGRESS from a previous crash will never complete — mark them failed.
stuck = db.query(BackgroundTask).filter(
BackgroundTask.status == TaskStatus.IN_PROGRESS
).all()
if stuck:
print(f"Found {len(stuck)} orphaned IN_PROGRESS task(s) from previous run — marking FAILED")
for task in stuck:
task.status = TaskStatus.FAILED
task.error_message = "orphaned on restart"
db.commit()
pending_tasks = db.query(BackgroundTask).filter(
BackgroundTask.task_type == 'document_processing',
BackgroundTask.status == TaskStatus.PENDING

View file

@ -1,154 +0,0 @@
"""Lightweight AI Cost Tracker integration — fail-open, fire-and-forget."""
import os
import logging
from contextvars import ContextVar
from typing import Optional, Tuple
import httpx
logger = logging.getLogger(__name__)
_BASE = os.environ.get("COST_TRACKER_BASE_URL", "").rstrip("/")
_KEY = os.environ.get("COST_TRACKER_API_KEY", "")
_APP = os.environ.get("COST_TRACKER_SOURCE_APP", "")
_HEADERS = {"X-API-Key": _KEY} if _KEY else {}
# Per-async-task user context — set in routes/background tasks, read at AI call sites
_user_ctx: ContextVar[str] = ContextVar("ct_user_email", default="")
def _enabled() -> bool:
return bool(_BASE and _KEY and _APP)
def set_user_ctx(email: str) -> None:
_user_ctx.set(email)
def get_user_ctx() -> str:
return _user_ctx.get()
def model_id_for(model_type: str) -> str:
"""Map llm_factory model_type alias to the actual model ID string."""
try:
from llm_factory import (
OPENAI_CHAT_MODEL, ANTHROPIC_CHAT_MODEL,
GEMINI_CHAT_MODEL, GEMINI_FLASH_MODEL, OPENAI_LEGACY_MODEL,
)
return {
"gpt54-exp": OPENAI_CHAT_MODEL,
"claude46-exp": ANTHROPIC_CHAT_MODEL,
"gemini31-exp": GEMINI_CHAT_MODEL,
"gemini31-flash": GEMINI_FLASH_MODEL,
"gpt4o": "gpt-4o",
"gpt4": "gpt-4",
"openai": "gpt-4",
"podcast": OPENAI_LEGACY_MODEL,
}.get(model_type, model_type)
except Exception:
return model_type
def extract_llama_tokens(response) -> Tuple[int, int]:
"""Extract (input_tokens, output_tokens) from a LlamaIndex ChatResponse."""
try:
raw = getattr(response, "raw", None)
if raw is None:
return 0, 0
if isinstance(raw, dict):
usage = raw.get("usage") or {}
return (
int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0),
int(usage.get("completion_tokens") or usage.get("output_tokens") or 0),
)
u = getattr(raw, "usage", None)
if u:
inp = int(getattr(u, "prompt_tokens", 0) or getattr(u, "input_tokens", 0) or 0)
out = int(getattr(u, "completion_tokens", 0) or getattr(u, "output_tokens", 0) or 0)
return inp, out
except Exception:
pass
return 0, 0
async def preflight(
model: str,
user_external_id: str,
project_id: Optional[str] = None,
) -> bool:
"""Check budget before an AI call. Always returns True on error (fail-open)."""
if not _enabled() or not user_external_id:
return True
try:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{_BASE}/preflight",
headers=_HEADERS,
json={
"source_app": _APP,
"model": model,
"user_external_id": user_external_id,
**({"project_id": project_id} if project_id else {}),
},
timeout=3.0,
)
return r.json().get("allow", True)
except Exception as exc:
logger.warning("cost_tracker preflight error (allowing): %s", exc)
return True
async def record(
model: str,
user_external_id: str,
input_tokens: int = 0,
output_tokens: int = 0,
chars: int = 0,
project_id: Optional[str] = None,
metadata: Optional[dict] = None,
) -> None:
"""Record AI usage after a call. Fire-and-forget — errors never propagate."""
if not _enabled() or not user_external_id:
return
units: dict = {}
if input_tokens: units["token_input"] = input_tokens
if output_tokens: units["token_output"] = output_tokens
if chars: units["char"] = chars
if not units:
return
try:
async with httpx.AsyncClient() as client:
await client.post(
f"{_BASE}/usage/record",
headers=_HEADERS,
json={
"model": model,
"user_external_id": user_external_id,
"units": units,
**({"project_external_id": project_id} if project_id else {}),
**({"metadata": metadata} if metadata else {}),
},
timeout=5.0,
)
except Exception as exc:
logger.warning("cost_tracker record error (ignoring): %s", exc)
def upsert_user(
user_external_id: str,
email: Optional[str] = None,
full_name: Optional[str] = None,
role: Optional[str] = None,
) -> None:
"""Enrich user record with profile data. Call once on login. Sync, non-fatal."""
if not _enabled():
return
try:
payload: dict = {"external_id": user_external_id}
if email: payload["email"] = email
if full_name: payload["full_name"] = full_name
if role: payload["role"] = role
httpx.post(f"{_BASE}/users/upsert", headers=_HEADERS, json=payload, timeout=3.0)
except Exception as exc:
logger.warning("cost_tracker upsert_user error (ignoring): %s", exc)

View file

@ -180,14 +180,13 @@ Format your response in clear, detailed markdown with headers. Be thorough - thi
print(f" 📦 Contents structure: {prompt_parts[0].keys()} | {prompt_parts[1].keys()}")
# Use Gemini 3.1 Pro Preview for analysis (new SDK)
from llm_factory import GEMINI_CHAT_MODEL
print(f" 🔌 [GEMINI] models.generate_content(model={GEMINI_CHAT_MODEL}, file={uploaded_file.name})")
# Use Gemini 2.5 Pro for analysis (new SDK)
print(f" 🔌 [GEMINI] models.generate_content(model=gemini-2.5-pro, file={uploaded_file.name})")
analysis_start = time.time()
try:
response = client.models.generate_content(
model=GEMINI_CHAT_MODEL,
model='gemini-2.5-pro',
contents=prompt_parts
)
analysis_duration = time.time() - analysis_start

View file

@ -5,103 +5,115 @@ Supports per-notebook model selection
import os
from dotenv import load_dotenv
from typing import Type
from typing import Optional, Type
from pydantic import BaseModel
load_dotenv()
# tiktoken doesn't know gpt-5.4 model names yet — register them before any tokenization call
def _register_tiktoken_models() -> None:
try:
import tiktoken.model as _tm
for _m in ("gpt-5.4-2026-03-05", "gpt-5.4", "gpt-5", "gpt-5-2025-08-07"):
if _m not in _tm.MODEL_TO_ENCODING:
_tm.MODEL_TO_ENCODING[_m] = "o200k_base"
except Exception:
pass
_register_tiktoken_models()
# Model IDs — override any of these via env vars without rebuilding the Docker image
OPENAI_CHAT_MODEL = os.getenv("OPENAI_CHAT_MODEL", "gpt-5.4-2026-03-05")
ANTHROPIC_CHAT_MODEL = os.getenv("ANTHROPIC_CHAT_MODEL", "claude-sonnet-4-6")
GEMINI_CHAT_MODEL = os.getenv("GEMINI_CHAT_MODEL", "gemini-3.1-pro-preview")
GEMINI_FLASH_MODEL = os.getenv("GEMINI_FLASH_MODEL", "gemini-3-flash-preview")
# Legacy model for audio/utils (podcast script generator, LlamaCloud query helper)
OPENAI_LEGACY_MODEL = os.getenv("OPENAI_LEGACY_MODEL", OPENAI_CHAT_MODEL)
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT_SECONDS", "900"))
def get_llm_by_type(model_type: str = 'gpt4o'):
"""
Get LLM instance based on model type.
Get LLM instance based on model type
Args:
model_type: 'gpt54-exp', 'gpt4o', 'gpt4', 'claude46-exp', 'gemini31-exp', 'gemini31-flash'
model_type: 'gpt5', 'gpt4o', 'gpt4', 'claude45', 'claude4', 'gemini25', 'gemini', etc.
Returns:
LLM instance
"""
if model_type == 'gpt54-exp':
# Newest experimental models (may not work if LlamaIndex not updated)
if model_type == 'gpt51-exp':
from llama_index.llms.openai import OpenAI
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY not found in environment")
return OpenAI(model=OPENAI_CHAT_MODEL, api_key=api_key, temperature=0.7, timeout=LLM_TIMEOUT)
# Note: gpt-5.1 not yet in llama-index, using gpt-5 instead
return OpenAI(model="gpt-5", api_key=api_key, temperature=0.7, timeout=900.0)
elif model_type == 'claude46-exp':
elif model_type == 'claude45-exp':
from llama_index.llms.anthropic import Anthropic
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
raise ValueError("ANTHROPIC_API_KEY not found in environment")
return Anthropic(
model=ANTHROPIC_CHAT_MODEL,
model="claude-sonnet-4-5-20250929",
api_key=api_key,
temperature=0.7,
max_tokens=8192, # Increase to 8K to prevent truncation
timeout=900.0 # 15 minute timeout
)
elif model_type == 'gemini25-exp':
from llama_index.llms.google_genai import GoogleGenAI
api_key = os.getenv("GOOGLE_API_KEY")
if not api_key:
raise ValueError("GOOGLE_API_KEY not found in environment")
return GoogleGenAI(model="gemini-3-pro-preview", api_key=api_key, temperature=0.7)
# Stable/working models
elif model_type == 'gemini':
from llama_index.llms.google_genai import GoogleGenAI
api_key = os.getenv("GOOGLE_API_KEY")
if not api_key:
raise ValueError("GOOGLE_API_KEY not found in environment")
return GoogleGenAI(
model="gemini-2.5-flash",
api_key=api_key,
temperature=0.7,
)
elif model_type == 'claude':
from llama_index.llms.anthropic import Anthropic
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
raise ValueError("ANTHROPIC_API_KEY not found in environment")
return Anthropic(
model="claude-sonnet-4-20250514",
api_key=api_key,
temperature=0.7,
max_tokens=8192,
timeout=LLM_TIMEOUT,
)
elif model_type == 'gemini31-exp':
from llama_index.llms.google_genai import GoogleGenAI
api_key = os.getenv("GOOGLE_API_KEY")
if not api_key:
raise ValueError("GOOGLE_API_KEY not found in environment")
return GoogleGenAI(model=GEMINI_CHAT_MODEL, api_key=api_key, temperature=0.7, timeout=LLM_TIMEOUT)
elif model_type == 'gemini31-flash':
from llama_index.llms.google_genai import GoogleGenAI
api_key = os.getenv("GOOGLE_API_KEY")
if not api_key:
raise ValueError("GOOGLE_API_KEY not found in environment")
return GoogleGenAI(
model=GEMINI_FLASH_MODEL,
api_key=api_key,
temperature=0.7,
timeout=LLM_TIMEOUT,
timeout=900.0
)
elif model_type == 'gpt4o':
from llama_index.llms.openai import OpenAI
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY not found in environment")
return OpenAI(model="gpt-4o", api_key=api_key, temperature=0.7, timeout=LLM_TIMEOUT)
else: # gpt4 / openai (legacy)
from llama_index.llms.openai import OpenAI
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY not found in environment")
return OpenAI(model="gpt-4", api_key=api_key, temperature=0.7, timeout=LLM_TIMEOUT)
return OpenAI(
model="gpt-4o",
api_key=api_key,
temperature=0.7,
timeout=900.0
)
else: # gpt4 (legacy)
from llama_index.llms.openai import OpenAI
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY not found in environment")
return OpenAI(
model="gpt-4",
api_key=api_key,
temperature=0.7,
timeout=900.0
)
def get_structured_llm(model_type: str, output_class: Type[BaseModel]):
"""
Get structured LLM for pydantic output.
Get structured LLM for pydantic output
Args:
model_type: model alias string
model_type: 'openai' or 'gemini'
output_class: Pydantic model class for structured output
Returns:
@ -114,40 +126,47 @@ def get_structured_llm(model_type: str, output_class: Type[BaseModel]):
def get_model_display_name(model_type: str) -> str:
"""Get user-friendly model name"""
names = {
'gpt54-exp': f'GPT-5.4',
'claude46-exp': 'Claude Sonnet 4.5',
'gemini31-exp': 'Gemini 3.1 Pro Preview',
# Experimental (may not work)
'gpt51-exp': 'GPT-5.1 (Experimental)',
'claude45-exp': 'Claude Sonnet 4.5 (Experimental)',
'gemini25-exp': 'Gemini 3 Pro Preview (Experimental)',
# Stable
'gpt4o': 'OpenAI GPT-4o',
'gpt4': 'OpenAI GPT-4',
'gemini31-flash': 'Gemini 3 Flash',
'openai': 'OpenAI GPT-4', # legacy
'claude': 'Claude Sonnet 4.0',
'gemini': 'Google Gemini 2.5 Flash',
'openai': 'OpenAI GPT-4' # Legacy
}
return names.get(model_type, model_type)
return names.get(model_type, 'Unknown Model')
def get_model_emoji(model_type: str) -> str:
"""Get emoji for model type"""
emojis = {
'gpt54-exp': '🚀',
'claude46-exp': '🧠',
'gemini31-exp': '💎',
'gpt51-exp': '🚀',
'claude45-exp': '🧠',
'gemini25-exp': '💎',
'gpt4o': '',
'gpt4': '🤖',
'gemini31-flash': '',
'openai': '🤖',
'claude': '🧠',
'gemini': '',
'openai': '🤖'
}
return emojis.get(model_type, '🤖')
# Cost estimates per 1M tokens (approximate)
# Cost estimates per 1M tokens
MODEL_COSTS = {
'gpt54-exp': {'input': 1.25, 'output': 10.0, 'description': f'GPT-5.4 — Latest OpenAI ({OPENAI_CHAT_MODEL})'},
'claude46-exp': {'input': 3.0, 'output': 15.0, 'description': f'Claude Sonnet 4.6 — Anthropic ({ANTHROPIC_CHAT_MODEL})'},
'gemini31-exp': {'input': 1.25, 'output': 10.0, 'description': f'Gemini 3.1 Pro — Google ({GEMINI_CHAT_MODEL})'},
'gpt4o': {'input': 5.0, 'output': 15.0, 'description': 'GPT-4o — OpenAI stable'},
'gpt4': {'input': 30.0, 'output': 60.0, 'description': 'GPT-4 — Legacy'},
'gemini31-flash': {'input': 0.075, 'output': 0.30, 'description': f'Gemini 3 Flash — Google fast ({GEMINI_FLASH_MODEL})'},
'openai': {'input': 30.0, 'output': 60.0, 'description': 'GPT-4 — Legacy'},
# Experimental
'gpt51-exp': {'input': 1.25, 'output': 10.0, 'description': 'GPT-5.1 (Experimental - may not work)'},
'claude45-exp': {'input': 3.0, 'output': 15.0, 'description': 'Claude 4.5 (Experimental - may not work)'},
'gemini25-exp': {'input': 1.25, 'output': 10.0, 'description': 'Gemini 3 Pro Preview (Experimental - may not work)'},
# Stable
'gpt4o': {'input': 5.0, 'output': 15.0, 'description': 'GPT-4o - Latest stable from OpenAI'},
'gpt4': {'input': 30.0, 'output': 60.0, 'description': 'GPT-4 - Original'},
'claude': {'input': 3.0, 'output': 15.0, 'description': 'Claude Sonnet 4.0 - Stable'},
'gemini': {'input': 0.15, 'output': 0.60, 'description': 'Gemini 2.5 Flash - Stable'},
'openai': {'input': 30.0, 'output': 60.0, 'description': 'GPT-4 - Legacy'}
}

View file

@ -101,11 +101,8 @@ Focus on what's revealed when these documents are considered as a collection, no
]
try:
from cost_tracker import record, get_user_ctx, model_id_for, extract_llama_tokens
llm_synthesis = get_structured_llm(model_type, NotebookSynthesis)
response = await llm_synthesis.achat(messages=messages)
inp, out = extract_llama_tokens(response)
await record(model=model_id_for(model_type), user_external_id=get_user_ctx(), input_tokens=inp, output_tokens=out)
# Handle different response types from different models
content = response.message.content
@ -187,7 +184,7 @@ Provide a clear structure with introduction, main topics, discussion points, and
try:
# For Claude/Gemini, use regular LLM with JSON instructions
# as_structured_llm has bugs with these models
if model_type in ['claude46-exp', 'gemini31-exp', 'gemini31-flash']:
if model_type in ['claude45-exp', 'claude', 'gemini25-exp', 'gemini']:
llm = get_llm_by_type(model_type)
# Modify the last message to explicitly request JSON
@ -211,9 +208,6 @@ Return ONLY the JSON, no additional text."""
))
response = await llm.achat(messages=json_messages)
from cost_tracker import record, get_user_ctx, model_id_for, extract_llama_tokens
inp, out = extract_llama_tokens(response)
await record(model=model_id_for(model_type), user_external_id=get_user_ctx(), input_tokens=inp, output_tokens=out)
content = response.message.content
print(f"Raw response from {model_type}: {content[:500]}")
@ -238,9 +232,6 @@ Return ONLY the JSON, no additional text."""
# OpenAI works with as_structured_llm
llm_podcast = get_structured_llm(model_type, PodcastOutline)
response = await llm_podcast.achat(messages=messages)
from cost_tracker import record, get_user_ctx, model_id_for, extract_llama_tokens
inp, out = extract_llama_tokens(response)
await record(model=model_id_for(model_type), user_external_id=get_user_ctx(), input_tokens=inp, output_tokens=out)
outline = PodcastOutline.model_validate_json(response.message.content)
outline.target_length_minutes = target_length
@ -318,9 +309,6 @@ Format as a natural conversation with approximately {target_turns} speaking turn
try:
llm = get_llm_by_type(model_type)
response = await llm.achat(messages=messages)
from cost_tracker import record, get_user_ctx, model_id_for, extract_llama_tokens
inp, out = extract_llama_tokens(response)
await record(model=model_id_for(model_type), user_external_id=get_user_ctx(), input_tokens=inp, output_tokens=out)
script = response.message.content
print(f" ✓ Script generated: {len(script)} chars (~{target_turns} turns)")
return script

View file

@ -96,28 +96,30 @@ if st.session_state.get("creating_notebook"):
st.markdown("### AI Model Selection")
model_options = {
'gpt54-exp': '🚀 GPT-5.4',
'claude46-exp': '🧠 Claude Sonnet 4.6',
'gemini31-exp': '💎 Gemini 3.1 Pro Preview',
'gpt5-exp': '🚀 GPT-5',
'claude45-exp': '🧠 Claude Sonnet 4.5',
'gemini25-exp': '💎 Gemini 2.5 Pro',
'gpt4o': '⚡ GPT-4o',
'gemini31-flash': '✨ Gemini 3.1 Flash',
'gemini': '✨ Gemini 2.0 Flash',
'gpt4': '🤖 GPT-4'
}
model_choice = st.selectbox(
"Choose AI Model:",
options=list(model_options.keys()),
format_func=lambda x: model_options[x],
index=0, # Default to GPT-5.4 (newest)
help="GPT-5.4 and Claude Sonnet 4.6 are the latest models (2025). All tested and working."
index=0, # Default to GPT-5 (newest and confirmed working!)
help="GPT-5 and Claude 4.5 are the latest models (2025). All tested and working."
)
# Show pricing
costs = {
'gpt54-exp': '$1.25 input, $10 output per 1M tokens',
'claude46-exp': '$3 input, $15 output per 1M tokens',
'gemini31-exp': '$1.25 input, $10 output per 1M tokens',
'gpt5-exp': '$1.25 input, $10 output per 1M tokens',
'claude45-exp': '$3 input, $15 output per 1M tokens',
'gemini25-exp': '$1.25 input, $5 output per 1M tokens',
'gpt4o': '$5 input, $15 output per 1M tokens',
'gemini31-flash': '$0.15 input, $0.60 output per 1M tokens (cheapest!)',
'gemini': '$0.075 input, $0.30 output per 1M tokens (cheapest!)',
'gpt4': '$30 input, $60 output per 1M tokens'
}
st.caption(f"💰 {costs[model_choice]}")

View file

@ -12,7 +12,6 @@ enabling efficient use of LlamaCloud's pipeline limits while maintaining isolati
from llama_cloud.client import AsyncLlamaCloud
from llama_index.indices.managed.llama_cloud import LlamaCloudIndex
from llm_factory import get_llm_by_type
import asyncio
import os
from dotenv import load_dotenv
from typing import Optional, List
@ -462,7 +461,7 @@ def get_notebook_query_engine(
Args:
pipeline_id: The notebook's pipeline ID
model_type: 'gpt54-exp', 'gpt4o', 'claude46-exp', 'gemini31-flash', etc.
model_type: 'gpt5-exp', 'gpt4o', 'claude', 'gemini', etc.
notebook_id: Optional notebook ID for metadata filtering (used with shared pipeline)
Returns:
@ -517,8 +516,7 @@ async def query_notebook_pipeline(
pipeline_id: str,
question: str,
model_type: str = 'gpt4o',
notebook_id: Optional[int] = None,
user_external_id: Optional[str] = None,
notebook_id: Optional[int] = None
) -> Optional[str]:
"""
Query a specific notebook's pipeline
@ -539,18 +537,7 @@ async def query_notebook_pipeline(
print("Error: No pipeline_id provided")
return "Error: Notebook has no pipeline configured."
# Resolve shared-pipeline status via async lookup so the cache is always
# populated even after a server restart (is_shared_pipeline() can return
# False spuriously when _shared_pipeline_id is None).
resolved_notebook_id: Optional[int] = None
if notebook_id is not None:
if await is_shared_pipeline_async(pipeline_id):
resolved_notebook_id = notebook_id
logger.info(f"Confirmed shared pipeline — filtering by notebook_id={notebook_id}")
else:
logger.info(f"Legacy dedicated pipeline {pipeline_id} — no metadata filter needed")
query_engine = get_notebook_query_engine(pipeline_id, model_type, resolved_notebook_id)
query_engine = get_notebook_query_engine(pipeline_id, model_type, notebook_id)
if not query_engine:
print(f"Error: Could not create query engine for pipeline {pipeline_id}")
@ -558,17 +545,7 @@ async def query_notebook_pipeline(
print(f"Querying pipeline {pipeline_id} with {model_type} model, question: {question[:50]}...")
augmented_question = (
"IMPORTANT: You MUST respond in the same language as the user's question below. "
"Do not switch to any other language regardless of the document language.\n\n"
f"Question: {question}"
)
query_timeout = int(os.getenv("LLAMA_QUERY_TIMEOUT", "120"))
try:
response = await asyncio.wait_for(query_engine.aquery(augmented_question), timeout=query_timeout)
except asyncio.TimeoutError:
return f"Error: Query timed out after {query_timeout}s. The document index may be slow or overloaded — please try again."
response = await query_engine.aquery(question)
print(f"Response object type: {type(response)}")
print(f"Response has .response attr: {hasattr(response, 'response')}")
@ -576,26 +553,6 @@ async def query_notebook_pipeline(
print(f"Response.response value: {response.response[:100] if response.response else 'None or empty'}")
print(f"Response has source_nodes: {hasattr(response, 'source_nodes')}")
# cost tracking
try:
from cost_tracker import record, model_id_for, extract_llama_tokens
if user_external_id:
_inp, _out = extract_llama_tokens(response)
if not _inp and not _out:
_inp = len(augmented_question) // 4
_out = len(response.response or "") // 4
print(f"[CT] recording model={model_id_for(model_type)} user={user_external_id} inp={_inp} out={_out}")
import asyncio as _asyncio
_asyncio.ensure_future(record(
model=model_id_for(model_type),
user_external_id=user_external_id,
input_tokens=_inp,
output_tokens=_out,
metadata={"notebook_id": notebook_id},
))
except Exception as _e:
print(f"cost_tracker chat record error (ignored): {_e}")
if not response or not response.response:
print("WARNING: Response is empty or None!")
return "Sorry, I couldn't find an answer to your question."

View file

@ -149,7 +149,7 @@ async def _generate(doc_summaries, model_type, system_msg, user_msg, output_clas
ChatMessage(role="system", content=system_msg),
ChatMessage(role="user", content=user_msg),
]
if model_type in ('claude46-exp', 'gemini31-exp', 'gemini31-flash'):
if model_type in ('claude45-exp', 'claude', 'gemini25-exp', 'gemini'):
llm = get_llm_by_type(model_type)
schema = json.dumps(output_class.model_json_schema(), indent=2)
messages[-1].content += f"\n\nRespond ONLY with valid JSON matching this schema:\n{schema}"
@ -157,9 +157,6 @@ async def _generate(doc_summaries, model_type, system_msg, user_msg, output_clas
else:
llm = get_structured_llm(model_type, output_class)
response = await llm.achat(messages=messages)
from cost_tracker import record, get_user_ctx, model_id_for, extract_llama_tokens
inp, out = extract_llama_tokens(response)
await record(model=model_id_for(model_type), user_external_id=get_user_ctx(), input_tokens=inp, output_tokens=out)
return _parse_response(response, output_class)
@ -223,403 +220,6 @@ async def generate_slides(doc_summaries, model_type='openai', options: dict = No
SlideDeckOutput)
def analyze_pptx_template(template_path: str) -> dict:
"""Extract structure from a PPTX template for Claude to use."""
from pptx import Presentation as PRS
prs = PRS(template_path)
layouts = []
for i, layout in enumerate(prs.slide_layouts):
if i >= 10:
break
placeholders = []
for ph in layout.placeholders:
try:
placeholders.append({
"idx": ph.placeholder_format.idx,
"name": ph.name,
"type": str(ph.placeholder_format.type).split(".")[-1],
"left_in": round(ph.left / 914400, 2),
"top_in": round(ph.top / 914400, 2),
"width_in": round(ph.width / 914400, 2),
"height_in": round(ph.height / 914400, 2),
})
except Exception:
pass
layouts.append({"index": i, "name": layout.name, "placeholders": placeholders})
# Sample first slide colors/fonts
sample_shapes = []
slides_iter = iter(prs.slides)
first_slide = next(slides_iter, None)
if first_slide is not None:
shapes_limited = []
for j, shape in enumerate(first_slide.shapes):
if j >= 6:
break
shapes_limited.append(shape)
for shape in shapes_limited:
info = {"name": shape.name}
if shape.has_text_frame and shape.text_frame.paragraphs:
para = shape.text_frame.paragraphs[0]
if para.runs:
run = para.runs[0]
try:
info["font_color"] = str(run.font.color.rgb)
except Exception:
pass
try:
info["font_size_pt"] = round(run.font.size / 12700) if run.font.size else None
except Exception:
pass
try:
if run.font.name:
info["font_name"] = run.font.name
except Exception:
pass
try:
info["fill_color"] = str(shape.fill.fore_color.rgb)
except Exception:
pass
sample_shapes.append(info)
return {
"width_inches": round(prs.slide_width / 914400, 2),
"height_inches": round(prs.slide_height / 914400, 2),
"total_layouts": sum(1 for _ in prs.slide_layouts),
"layouts": layouts,
"sample_shapes": sample_shapes,
}
def extract_slides_json_from_pptx(pptx_bytes: bytes) -> dict:
"""Parse a PPTX binary and return a StudioSlides-compatible dict for preview."""
import io
from pptx import Presentation
prs = Presentation(io.BytesIO(pptx_bytes))
presentation_title = ""
subtitle = ""
slides = []
for i, slide in enumerate(prs.slides):
texts: list[str] = []
for shape in slide.shapes:
if not shape.has_text_frame:
continue
for para in shape.text_frame.paragraphs:
t = para.text.strip()
if t:
texts.append(t)
if not texts:
continue
if i == 0:
presentation_title = texts[0] if texts else f"Slide {i + 1}"
subtitle = texts[1] if len(texts) > 1 else ""
else:
title = texts[0] if texts else f"Slide {i + 1}"
bullets = [t for t in texts[1:] if t]
slides.append({"title": title, "bullets": bullets})
if not presentation_title:
presentation_title = "Presentation"
return {
"presentation_title": presentation_title,
"subtitle": subtitle,
"slides": slides,
}
def clear_template_slides(template_path: str, output_path: str) -> None:
"""Copy template to output_path and remove all slides cleanly.
Operates directly on the zip to avoid python-pptx 1.x serialization quirks.
Removes slide XML files, their rels files, slide relationships from
presentation.xml.rels, sldId entries from presentation.xml, and slide/notes
Override entries from [Content_Types].xml.
"""
import shutil
import zipfile
import re
from lxml import etree
shutil.copy(template_path, output_path)
# Read all zip entries
with zipfile.ZipFile(output_path, 'r') as z:
info_list = z.infolist()
files = {i.filename: z.read(i.filename) for i in info_list}
compress_map = {i.filename: i.compress_type for i in info_list}
# Patterns for entries to drop entirely
_DROP = [
re.compile(r'^ppt/slides/slide\d+\.xml$'),
re.compile(r'^ppt/slides/_rels/slide\d+\.xml\.rels$'),
re.compile(r'^ppt/notesSlides/notesSlide\d+\.xml$'),
re.compile(r'^ppt/notesSlides/_rels/notesSlide\d+\.xml\.rels$'),
]
def _should_drop(name: str) -> bool:
return any(p.match(name) for p in _DROP)
P_NS = 'http://schemas.openxmlformats.org/presentationml/2006/main'
SLIDE_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide'
SLIDE_CT = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml'
NOTES_CT = 'application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml'
# Remove <p:sldId> elements from presentation.xml
PRS_XML = 'ppt/presentation.xml'
if PRS_XML in files:
root = etree.fromstring(files[PRS_XML])
for lst in root.iter(f'{{{P_NS}}}sldIdLst'):
for child in list(lst):
lst.remove(child)
files[PRS_XML] = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
# Remove slide relationships from presentation.xml.rels
PRS_RELS = 'ppt/_rels/presentation.xml.rels'
if PRS_RELS in files:
root = etree.fromstring(files[PRS_RELS])
for rel in list(root):
if rel.get('Type') == SLIDE_REL:
root.remove(rel)
files[PRS_RELS] = etree.tostring(root)
# Remove slide/notes Override entries from [Content_Types].xml
CT_XML = '[Content_Types].xml'
if CT_XML in files:
root = etree.fromstring(files[CT_XML])
for override in list(root):
if override.get('ContentType', '') in (SLIDE_CT, NOTES_CT):
root.remove(override)
files[CT_XML] = etree.tostring(root)
# Write cleaned zip
with zipfile.ZipFile(output_path, 'w') as zout:
for name, data in files.items():
if _should_drop(name):
continue
zi = zipfile.ZipInfo(name)
zi.compress_type = compress_map.get(name, zipfile.ZIP_DEFLATED)
zout.writestr(zi, data)
def _generate_pptx_from_template_sync(
template_path: str,
doc_summaries: list,
model_type: str,
custom_prompt: str,
output_path: str,
) -> dict:
"""Synchronous implementation — run via asyncio.to_thread."""
import subprocess, tempfile, anthropic as _anthropic
# Pre-process: copy template and clear slides deterministically in main process
try:
clear_template_slides(template_path, output_path)
except Exception as e:
raise Exception(f"Failed to process template: {e}") from e
template_info = analyze_pptx_template(template_path) # analyse original (has slides for style info)
ctx = build_context(doc_summaries)
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
client = _anthropic.Anthropic(api_key=api_key)
system_msg = (
"You are an expert Python developer specializing in python-pptx. "
"Return ONLY executable Python code — no markdown fences, no prose."
)
user_msg = f"""Create a Python script using python-pptx that generates a professional presentation from the provided template.
OUTPUT PATH: {output_path}
TEMPLATE STRUCTURE:
{json.dumps(template_info, indent=2)}
PRESENTATION CONTENT (from analyzed documents):
{ctx[:3000]}
USER INSTRUCTIONS:
{custom_prompt or 'Create a professional, well-structured presentation matching the template style.'}
SAFE SLIDE CREATION PATTERN (follow exactly):
1. Open the already-prepared output file (template copied, slides already cleared):
prs = Presentation(r'{output_path}')
2. Add each slide using ONLY layout index 0:
slide = prs.slides.add_slide(prs.slide_layouts[0])
# The slide AUTOMATICALLY inherits the template background, colors and fonts
# from the slide master — DO NOT add background shapes
3. Add content using textboxes ONLY (NOT placeholders they cause crashes):
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
txBox = slide.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(9), Inches(1))
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = "Slide title here"
run = p.runs[0]
run.font.size = Pt(28)
run.font.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) # use exact template colors
# Also set font name from template: run.font.name = "Font Name Here"
4. CRITICAL STYLE RULES follow to preserve template visual design:
- Use font_name values from TEMPLATE STRUCTURE for run.font.name
- Use font_color hex values from TEMPLATE STRUCTURE for RGBColor
- NEVER add a rectangle/shape that covers the full slide (it hides the template background)
- NEVER set slide.background.fill let it inherit from the slide master
- Text boxes should be transparent (no fill): txBox.fill.background()
5. Create 8-12 slides (title slide, content slides, conclusion).
6. Last line must be exactly: prs.save(r'{output_path}')
CRITICAL these cause runtime crashes, never use them:
- shutil.copy() DO NOT copy; the file is already prepared at the output path
- copy.deepcopy() on any pptx object
- slide.placeholders[N].text = ... (use textboxes instead)
- prs.slide_layouts[N] with N > 0
- add_shape() or add_picture() covering the full slide dimensions
ALLOWED IMPORTS ONLY:
import shutil, io, math
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.chart.data import ChartData, CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE
Return ONLY Python code no markdown, no explanations."""
def _call_claude(messages):
from llm_factory import ANTHROPIC_CHAT_MODEL
resp = client.messages.create(
model=ANTHROPIC_CHAT_MODEL,
max_tokens=16000,
system=system_msg,
messages=messages,
)
code = resp.content[0].text.strip()
code = re.sub(r'^```\w*\n?', '', code)
code = re.sub(r'\n?```\s*$', '', code).strip()
return code
def _exec_code(code: str):
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, dir='/tmp') as f:
f.write(code)
path = f.name
try:
r = subprocess.run(
['/app/.venv/bin/python', path],
timeout=60,
capture_output=True,
text=True,
env={
'PATH': '/usr/local/bin:/usr/bin:/bin:/app/.venv/bin',
'HOME': '/tmp',
},
)
if r.returncode != 0:
print(f"[from-template] subprocess stderr:\n{r.stderr}\nstdout:\n{r.stdout}")
return r.returncode == 0, r.stderr or r.stdout
finally:
try:
os.unlink(path)
except Exception:
pass
messages = [{"role": "user", "content": user_msg}]
code = _call_claude(messages)
ok, err = _exec_code(code)
if not ok:
messages.append({"role": "assistant", "content": code})
messages.append({"role": "user", "content": (
f"The code failed:\n{err[:400]}\n\n"
f"Fix it. The file at {output_path} is already prepared (template copied, slides cleared).\n"
f"Open it directly with Presentation(r'{output_path}'). Do NOT copy the template again.\n"
"Return ONLY the fixed Python code."
)})
code = _call_claude(messages)
ok, err = _exec_code(code)
if not ok:
print(f"[from-template] generation failed:\n{err}")
raise Exception(f"Template PPTX generation failed: {err}")
return {"status": "success", "output_path": output_path}
async def generate_pptx_from_template(
template_path: str,
doc_summaries: list,
model_type: str,
custom_prompt: str,
output_path: str,
) -> dict:
"""Async wrapper — offloads blocking work to a thread pool."""
import asyncio
return await asyncio.to_thread(
_generate_pptx_from_template_sync,
template_path, doc_summaries, model_type, custom_prompt, output_path,
)
async def regenerate_single_slide(
current_slide: dict,
slide_index: int,
total_slides: int,
doc_summaries: list,
model_type: str,
custom_prompt: str,
) -> dict:
"""Regenerate a single slide based on a custom prompt."""
from llm_factory import get_llm_by_type
ctx = build_context(doc_summaries)[:2000]
schema = json.dumps(SlideContent.model_json_schema(), indent=2)
system_msg = "You are an expert presentation designer. Return ONLY valid JSON matching the schema."
user_msg = f"""Regenerate slide {slide_index + 1} of {total_slides}.
CURRENT SLIDE:
{json.dumps(current_slide, indent=2)}
DOCUMENT CONTENT:
{ctx}
NEW INSTRUCTIONS:
{custom_prompt}
RULES:
- Keep slide focused and concise
- 0-2 bullets when diagram present, 3-5 bullets otherwise
- Use 'flowchart' diagram for processes, 'bar_chart' for comparisons, 'pie_chart' for proportions
- Never put [DIAGRAM] in the title
Respond ONLY with valid JSON matching this schema:
{schema}"""
llm = get_llm_by_type(model_type)
response = await llm.achat(messages=[
ChatMessage(role="system", content=system_msg),
ChatMessage(role="user", content=user_msg),
])
text = str(response.message.content).strip()
m = re.search(r'\{.*\}', text, re.DOTALL)
if m:
text = m.group(0)
parsed = json.loads(text)
slide = SlideContent(**parsed)
return slide.model_dump()
async def generate_report(doc_summaries, model_type='openai', options: dict = None) -> ReportOutput:
ctx = build_context(doc_summaries)
opts = options or {}

View file

@ -81,8 +81,7 @@ if (
and os.getenv("LLAMACLOUD_PIPELINE_ID", None)
and os.getenv("OPENAI_API_KEY", None)
):
from llm_factory import OPENAI_LEGACY_MODEL
LLM = OpenAIResponses(model=OPENAI_LEGACY_MODEL, api_key=os.getenv("OPENAI_API_KEY"))
LLM = OpenAIResponses(model="gpt-4.1", api_key=os.getenv("OPENAI_API_KEY"))
CLIENT = AsyncLlamaCloud(token=os.getenv("LLAMACLOUD_API_KEY"))
EXTRACT_AGENT = LlamaExtract(api_key=os.getenv("LLAMACLOUD_API_KEY")).get_agent(
id=os.getenv("EXTRACT_AGENT_ID")

755
backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,6 @@ services:
volumes:
- pgdata_nextjs:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres_nextjs"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
@ -26,12 +20,6 @@ services:
volumes:
- redis_data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
backend:
build:
@ -50,17 +38,9 @@ services:
- podcasts_data:/app/conversations
- uploads_data:/app/failed_uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
- postgres
- redis
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:9000/api/health || exit 1"]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
frontend:
build:
@ -75,15 +55,8 @@ services:
NEXT_PUBLIC_API_URL: "https://ai-sandbox.oliver.solutions/notebookllama-back"
NEXT_PUBLIC_WS_URL: "wss://ai-sandbox.oliver.solutions/notebookllama-back"
depends_on:
backend:
condition: service_started
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:4000/notebookllama', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
interval: 20s
timeout: 10s
retries: 5
start_period: 60s
jaeger:
image: jaegertracing/all-in-one:latest

View file

@ -15,11 +15,11 @@ export default function AdminPage() {
// Check if user is admin - strict check
if (!user || !user.is_admin) {
return (
<div style={{ minHeight: '100vh', background: 'var(--bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--fg)', marginBottom: '0.5rem' }}> Admin Access Required</h2>
<p style={{ color: 'var(--fg-muted)' }}>This page is only accessible to administrators.</p>
<Link href="/notebooks" style={{ marginTop: '1rem', display: 'inline-block', color: 'var(--primary)' }}>
<h2 className="text-2xl font-bold text-gray-900 mb-2"> Admin Access Required</h2>
<p className="text-gray-700">This page is only accessible to administrators.</p>
<Link href="/notebooks" className="mt-4 inline-block text-blue-600 hover:text-blue-700">
Go to My Notebooks
</Link>
</div>
@ -85,107 +85,87 @@ export default function AdminPage() {
});
return (
<div style={{ minHeight: '100vh', background: 'var(--bg)', paddingTop: '2rem', paddingBottom: '2rem' }}>
<div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4 max-w-7xl">
<div className="mb-8">
<h1 style={{ fontSize: '1.875rem', fontWeight: 700, color: 'var(--fg)', marginBottom: '0.5rem' }}> Admin Dashboard</h1>
<p style={{ color: 'var(--fg-muted)' }}>System statistics and monitoring</p>
<h1 className="text-3xl font-bold text-gray-900 mb-2"> Admin Dashboard</h1>
<p className="text-gray-700">System statistics and monitoring</p>
</div>
{/* System Health */}
<div className="card" style={{ padding: '1.5rem', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--fg)', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Activity className="w-6 h-6" style={{ color: 'var(--primary)' }} />
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center space-x-2">
<Activity className="w-6 h-6 text-blue-600" />
<span>System Health</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<span style={{ color: 'var(--fg)', fontWeight: 500 }}>Status</span>
<span style={{
padding: '0.25rem 0.75rem',
borderRadius: '9999px',
fontSize: '0.875rem',
fontWeight: 600,
background: health?.status === 'healthy' ? 'color-mix(in srgb, var(--success) 15%, transparent)' : 'color-mix(in srgb, var(--danger) 15%, transparent)',
color: health?.status === 'healthy' ? 'var(--success)' : 'var(--danger)',
}}>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<span className="text-gray-700 font-medium">Status</span>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${health?.status === 'healthy' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{health?.status || 'unknown'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<span style={{ color: 'var(--fg)', fontWeight: 500 }}>Database</span>
<span style={{ color: 'var(--success)', fontWeight: 600 }}>{health?.database || 'unknown'}</span>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<span className="text-gray-700 font-medium">Database</span>
<span className="text-green-600 font-semibold">{health?.database || 'unknown'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<span style={{ color: 'var(--fg)', fontWeight: 500 }}>Pending Tasks</span>
<span style={{ color: 'var(--fg)', fontWeight: 700 }}>{health?.pending_tasks || 0}</span>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<span className="text-gray-700 font-medium">Pending Tasks</span>
<span className="text-gray-900 font-bold">{health?.pending_tasks || 0}</span>
</div>
</div>
</div>
{/* Statistics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<StatCard icon={Users} title="Total Users" value={stats?.total_users || 0} color="primary" />
<StatCard icon={BookOpen} title="Total Notebooks" value={stats?.total_notebooks || 0} color="success" />
<StatCard icon={FileText} title="Total Documents" value={stats?.total_documents || 0} color="info" />
<StatCard icon={MessageSquare} title="Chat Messages" value={stats?.total_chats || 0} color="warning" />
<StatCard icon={Users} title="Total Users" value={stats?.total_users || 0} color="blue" />
<StatCard icon={BookOpen} title="Total Notebooks" value={stats?.total_notebooks || 0} color="green" />
<StatCard icon={FileText} title="Total Documents" value={stats?.total_documents || 0} color="purple" />
<StatCard icon={MessageSquare} title="Chat Messages" value={stats?.total_chats || 0} color="orange" />
</div>
{/* Cost Estimation */}
{costs && (
<div className="card" style={{ padding: '1.5rem', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--fg)', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<DollarSign className="w-6 h-6" style={{ color: 'var(--success)' }} />
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center space-x-2">
<DollarSign className="w-6 h-6 text-green-600" />
<span>Estimated Costs</span>
</h2>
<div className="grid grid-cols-4 gap-4">
<div style={{ padding: '1rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)', marginBottom: '0.25rem' }}>Document Processing</p>
<p style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--fg)' }}>${costs.document_processing}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--fg-muted)' }}>{costs.counts.summaries} docs × $0.60</p>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-700 mb-1">Document Processing</p>
<p className="text-2xl font-bold text-gray-900">${costs.document_processing}</p>
<p className="text-xs text-gray-700">{costs.counts.summaries} docs × $0.60</p>
</div>
<div style={{ padding: '1rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)', marginBottom: '0.25rem' }}>Chat Messages</p>
<p style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--fg)' }}>${costs.chat_messages}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--fg-muted)' }}>{costs.counts.chats} msgs × $0.01</p>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-700 mb-1">Chat Messages</p>
<p className="text-2xl font-bold text-gray-900">${costs.chat_messages}</p>
<p className="text-xs text-gray-700">{costs.counts.chats} msgs × $0.01</p>
</div>
<div style={{ padding: '1rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)', marginBottom: '0.25rem' }}>Podcasts</p>
<p style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--fg)' }}>${costs.podcasts}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--fg-muted)' }}>{costs.counts.podcasts} × $0.50</p>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-700 mb-1">Podcasts</p>
<p className="text-2xl font-bold text-gray-900">${costs.podcasts}</p>
<p className="text-xs text-gray-700">{costs.counts.podcasts} × $0.50</p>
</div>
<div style={{ padding: '1rem', background: 'color-mix(in srgb, var(--success) 12%, transparent)', border: '2px solid color-mix(in srgb, var(--success) 30%, transparent)', borderRadius: 'var(--radius)' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--success)', marginBottom: '0.25rem', fontWeight: 600 }}>Total</p>
<p style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--success)' }}>${costs.total}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--success)', opacity: 0.8 }}>Estimated</p>
<div className="p-4 bg-green-50 rounded-lg border-2 border-green-200">
<p className="text-sm text-green-700 mb-1 font-semibold">Total</p>
<p className="text-2xl font-bold text-green-900">${costs.total}</p>
<p className="text-xs text-green-600">Estimated</p>
</div>
</div>
</div>
)}
{/* Recent Activity Tabs */}
<div className="card" style={{ padding: '1.5rem', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--fg)', marginBottom: '1rem' }}>🕐 Recent Activity</h2>
<div style={{ borderBottom: '1px solid var(--border)' }}>
<nav style={{ display: 'flex', gap: '2rem' }}>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">🕐 Recent Activity</h2>
<div className="border-b border-gray-200">
<nav className="flex space-x-8">
{['users', 'notebooks', 'documents'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
paddingTop: '0.5rem',
paddingBottom: '0.5rem',
paddingLeft: '0.25rem',
paddingRight: '0.25rem',
borderBottom: activeTab === tab ? '2px solid var(--primary)' : '2px solid transparent',
color: activeTab === tab ? 'var(--primary)' : 'var(--fg-muted)',
fontWeight: 500,
fontSize: '0.875rem',
textTransform: 'capitalize',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
className={`py-2 px-1 border-b-2 font-medium text-sm capitalize ${activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-700 hover:text-gray-700'}`}
>
{tab}
</button>
@ -193,31 +173,27 @@ export default function AdminPage() {
</nav>
</div>
<div style={{ marginTop: '1rem' }}>
<div className="mt-4">
{/* Users Tab */}
{activeTab === 'users' && users && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div className="space-y-2">
{users.map((u: any) => (
<div key={u.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<Users className="w-5 h-5" style={{ color: 'var(--primary)' }} />
<div key={u.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
<div className="flex items-center space-x-3">
<Users className="w-5 h-5 text-blue-600" />
<div>
<p style={{ fontWeight: 500, color: 'var(--fg)' }}>
<p className="font-medium text-gray-900">
{u.username}
{u.is_admin && (
<span style={{ marginLeft: '0.5rem', padding: '0.125rem 0.5rem', background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)', fontSize: '0.75rem', borderRadius: 'var(--radius-sm)', fontWeight: 600 }}>ADMIN</span>
)}
{u.is_suspended && (
<span style={{ marginLeft: '0.5rem', padding: '0.125rem 0.5rem', background: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', fontSize: '0.75rem', borderRadius: 'var(--radius-sm)', fontWeight: 600 }}>SUSPENDED</span>
)}
{u.is_admin && <span className="ml-2 px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded font-semibold">ADMIN</span>}
{u.is_suspended && <span className="ml-2 px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded font-semibold">SUSPENDED</span>}
</p>
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>{u.email}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--fg-muted)' }}>
<p className="text-sm text-gray-700">{u.email}</p>
<p className="text-xs text-gray-700">
{formatDistanceToNow(new Date(u.created_at), { addSuffix: true })}
</p>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div className="flex items-center gap-2">
<button
onClick={async () => {
const newStatus = !u.is_admin;
@ -230,18 +206,11 @@ export default function AdminPage() {
}
}
}}
style={{
padding: '0.25rem 0.75rem',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem',
fontWeight: 600,
cursor: 'pointer',
border: 'none',
background: u.is_admin
? 'color-mix(in srgb, var(--warning) 15%, transparent)'
: 'color-mix(in srgb, var(--success) 15%, transparent)',
color: u.is_admin ? 'var(--warning)' : 'var(--success)',
}}
className={`px-3 py-1 rounded text-xs font-semibold ${
u.is_admin
? 'bg-orange-100 text-orange-700 hover:bg-orange-200'
: 'bg-green-100 text-green-700 hover:bg-green-200'
}`}
>
{u.is_admin ? 'Make User' : 'Make Admin'}
</button>
@ -258,19 +227,11 @@ export default function AdminPage() {
}
}}
disabled={u.id === user?.id}
style={{
padding: '0.25rem 0.75rem',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem',
fontWeight: 600,
cursor: u.id === user?.id ? 'not-allowed' : 'pointer',
opacity: u.id === user?.id ? 0.5 : 1,
border: 'none',
background: u.is_suspended
? 'color-mix(in srgb, var(--warning) 15%, transparent)'
: 'var(--bg-card)',
color: u.is_suspended ? 'var(--warning)' : 'var(--fg-muted)',
}}
className={`px-3 py-1 rounded text-xs font-semibold ${
u.is_suspended
? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{u.is_suspended ? 'Unsuspend' : 'Suspend'}
</button>
@ -286,17 +247,7 @@ export default function AdminPage() {
}
}}
disabled={u.id === user?.id}
style={{
padding: '0.25rem 0.75rem',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem',
fontWeight: 600,
cursor: u.id === user?.id ? 'not-allowed' : 'pointer',
opacity: u.id === user?.id ? 0.5 : 1,
border: 'none',
background: 'color-mix(in srgb, var(--danger) 15%, transparent)',
color: 'var(--danger)',
}}
className="px-3 py-1 rounded text-xs font-semibold bg-red-100 text-red-700 hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete
</button>
@ -308,17 +259,17 @@ export default function AdminPage() {
{/* Notebooks Tab */}
{activeTab === 'notebooks' && notebooks && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div className="space-y-2">
{notebooks.map((nb: any) => (
<div key={nb.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<BookOpen className="w-5 h-5" style={{ color: 'var(--success)' }} />
<div key={nb.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
<div className="flex items-center space-x-3">
<BookOpen className="w-5 h-5 text-green-600" />
<div>
<p style={{ fontWeight: 500, color: 'var(--fg)' }}>{nb.name}</p>
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>by {nb.owner_username} {nb.model_type}</p>
<p className="font-medium text-gray-900">{nb.name}</p>
<p className="text-sm text-gray-700">by {nb.owner_username} {nb.model_type}</p>
</div>
</div>
<span style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>
<span className="text-sm text-gray-700">
{formatDistanceToNow(new Date(nb.created_at), { addSuffix: true })}
</span>
</div>
@ -328,17 +279,17 @@ export default function AdminPage() {
{/* Documents Tab */}
{activeTab === 'documents' && documents && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div className="space-y-2">
{documents.map((doc: any) => (
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<FileText className="w-5 h-5" style={{ color: 'var(--info)' }} />
<div key={doc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
<div className="flex items-center space-x-3">
<FileText className="w-5 h-5 text-purple-600" />
<div>
<p style={{ fontWeight: 500, color: 'var(--fg)' }}>{doc.filename}</p>
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>by {doc.owner_username}</p>
<p className="font-medium text-gray-900">{doc.filename}</p>
<p className="text-sm text-gray-700">by {doc.owner_username}</p>
</div>
</div>
<span style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>
<span className="text-sm text-gray-700">
{formatDistanceToNow(new Date(doc.created_at), { addSuffix: true })}
</span>
</div>
@ -350,22 +301,22 @@ export default function AdminPage() {
{/* Background Tasks */}
{tasks && tasks.length > 0 && (
<div className="card" style={{ padding: '1.5rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--fg)', marginBottom: '1rem' }}> Background Tasks (Last 20)</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4"> Background Tasks (Last 20)</h2>
<div className="space-y-2">
{tasks.map((task: any) => (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem', background: 'var(--bg-muted)', borderRadius: 'var(--radius)' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div key={task.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
<div className="flex-1">
<div className="flex items-center space-x-2">
{getTaskIcon(task.status)}
<span style={{ fontWeight: 500, color: 'var(--fg)' }}>{task.task_type}</span>
<span className="font-medium text-gray-900">{task.task_type}</span>
</div>
{task.error_message && (
<p style={{ fontSize: '0.75rem', color: 'var(--danger)', marginTop: '0.25rem' }}>{task.error_message}</p>
<p className="text-xs text-red-600 mt-1">{task.error_message}</p>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', fontSize: '0.875rem', color: 'var(--fg-muted)' }}>
<span style={getTaskStatusStyle(task.status)}>
<div className="flex items-center space-x-4 text-sm text-gray-700">
<span className={getTaskStatusClass(task.status)}>
{task.status}
</span>
<span>{formatDistanceToNow(new Date(task.created_at), { addSuffix: true })}</span>
@ -381,26 +332,20 @@ export default function AdminPage() {
}
function StatCard({ icon: Icon, title, value, color }: any) {
const iconColors: any = {
primary: 'var(--primary)',
success: 'var(--success)',
info: 'var(--info)',
warning: 'var(--warning)',
};
const iconBgs: any = {
primary: 'color-mix(in srgb, var(--primary) 15%, transparent)',
success: 'color-mix(in srgb, var(--success) 15%, transparent)',
info: 'color-mix(in srgb, var(--info) 15%, transparent)',
warning: 'color-mix(in srgb, var(--warning) 15%, transparent)',
const colors: any = {
blue: 'text-blue-600 bg-blue-100',
green: 'text-green-600 bg-green-100',
purple: 'text-purple-600 bg-purple-100',
orange: 'text-orange-600 bg-orange-100',
};
return (
<div className="card" style={{ padding: '1.5rem' }}>
<div style={{ padding: '0.75rem', borderRadius: 'var(--radius)', display: 'inline-block', background: iconBgs[color], marginBottom: '0.75rem' }}>
<Icon className="w-6 h-6" style={{ color: iconColors[color] }} />
<div className="bg-white rounded-lg shadow-md p-6">
<div className={`p-3 rounded-lg inline-block ${colors[color]} mb-3`}>
<Icon className="w-6 h-6" />
</div>
<p style={{ color: 'var(--fg-muted)', fontSize: '0.875rem', marginBottom: '0.25rem' }}>{title}</p>
<p style={{ fontSize: '1.875rem', fontWeight: 700, color: 'var(--fg)' }}>{value.toLocaleString()}</p>
<p className="text-gray-700 text-sm mb-1">{title}</p>
<p className="text-3xl font-bold text-gray-900">{value.toLocaleString()}</p>
</div>
);
}
@ -408,24 +353,24 @@ function StatCard({ icon: Icon, title, value, color }: any) {
function getTaskIcon(status: string) {
switch (status) {
case 'completed':
return <span style={{ color: 'var(--success)' }}></span>;
return <span className="text-green-600"></span>;
case 'in_progress':
return <span style={{ color: 'var(--info)' }}>🔵</span>;
return <span className="text-blue-600">🔵</span>;
case 'pending':
return <span style={{ color: 'var(--warning)' }}>🟡</span>;
return <span className="text-yellow-600">🟡</span>;
case 'failed':
return <span style={{ color: 'var(--danger)' }}></span>;
return <span className="text-red-600"></span>;
default:
return <span></span>;
}
}
function getTaskStatusStyle(status: string): React.CSSProperties {
const styles: Record<string, React.CSSProperties> = {
completed: { color: 'var(--success)', fontWeight: 600 },
in_progress: { color: 'var(--info)', fontWeight: 600 },
pending: { color: 'var(--warning)', fontWeight: 600 },
failed: { color: 'var(--danger)', fontWeight: 600 },
function getTaskStatusClass(status: string) {
const classes: any = {
completed: 'text-green-600 font-semibold',
in_progress: 'text-blue-600 font-semibold',
pending: 'text-yellow-600 font-semibold',
failed: 'text-red-600 font-semibold',
};
return styles[status] || { color: 'var(--fg-muted)' };
return classes[status] || 'text-gray-700';
}

View file

@ -20,11 +20,11 @@
--border: #E2E8F0;
--border-strong: #CBD5E1;
/* Brand / Primary — amber */
--primary: #FFC407;
--primary-fg: #1E293B;
--primary-hover: #E6B000;
--primary-light: rgba(255, 196, 7, 0.12);
/* Brand / Primary — indigo */
--primary: #6366F1;
--primary-fg: #FFFFFF;
--primary-hover: #4F46E5;
--primary-light: #EEF2FF;
/* Accent — amber (brand color) */
--accent: #FFC407;
@ -35,10 +35,6 @@
--warning: #D97706;
--danger: #DC2626;
--info: #2563EB;
--status-success-bg: rgba(52, 211, 153, 0.12);
--status-warning-bg: rgba(251, 191, 36, 0.12);
--status-danger-bg: rgba(248, 113, 113, 0.12);
--status-info-bg: rgba(96, 165, 250, 0.12);
/* Shadow */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
@ -72,11 +68,11 @@
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.15);
/* Brand / Primary — amber */
--primary: #FFC407;
--primary-fg: #0A0A0F;
--primary-hover: #FFD040;
--primary-light: rgba(255, 196, 7, 0.12);
/* Brand / Primary — lighter indigo for dark bg */
--primary: #818CF8;
--primary-fg: #0F0F1A;
--primary-hover: #A5B4FC;
--primary-light: rgba(99, 102, 241, 0.12);
/* Accent */
--accent: #FFC407;
@ -87,10 +83,6 @@
--warning: #FBBF24;
--danger: #F87171;
--info: #60A5FA;
--status-success-bg: rgba(52, 211, 153, 0.12);
--status-warning-bg: rgba(251, 191, 36, 0.12);
--status-danger-bg: rgba(248, 113, 113, 0.12);
--status-info-bg: rgba(96, 165, 250, 0.12);
/* Shadow */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.4);
@ -180,7 +172,7 @@ body {
.input-base::placeholder { color: var(--fg-subtle); }
.input-base:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(255, 196, 7, 0.25);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
/* ── Prose (markdown) ────────────────────────────────────────────────────── */

View file

@ -56,21 +56,21 @@ export default function MicrosoftCallbackPage() {
}, [router, setUser]);
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
{error ? (
<div className="card p-8" style={{ maxWidth: 400 }}>
<div className="mb-4" style={{ color: 'var(--danger)' }}>
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md">
<div className="text-red-600 mb-4">
<h2 className="text-xl font-bold mb-2">Authentication Error</h2>
<p className="text-sm">{error}</p>
</div>
<p className="text-sm" style={{ color: 'var(--fg-muted)' }}>Redirecting to login page...</p>
<p className="text-gray-700 text-sm">Redirecting to login page...</p>
</div>
) : (
<div className="card p-8">
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: 'var(--primary)' }} />
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--fg)' }}>Signing you in...</h2>
<p style={{ color: 'var(--fg-muted)' }}>Processing Microsoft authentication</p>
<div className="bg-white rounded-lg shadow-lg p-8">
<Loader className="w-12 h-12 text-blue-600 animate-spin mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Signing you in...</h2>
<p className="text-gray-700">Processing Microsoft authentication</p>
</div>
)}
</div>

View file

@ -57,7 +57,7 @@ export default function LoginPage() {
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4" style={{ background: 'var(--primary)', boxShadow: '0 8px 24px rgba(255,196,7,0.4)' }}>
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4" style={{ background: 'var(--primary)', boxShadow: '0 8px 24px rgba(99,102,241,0.3)' }}>
<BookOpen className="w-7 h-7" style={{ color: 'var(--primary-fg)' }} />
</div>
<h1 className="text-2xl font-bold mb-1" style={{ color: 'var(--fg)' }}>Welcome back</h1>

View file

@ -35,9 +35,6 @@ export default function ChatPage() {
const wsRef = useRef<WebSocket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const intentionalCloseRef = useRef(false);
const [reconnectTrigger, setReconnectTrigger] = useState(0);
const { data: notebook } = useQuery({
queryKey: ['notebook', notebookId],
@ -129,30 +126,25 @@ export default function ChatPage() {
loadHistory();
}, [selectedSessionId, notebookId]);
// WebSocket connection with heartbeat and auto-reconnect
// WebSocket connection
useEffect(() => {
if (!selectedSessionId) return;
// Don't reconnect if already connected
if (wsRef.current?.readyState === WebSocket.OPEN) return;
intentionalCloseRef.current = false;
const ws = chatAPI.connectWebSocket(notebookId, selectedSessionId);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
// Send ping every 30s to keep the connection alive through nginx timeouts
heartbeatRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30_000);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'connected' || data.type === 'pong') {
// no-op
if (data.type === 'connected') {
// Don't add system message for reconnects
} else if (data.type === 'processing') {
setIsProcessing(true);
} else if (data.type === 'response') {
@ -179,18 +171,12 @@ export default function ChatPage() {
ws.onclose = () => {
setIsConnected(false);
if (heartbeatRef.current) { clearInterval(heartbeatRef.current); heartbeatRef.current = null; }
if (!intentionalCloseRef.current) {
setTimeout(() => setReconnectTrigger(n => n + 1), 2000);
}
};
return () => {
intentionalCloseRef.current = true;
if (heartbeatRef.current) { clearInterval(heartbeatRef.current); heartbeatRef.current = null; }
ws.close();
};
}, [notebookId, selectedSessionId, reconnectTrigger]);
}, [notebookId, selectedSessionId]);
// Auto-scroll
useEffect(() => {
@ -222,20 +208,19 @@ export default function ChatPage() {
};
return (
<div className="min-h-screen flex" style={{ background: 'var(--bg)', height: '100vh' }}>
<div className="min-h-screen bg-gray-50 flex">
{/* Sessions Sidebar */}
<div className="w-80 flex flex-col" style={{ background: 'var(--bg-card)', borderRight: '1px solid var(--border)' }}>
<div className="p-4" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
<div className="p-4 border-b border-gray-200">
<Link
href={`/notebooks/${notebookId}`}
className="inline-flex items-center space-x-2 mb-3"
style={{ color: 'var(--primary)' }}
className="inline-flex items-center space-x-2 text-blue-600 hover:text-blue-700 mb-3"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm">Back to notebook</span>
</Link>
<h2 className="font-bold text-lg" style={{ color: 'var(--fg)' }}>Chat Sessions</h2>
<p className="text-sm" style={{ color: 'var(--fg-muted)' }}>{notebook?.name}</p>
<h2 className="font-bold text-lg text-gray-900">Chat Sessions</h2>
<p className="text-sm text-gray-700">{notebook?.name}</p>
</div>
<div className="p-4 space-y-2">
@ -244,7 +229,7 @@ export default function ChatPage() {
<button
onClick={() => createSessionMutation.mutate()}
disabled={createSessionMutation.isPending}
className="btn-primary w-full flex items-center justify-center space-x-2 px-4 py-2 rounded-lg font-semibold disabled:opacity-50"
className="w-full flex items-center justify-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition font-semibold disabled:opacity-50"
>
<Plus className="w-4 h-4" />
<span>{createSessionMutation.isPending ? 'Creating...' : 'New Chat'}</span>
@ -252,8 +237,7 @@ export default function ChatPage() {
{((sessions?.private?.length ?? 0) > 0 || (sessions?.shared?.length ?? 0) > 0) && (
<button
onClick={() => setBulkDeleteMode(true)}
className="w-full flex items-center justify-center space-x-2 px-4 py-2 rounded-lg font-semibold text-sm transition"
style={{ background: 'var(--bg-muted)', color: 'var(--fg-muted)', border: '1px solid var(--border)' }}
className="w-full flex items-center justify-center space-x-2 bg-gray-200 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 transition font-semibold text-sm"
>
<Trash2 className="w-4 h-4" />
<span>Bulk Delete</span>
@ -276,16 +260,14 @@ export default function ChatPage() {
}
}}
disabled={selectedForBulkDelete.size === 0}
className="w-full flex items-center justify-center space-x-2 px-4 py-2 rounded-lg font-semibold disabled:opacity-50 transition"
style={{ background: 'var(--danger)', color: 'var(--primary-fg)' }}
className="w-full flex items-center justify-center space-x-2 bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition font-semibold disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
<span>Delete {selectedForBulkDelete.size} Selected</span>
</button>
<button
onClick={() => { setBulkDeleteMode(false); setSelectedForBulkDelete(new Set()); }}
className="w-full px-4 py-2 rounded-lg font-semibold text-sm transition"
style={{ background: 'var(--bg-muted)', color: 'var(--fg-muted)', border: '1px solid var(--border)' }}
className="w-full bg-gray-200 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 transition font-semibold text-sm"
>
Cancel
</button>
@ -297,16 +279,15 @@ export default function ChatPage() {
{/* Private Chats */}
{sessions?.private && sessions.private.length > 0 && (
<div className="px-4 pb-4">
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--fg-muted)' }}>My Chats ({sessions.private.length})</h3>
<h3 className="text-sm font-semibold text-gray-700 mb-2">My Chats ({sessions.private.length})</h3>
{sessions.private.map((session: any) => (
<div key={session.id} className="mb-2">
<div
className="p-3 rounded-lg cursor-pointer transition"
style={
className={`p-3 rounded-lg cursor-pointer transition ${
selectedSessionId === session.id
? { background: 'var(--primary-light)', border: '2px solid var(--primary)' }
: { background: 'var(--bg-muted)', border: '1px solid var(--border)' }
}
? 'bg-blue-50 border-2 border-blue-600'
: 'bg-gray-50 hover:bg-gray-100 border border-gray-200'
}`}
>
<div className="flex items-center justify-between">
{bulkDeleteMode && (
@ -330,17 +311,17 @@ export default function ChatPage() {
className="flex-1 text-left"
disabled={bulkDeleteMode}
>
<p className="font-medium text-sm truncate" style={{ color: 'var(--fg)' }}>
<p className="font-medium text-sm text-gray-900 truncate">
{session.title || `Chat ${new Date(session.created_at).toLocaleString()}`}
</p>
<p className="text-xs" style={{ color: 'var(--fg-muted)' }}>
<p className="text-xs text-gray-700">
{session.message_count || 0} messages
</p>
</button>
{!bulkDeleteMode && (
<button
onClick={() => setShowSessionMenu(showSessionMenu === session.id ? null : session.id)}
style={{ color: 'var(--fg-muted)' }}
className="text-gray-700 hover:text-gray-700"
>
<MoreVertical className="w-4 h-4" />
</button>
@ -348,17 +329,14 @@ export default function ChatPage() {
</div>
{showSessionMenu === session.id && (
<div className="mt-2 space-y-1 pt-2" style={{ borderTop: '1px solid var(--border)' }}>
<div className="mt-2 space-y-1 pt-2 border-t border-gray-200">
<button
onClick={() => {
setRenamingSession(session.id);
setNewTitle(session.title || '');
setShowSessionMenu(null);
}}
className="w-full text-left text-sm px-2 py-1 rounded flex items-center space-x-2 transition"
style={{ color: 'var(--fg)' }}
onMouseOver={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseOut={e => (e.currentTarget.style.background = 'transparent')}
className="w-full text-left text-sm px-2 py-1 text-gray-900 hover:bg-gray-100 rounded flex items-center space-x-2"
>
<Edit2 className="w-3 h-3" />
<span>Rename</span>
@ -366,10 +344,7 @@ export default function ChatPage() {
<button
onClick={() => toggleShareMutation.mutate(session.id)}
disabled={toggleShareMutation.isPending}
className="w-full text-left text-sm px-2 py-1 rounded flex items-center space-x-2 disabled:opacity-50 transition"
style={{ color: 'var(--fg)' }}
onMouseOver={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseOut={e => (e.currentTarget.style.background = 'transparent')}
className="w-full text-left text-sm px-2 py-1 text-gray-900 hover:bg-gray-100 rounded flex items-center space-x-2 disabled:opacity-50"
>
{session.is_shared ? <Lock className="w-3 h-3" /> : <Share2 className="w-3 h-3" />}
<span>{toggleShareMutation.isPending ? 'Updating...' : (session.is_shared ? 'Make Private' : 'Share')}</span>
@ -381,10 +356,7 @@ export default function ChatPage() {
}
}}
disabled={deleteSessionMutation.isPending}
className="w-full text-left text-sm px-2 py-1 rounded flex items-center space-x-2 disabled:opacity-50 transition"
style={{ color: 'var(--danger)' }}
onMouseOver={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseOut={e => (e.currentTarget.style.background = 'transparent')}
className="w-full text-left text-sm px-2 py-1 hover:bg-red-50 text-red-600 rounded flex items-center space-x-2 disabled:opacity-50"
>
<Trash2 className="w-3 h-3" />
<span>{deleteSessionMutation.isPending ? 'Deleting...' : 'Delete'}</span>
@ -398,7 +370,7 @@ export default function ChatPage() {
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="input-base w-full text-sm px-2 py-1"
className="w-full text-sm border border-gray-300 rounded px-2 py-1 text-gray-900 placeholder:text-gray-600"
placeholder="Chat title"
/>
<div className="flex space-x-2">
@ -409,14 +381,13 @@ export default function ChatPage() {
}
}}
disabled={renameSessionMutation.isPending}
className="btn-primary text-xs px-3 py-1 rounded disabled:opacity-50"
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-50"
>
{renameSessionMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => setRenamingSession(null)}
className="text-xs px-3 py-1 rounded transition"
style={{ background: 'var(--bg-muted)', color: 'var(--fg-muted)', border: '1px solid var(--border)' }}
className="text-xs bg-gray-200 text-gray-700 px-3 py-1 rounded hover:bg-gray-300"
>
Cancel
</button>
@ -431,17 +402,16 @@ export default function ChatPage() {
{/* Shared Chats */}
{sessions?.shared && sessions.shared.length > 0 && (
<div className="px-4 pb-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--fg-muted)' }}>Shared Chats ({sessions.shared.length})</h3>
<div className="px-4 pb-4 border-t border-gray-200 pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Shared Chats ({sessions.shared.length})</h3>
{sessions.shared.map((session: any) => (
<div key={session.id} className="mb-2">
<div
className="p-3 rounded-lg transition"
style={
className={`p-3 rounded-lg transition ${
selectedSessionId === session.id
? { background: 'var(--primary-light)', border: '2px solid var(--primary)' }
: { background: 'var(--bg-muted)', border: '1px solid var(--border)' }
}
? 'bg-purple-50 border-2 border-purple-600'
: 'bg-gray-50 hover:bg-gray-100 border border-gray-200'
}`}
>
<div className="flex items-center justify-between">
{bulkDeleteMode && (
@ -465,18 +435,18 @@ export default function ChatPage() {
className="flex-1 text-left"
disabled={bulkDeleteMode}
>
<p className="font-medium text-sm truncate flex items-center space-x-1" style={{ color: 'var(--fg)' }}>
<p className="font-medium text-sm text-gray-900 truncate flex items-center space-x-1">
<Share2 className="w-3 h-3" />
<span>{session.title || `Chat ${new Date(session.created_at).toLocaleString()}`}</span>
</p>
<p className="text-xs" style={{ color: 'var(--fg-muted)' }}>
<p className="text-xs text-gray-700">
by {session.username || 'Unknown'} {session.message_count || 0} messages
</p>
</button>
{!bulkDeleteMode && (
<button
onClick={() => setShowSessionMenu(showSessionMenu === session.id ? null : session.id)}
style={{ color: 'var(--fg-muted)' }}
className="text-gray-700 hover:text-gray-700"
>
<MoreVertical className="w-4 h-4" />
</button>
@ -485,14 +455,11 @@ export default function ChatPage() {
{/* Shared Chat Menu */}
{showSessionMenu === session.id && (
<div className="mt-2 space-y-1 pt-2" style={{ borderTop: '1px solid var(--border)' }}>
<div className="mt-2 space-y-1 pt-2 border-t border-gray-200">
<button
onClick={() => toggleShareMutation.mutate(session.id)}
disabled={toggleShareMutation.isPending}
className="w-full text-left text-sm px-2 py-1 rounded flex items-center space-x-2 disabled:opacity-50 transition"
style={{ color: 'var(--fg)' }}
onMouseOver={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseOut={e => (e.currentTarget.style.background = 'transparent')}
className="w-full text-left text-sm px-2 py-1 text-gray-900 hover:bg-gray-100 rounded flex items-center space-x-2 disabled:opacity-50"
>
<Lock className="w-3 h-3" />
<span>{toggleShareMutation.isPending ? 'Updating...' : 'Make Private'}</span>
@ -504,10 +471,7 @@ export default function ChatPage() {
}
}}
disabled={deleteSessionMutation.isPending}
className="w-full text-left text-sm px-2 py-1 rounded flex items-center space-x-2 disabled:opacity-50 transition"
style={{ color: 'var(--danger)' }}
onMouseOver={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseOut={e => (e.currentTarget.style.background = 'transparent')}
className="w-full text-left text-sm px-2 py-1 hover:bg-red-50 text-red-600 rounded flex items-center space-x-2 disabled:opacity-50"
>
<Trash2 className="w-3 h-3" />
<span>{deleteSessionMutation.isPending ? 'Deleting...' : 'Delete'}</span>
@ -525,26 +489,26 @@ export default function ChatPage() {
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="p-4" style={{ background: 'var(--bg-card)', borderBottom: '1px solid var(--border)' }}>
<h1 className="text-2xl font-bold" style={{ color: 'var(--fg)' }}>{notebook?.name || 'Chat'}</h1>
<div className="flex items-center space-x-4 text-sm mt-1" style={{ color: 'var(--fg-muted)' }}>
<div className="bg-white border-b border-gray-200 p-4">
<h1 className="text-2xl font-bold text-gray-900">{notebook?.name || 'Chat'}</h1>
<div className="flex items-center space-x-4 text-sm text-gray-700 mt-1">
<span>
{isConnected ? (
<span className="flex items-center space-x-1" style={{ color: 'var(--success)' }}>
<span className="w-2 h-2 rounded-full" style={{ background: 'var(--success)' }}></span>
<span className="flex items-center space-x-1 text-green-600">
<span className="w-2 h-2 bg-green-600 rounded-full"></span>
<span>Connected</span>
</span>
) : (
<span className="flex items-center space-x-1" style={{ color: 'var(--danger)' }}>
<span className="w-2 h-2 rounded-full" style={{ background: 'var(--danger)' }}></span>
<span className="flex items-center space-x-1 text-red-600">
<span className="w-2 h-2 bg-red-600 rounded-full"></span>
<span>Disconnected</span>
</span>
)}
</span>
<span></span>
<span>Model: <strong style={{ color: 'var(--fg)' }}>{notebook?.model_type}</strong></span>
<span>Model: <strong>{notebook?.model_type}</strong></span>
<span></span>
<span><strong style={{ color: 'var(--fg)' }}>{notebook?.documents?.length || 0}</strong> documents</span>
<span><strong>{notebook?.documents?.length || 0}</strong> documents</span>
</div>
</div>
@ -552,15 +516,15 @@ export default function ChatPage() {
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{!selectedSessionId ? (
<div className="text-center py-12">
<Bot className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--border)' }} />
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--fg-muted)' }}>Select or create a chat session</h3>
<p style={{ color: 'var(--fg-muted)' }}>Choose a session from the sidebar to start chatting</p>
<Bot className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-700 mb-2">Select or create a chat session</h3>
<p className="text-gray-700">Choose a session from the sidebar to start chatting</p>
</div>
) : messages.length === 0 ? (
<div className="text-center py-12">
<Bot className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--border)' }} />
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--fg-muted)' }}>Start a conversation</h3>
<p style={{ color: 'var(--fg-muted)' }}>Ask questions about the documents in this notebook</p>
<Bot className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-700 mb-2">Start a conversation</h3>
<p className="text-gray-700">Ask questions about the documents in this notebook</p>
</div>
) : (
messages.map((message, index) => (
@ -569,31 +533,31 @@ export default function ChatPage() {
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className="max-w-3xl rounded-lg p-4"
style={
className={`max-w-3xl rounded-lg p-4 ${
message.role === 'user'
? { background: 'var(--primary)', color: 'var(--primary-fg)' }
? 'bg-blue-600 text-white'
: message.role === 'system'
? { background: 'var(--bg-muted)', color: 'var(--fg-muted)', fontSize: '0.875rem', textAlign: 'center', width: '100%', border: '1px solid var(--border)' }
: { background: 'var(--bg-muted)', color: 'var(--fg)', border: '1px solid var(--border)' }
}
? 'bg-gray-100 text-gray-700 text-sm text-center w-full'
: 'bg-white border border-gray-200'
}`}
>
<div className="flex items-start space-x-3">
{message.role === 'assistant' && (
<Bot className="w-6 h-6 flex-shrink-0 mt-1" style={{ color: 'var(--primary)' }} />
<Bot className="w-6 h-6 text-blue-600 flex-shrink-0 mt-1" />
)}
{message.role === 'user' && (
<User className="w-6 h-6 flex-shrink-0 mt-1" style={{ color: 'var(--primary-fg)' }} />
<User className="w-6 h-6 flex-shrink-0 mt-1" />
)}
<div className="flex-1">
{message.role === 'assistant' ? (
<div className="prose prose-sm max-w-none" style={{ color: 'var(--fg)' }}>
<div className="prose prose-sm max-w-none text-gray-900">
<ReactMarkdown
components={{
h1: ({node, ...props}) => <h1 className="text-2xl font-bold mt-4 mb-2" style={{ color: 'var(--fg)' }} {...props} />,
h2: ({node, ...props}) => <h2 className="text-xl font-bold mt-3 mb-2" style={{ color: 'var(--fg)' }} {...props} />,
h3: ({node, ...props}) => <h3 className="text-lg font-bold mt-2 mb-1" style={{ color: 'var(--fg)' }} {...props} />,
h4: ({node, ...props}) => <h4 className="text-base font-bold mt-2 mb-1" style={{ color: 'var(--fg)' }} {...props} />,
// Ensure headings render properly
h1: ({node, ...props}) => <h1 className="text-2xl font-bold mt-4 mb-2 text-gray-900" {...props} />,
h2: ({node, ...props}) => <h2 className="text-xl font-bold mt-3 mb-2 text-gray-900" {...props} />,
h3: ({node, ...props}) => <h3 className="text-lg font-bold mt-2 mb-1 text-gray-900" {...props} />,
h4: ({node, ...props}) => <h4 className="text-base font-bold mt-2 mb-1 text-gray-900" {...props} />,
}}
>
{message.content}
@ -603,16 +567,17 @@ export default function ChatPage() {
<p className="whitespace-pre-wrap">{message.content}</p>
)}
{message.role === 'assistant' && message.sources && (
<details className="mt-3 rounded-lg p-2" style={{ background: 'var(--bg-card)', border: '1px solid var(--border)' }}>
<summary className="text-xs cursor-pointer font-medium" style={{ color: 'var(--primary)' }}>
<details className="mt-3 bg-gray-50 rounded-lg p-2">
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-700 font-medium">
📚 View sources ({message.sources.split('\n').filter((l: string) => l.trim().startsWith('-')).length})
</summary>
<div className="mt-2 text-xs p-2 rounded prose prose-sm" style={{ color: 'var(--fg-muted)', background: 'var(--bg-muted)' }}>
<div className="mt-2 text-xs text-gray-700 p-2 bg-white rounded prose prose-sm">
<ReactMarkdown
components={{
h1: ({node, ...props}) => <h1 className="text-base font-bold mt-2 mb-1" style={{ color: 'var(--fg-muted)' }} {...props} />,
h2: ({node, ...props}) => <h2 className="text-sm font-bold mt-2 mb-1" style={{ color: 'var(--fg-muted)' }} {...props} />,
h3: ({node, ...props}) => <h3 className="text-xs font-bold mt-1 mb-1" style={{ color: 'var(--fg-muted)' }} {...props} />,
// Ensure headings render properly in sources too
h1: ({node, ...props}) => <h1 className="text-base font-bold mt-2 mb-1 text-gray-700" {...props} />,
h2: ({node, ...props}) => <h2 className="text-sm font-bold mt-2 mb-1 text-gray-700" {...props} />,
h3: ({node, ...props}) => <h3 className="text-xs font-bold mt-1 mb-1 text-gray-700" {...props} />,
}}
>
{message.sources}
@ -629,10 +594,10 @@ export default function ChatPage() {
{isProcessing && (
<div className="flex justify-start">
<div className="rounded-lg p-4" style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)' }}>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center space-x-3">
<Loader className="w-6 h-6 animate-spin" style={{ color: 'var(--primary)' }} />
<span style={{ color: 'var(--fg-muted)' }}>Thinking...</span>
<Loader className="w-6 h-6 text-blue-600 animate-spin" />
<span className="text-gray-700">Thinking...</span>
</div>
</div>
</div>
@ -642,7 +607,7 @@ export default function ChatPage() {
</div>
{/* Input */}
<div className="p-4" style={{ background: 'var(--bg-card)', borderTop: '1px solid var(--border)' }}>
<div className="bg-white border-t border-gray-200 p-4">
<div className="flex items-end space-x-3">
<textarea
value={input}
@ -650,18 +615,18 @@ export default function ChatPage() {
onKeyPress={handleKeyPress}
placeholder={selectedSessionId ? "Ask a question about your documents..." : "Select a chat session first"}
rows={3}
className="input-base flex-1 px-4 py-3 resize-none"
className="flex-1 border border-gray-300 rounded-lg px-4 py-3 text-gray-900 placeholder:text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
disabled={!isConnected || isProcessing || !selectedSessionId}
/>
<button
onClick={handleSend}
disabled={!input.trim() || !isConnected || isProcessing || !selectedSessionId}
className="btn-primary p-3 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition"
className="bg-blue-600 text-white p-3 rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="w-6 h-6" />
</button>
</div>
<p className="text-xs mt-2" style={{ color: 'var(--fg-muted)' }}>
<p className="text-xs text-gray-700 mt-2">
Press Enter to send, Shift+Enter for new line
</p>
</div>

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ export default function NotebooksPage() {
const [newNotebook, setNewNotebook] = useState({
name: '',
description: '',
model_type: 'gpt54-exp'
model_type: 'gpt51-exp'
});
const [error, setError] = useState('');
@ -35,7 +35,7 @@ export default function NotebooksPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notebooks', user?.id] });
setIsCreating(false);
setNewNotebook({ name: '', description: '', model_type: 'gpt54-exp' });
setNewNotebook({ name: '', description: '', model_type: 'gpt51-exp' });
setError('');
},
onError: (error: any) => {
@ -102,50 +102,49 @@ export default function NotebooksPage() {
const modelOptions = [
{
value: 'gpt54-exp',
label: '🚀 GPT-5.4',
desc: 'Latest OpenAI model',
value: 'gpt51-exp',
label: '🚀 GPT-5.1',
desc: 'Latest OpenAI model (2025)',
price: '$1.25 input, $10 output per 1M tokens'
},
{
value: 'claude46-exp',
label: '🧠 Claude Sonnet 4.6',
value: 'claude45-exp',
label: '🧠 Claude Sonnet 4.5',
desc: 'Latest Anthropic model',
price: '$3 input, $15 output per 1M tokens'
},
{
value: 'gemini31-exp',
label: '💎 Gemini 3.1 Pro Preview',
value: 'gemini25-exp',
label: '💎 Gemini 3 Pro Preview',
desc: 'Latest Google model',
price: '$1.25 input, $10 output per 1M tokens'
},
{
value: 'gpt4o',
label: '⚡ GPT-4o',
desc: 'Stable OpenAI model',
desc: 'Fast OpenAI model',
price: '$5 input, $15 output per 1M tokens'
},
{
value: 'gemini31-flash',
label: '✨ Gemini 3 Flash',
desc: 'Fast Google model (cheapest)',
price: '$0.075 input, $0.30 output per 1M tokens'
value: 'gemini',
label: '✨ Gemini 2.5 Flash',
desc: 'Ultra-fast Google model',
price: '$0.15 input, $0.60 output per 1M tokens (cheapest!)'
},
];
return (
<div style={{ minHeight: '100vh', background: 'var(--bg)', paddingTop: '2rem', paddingBottom: '2rem' }}>
<div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4 max-w-7xl">
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '2rem' }}>
<div className="flex items-center justify-between mb-8">
<div>
<h1 style={{ fontSize: '1.875rem', fontWeight: 700, color: 'var(--fg)', marginBottom: '0.5rem' }}>📚 My Notebooks</h1>
<p style={{ color: 'var(--fg-muted)' }}>Manage your document collections and AI analysis</p>
<h1 className="text-3xl font-bold text-gray-900 mb-2">📚 My Notebooks</h1>
<p className="text-gray-700">Manage your document collections and AI analysis</p>
</div>
<button
onClick={() => setIsCreating(!isCreating)}
className="btn-primary"
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.75rem 1.5rem' }}
className="flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition font-semibold"
>
<Plus className="w-5 h-5" />
<span>New Notebook</span>
@ -154,18 +153,18 @@ export default function NotebooksPage() {
{/* Create Notebook Form */}
{isCreating && (
<div className="card" style={{ padding: '1.5rem', marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--fg)', marginBottom: '1rem' }}>Create New Notebook</h2>
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">Create New Notebook</h2>
{error && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: 'color-mix(in srgb, var(--danger) 12%, transparent)', border: '1px solid color-mix(in srgb, var(--danger) 35%, transparent)', color: 'var(--danger)', borderRadius: 'var(--radius)' }}>
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg">
{error}
</div>
)}
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label style={{ display: 'block', fontSize: '0.875rem', fontWeight: 500, color: 'var(--fg-muted)', marginBottom: '0.5rem' }}>
<label className="block text-sm font-medium text-gray-700 mb-2">
Notebook Name *
</label>
<input
@ -173,13 +172,13 @@ export default function NotebooksPage() {
value={newNotebook.name}
onChange={(e) => setNewNotebook({ ...newNotebook, name: e.target.value })}
placeholder="e.g., Q4 Marketing Research"
className="input-base"
className="w-full border border-gray-300 rounded-lg px-4 py-2 text-gray-900 placeholder:text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.875rem', fontWeight: 500, color: 'var(--fg-muted)', marginBottom: '0.5rem' }}>
<label className="block text-sm font-medium text-gray-700 mb-2">
Description (optional)
</label>
<textarea
@ -187,44 +186,37 @@ export default function NotebooksPage() {
onChange={(e) => setNewNotebook({ ...newNotebook, description: e.target.value })}
placeholder="What will this notebook contain?"
rows={3}
className="input-base"
className="w-full border border-gray-300 rounded-lg px-4 py-2 text-gray-900 placeholder:text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.875rem', fontWeight: 500, color: 'var(--fg-muted)', marginBottom: '0.5rem' }}>
<label className="block text-sm font-medium text-gray-700 mb-2">
AI Model Selection
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{modelOptions.map((option) => (
<label
key={option.value}
style={{
border: newNotebook.model_type === option.value
? '2px solid var(--primary)'
: '2px solid var(--border)',
background: newNotebook.model_type === option.value
? 'var(--primary-light)'
: 'var(--bg-card)',
borderRadius: 'var(--radius)',
padding: '1rem',
cursor: 'pointer',
transition: 'border-color 0.15s, background 0.15s',
}}
className={`border-2 rounded-lg p-4 cursor-pointer transition ${
newNotebook.model_type === option.value
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem' }}>
<div className="flex items-start space-x-3">
<input
type="radio"
name="model"
value={option.value}
checked={newNotebook.model_type === option.value}
onChange={(e) => setNewNotebook({ ...newNotebook, model_type: e.target.value })}
style={{ marginTop: '0.25rem', accentColor: 'var(--primary)' }}
className="mt-1 text-blue-600"
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--fg)' }}>{option.label}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--fg-muted)', marginTop: '0.25rem' }}>{option.desc}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--fg-muted)', marginTop: '0.25rem' }}>💰 {option.price}</div>
<div className="flex-1">
<div className="font-semibold text-sm text-gray-900">{option.label}</div>
<div className="text-xs text-gray-700 mt-1">{option.desc}</div>
<div className="text-xs text-gray-700 mt-1">💰 {option.price}</div>
</div>
</div>
</label>
@ -232,26 +224,18 @@ export default function NotebooksPage() {
</div>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<div className="flex space-x-3">
<button
type="submit"
disabled={createMutation.isPending}
className="btn-primary"
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition font-semibold disabled:opacity-50"
>
{createMutation.isPending ? 'Creating...' : 'Create Notebook'}
</button>
<button
type="button"
onClick={() => setIsCreating(false)}
style={{
background: 'var(--bg-muted)',
color: 'var(--fg-muted)',
padding: '0.625rem 1.25rem',
borderRadius: 'var(--radius)',
fontWeight: 600,
border: '1px solid var(--border)',
cursor: 'pointer',
}}
className="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 transition font-semibold"
>
Cancel
</button>
@ -262,9 +246,9 @@ export default function NotebooksPage() {
{/* Notebooks Grid */}
{isLoading ? (
<div style={{ textAlign: 'center', paddingTop: '3rem', paddingBottom: '3rem' }}>
<div style={{ width: '3rem', height: '3rem', borderRadius: '50%', borderBottom: '2px solid var(--primary)', animation: 'spin 1s linear infinite', margin: '0 auto' }} className="animate-spin"></div>
<p style={{ marginTop: '1rem', color: 'var(--fg-muted)' }}>Loading notebooks...</p>
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-700">Loading notebooks...</p>
</div>
) : notebooks && notebooks.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -273,21 +257,16 @@ export default function NotebooksPage() {
return (
<div
key={notebook.id}
className="card"
style={{
overflow: 'hidden',
border: isBroken ? '2px solid var(--warning)' : '1px solid var(--border)',
transition: 'box-shadow 0.2s',
}}
onMouseEnter={e => (e.currentTarget.style.boxShadow = 'var(--shadow-md)')}
onMouseLeave={e => (e.currentTarget.style.boxShadow = 'var(--shadow-sm)')}
className={`bg-white rounded-lg shadow-md hover:shadow-lg transition overflow-hidden ${
isBroken ? 'border-2 border-orange-400' : ''
}`}
>
<div style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<BookOpen className="w-8 h-8" style={{ color: isBroken ? 'var(--warning)' : 'var(--primary)' }} />
<div className="p-6">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-2">
<BookOpen className={`w-8 h-8 ${isBroken ? 'text-orange-600' : 'text-blue-600'}`} />
{isBroken && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', background: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', padding: '0.25rem 0.5rem', borderRadius: 'var(--radius-sm)', fontSize: '0.75rem', fontWeight: 600 }}>
<div className="flex items-center space-x-1 bg-orange-100 text-orange-800 px-2 py-1 rounded text-xs font-semibold">
<AlertTriangle className="w-3 h-3" />
<span>Needs Fix</span>
</div>
@ -300,88 +279,55 @@ export default function NotebooksPage() {
}
}}
disabled={deleteMutation.isPending}
style={{ color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer', opacity: deleteMutation.isPending ? 0.5 : 1 }}
className="text-red-500 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--fg)', marginBottom: '0.5rem' }}>{notebook.name}</h3>
<h3 className="text-xl font-bold text-gray-900 mb-2">{notebook.name}</h3>
{notebook.description && (
<p style={{ color: 'var(--fg-muted)', fontSize: '0.875rem', marginBottom: '1rem', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{notebook.description}</p>
<p className="text-gray-700 text-sm mb-4 line-clamp-2">{notebook.description}</p>
)}
{isBroken && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: 'color-mix(in srgb, var(--warning) 10%, transparent)', border: '1px solid color-mix(in srgb, var(--warning) 30%, transparent)', borderRadius: 'var(--radius)' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--warning)', marginBottom: '0.5rem' }}>
<div className="mb-4 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<p className="text-sm text-orange-800 mb-2">
This notebook failed to initialize properly. Click "Fix Notebook" to retry.
</p>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', fontSize: '0.875rem', color: 'var(--fg-muted)', marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div className="flex items-center space-x-4 text-sm text-gray-700 mb-4">
<div className="flex items-center space-x-1">
<Calendar className="w-4 h-4" />
<span>{formatDistanceToNow(new Date(notebook.created_at), { addSuffix: true })}</span>
</div>
</div>
{isBroken ? (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div className="flex space-x-2">
<button
onClick={() => retryPipelineMutation.mutate(notebook.id)}
disabled={retryPipelineMutation.isPending}
style={{
flex: 1,
background: 'var(--warning)',
color: 'var(--bg)',
padding: '0.5rem 1rem',
borderRadius: 'var(--radius)',
fontWeight: 600,
border: 'none',
cursor: retryPipelineMutation.isPending ? 'not-allowed' : 'pointer',
opacity: retryPipelineMutation.isPending ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
}}
className="flex-1 bg-orange-600 text-white px-4 py-2 rounded-lg hover:bg-orange-700 transition text-center font-semibold disabled:opacity-50 flex items-center justify-center space-x-2"
>
<RefreshCw className={`w-4 h-4 ${retryPipelineMutation.isPending ? 'animate-spin' : ''}`} />
<span>{retryPipelineMutation.isPending ? 'Fixing...' : 'Fix Notebook'}</span>
</button>
</div>
) : (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div className="flex space-x-2">
<Link
href={`/notebooks/${notebook.id}`}
style={{
flex: 1,
background: 'var(--primary)',
color: 'var(--primary-fg)',
padding: '0.5rem 1rem',
borderRadius: 'var(--radius)',
fontWeight: 600,
textAlign: 'center',
textDecoration: 'none',
}}
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition text-center font-semibold"
>
Open
</Link>
<Link
href={`/notebooks/${notebook.id}/chat`}
style={{
flex: 1,
background: 'var(--bg-muted)',
color: 'var(--fg-muted)',
padding: '0.5rem 1rem',
borderRadius: 'var(--radius)',
fontWeight: 600,
textAlign: 'center',
textDecoration: 'none',
border: '1px solid var(--border)',
}}
className="flex-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 transition text-center font-semibold"
>
Chat
</Link>
@ -393,13 +339,13 @@ export default function NotebooksPage() {
})}
</div>
) : (
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
<BookOpen className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--fg-subtle)' }} />
<h3 style={{ fontSize: '1.25rem', fontWeight: 600, color: 'var(--fg-muted)', marginBottom: '0.5rem' }}>No notebooks yet</h3>
<p style={{ color: 'var(--fg-muted)', marginBottom: '1.5rem' }}>Create your first notebook to get started</p>
<div className="text-center py-12 bg-white rounded-lg shadow-md">
<BookOpen className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-700 mb-2">No notebooks yet</h3>
<p className="text-gray-700 mb-6">Create your first notebook to get started</p>
<button
onClick={() => setIsCreating(true)}
className="btn-primary"
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition font-semibold"
>
Create Notebook
</button>

View file

@ -37,7 +37,7 @@ export default function Home() {
{/* Hero */}
<div className="text-center mb-14">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6" style={{ background: 'var(--primary)', boxShadow: '0 8px 24px rgba(255,196,7,0.4)' }}>
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6" style={{ background: 'var(--primary)', boxShadow: '0 8px 24px rgba(99,102,241,0.3)' }}>
<BookOpen className="w-8 h-8" style={{ color: 'var(--primary-fg)' }} />
</div>
<h1 className="text-4xl font-bold mb-3" style={{ color: 'var(--fg)' }}>

View file

@ -16,47 +16,47 @@ export default function SharedPage() {
});
return (
<div className="min-h-screen py-8" style={{ background: 'var(--bg)' }}>
<div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4 max-w-6xl">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--fg)' }}>🤝 Shared With Me</h1>
<p style={{ color: 'var(--fg-muted)' }}>Notebooks shared with you by other users</p>
<h1 className="text-3xl font-bold text-gray-900 mb-2">🤝 Shared With Me</h1>
<p className="text-gray-700">Notebooks shared with you by other users</p>
</div>
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 mx-auto" style={{ borderColor: 'var(--primary)' }}></div>
<p className="mt-4" style={{ color: 'var(--fg-muted)' }}>Loading shared notebooks...</p>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-700">Loading shared notebooks...</p>
</div>
) : sharedNotebooks && sharedNotebooks.length > 0 ? (
<div className="space-y-4">
{sharedNotebooks.map((share: any) => (
<div key={share.id} className="card p-6 hover:shadow-md transition">
<div key={share.id} className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<BookOpen className="w-8 h-8" style={{ color: 'var(--primary)' }} />
<BookOpen className="w-8 h-8 text-purple-600" />
<div>
<h2 className="text-xl font-bold" style={{ color: 'var(--fg)' }}>{share.name}</h2>
<p className="text-sm" style={{ color: 'var(--fg-muted)' }}>
<h2 className="text-xl font-bold text-gray-900">{share.name}</h2>
<p className="text-sm text-gray-700">
by {share.owner_username} ({share.owner_email})
</p>
</div>
</div>
{share.description && (
<p className="mb-4" style={{ color: 'var(--fg-muted)' }}>{share.description}</p>
<p className="text-gray-700 mb-4">{share.description}</p>
)}
<div className="flex items-center space-x-4 text-sm" style={{ color: 'var(--fg-muted)' }}>
<div className="flex items-center space-x-4 text-sm text-gray-700">
<span className="flex items-center space-x-1">
<User className="w-4 h-4" />
<span>Permission: <strong style={{ color: 'var(--fg)' }}>{share.permission}</strong></span>
<span>Permission: <strong className="text-gray-700">{share.permission}</strong></span>
</span>
<span></span>
<span className="flex items-center space-x-1">
<FileText className="w-4 h-4" />
<span><strong style={{ color: 'var(--fg)' }}>{share.document_count}</strong> documents</span>
<span><strong className="text-gray-700">{share.document_count}</strong> documents</span>
</span>
<span></span>
<span className="flex items-center space-x-1">
@ -69,14 +69,13 @@ export default function SharedPage() {
<div className="flex space-x-2 ml-4">
<Link
href={`/notebooks/${share.id}`}
className="btn-primary px-4 py-2 text-sm font-semibold"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition font-semibold"
>
View
</Link>
<Link
href={`/notebooks/${share.id}/chat`}
className="px-4 py-2 rounded-lg text-sm font-semibold border transition"
style={{ background: 'var(--bg-muted)', color: 'var(--fg)', borderColor: 'var(--border)' }}
className="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 transition font-semibold"
>
Chat
</Link>
@ -86,10 +85,10 @@ export default function SharedPage() {
))}
</div>
) : (
<div className="card p-12 text-center">
<Share2 className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--fg-muted)', opacity: 0.4 }} />
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--fg)' }}>No shared notebooks</h3>
<p style={{ color: 'var(--fg-muted)' }} className="mb-6">
<div className="bg-white rounded-lg shadow-md p-12 text-center">
<Share2 className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-700 mb-2">No shared notebooks</h3>
<p className="text-gray-700 mb-6">
When other users share notebooks with you, they'll appear here
</p>
</div>

View file

@ -6,7 +6,7 @@ import { authAPI } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { BookOpen, UserPlus, ArrowLeft } from 'lucide-react';
import { BookOpen, UserPlus } from 'lucide-react';
export default function SignupPage() {
const router = useRouter();
@ -51,79 +51,72 @@ export default function SignupPage() {
};
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4" style={{ background: 'var(--bg)' }}>
<div style={{ width: '100%', maxWidth: 400 }}>
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-12 px-4">
<div className="max-w-md w-full">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4" style={{ background: 'var(--primary)', boxShadow: '0 8px 24px rgba(255,196,7,0.4)' }}>
<BookOpen className="w-7 h-7" style={{ color: 'var(--primary-fg)' }} />
</div>
<h1 className="text-2xl font-bold mb-1" style={{ color: 'var(--fg)' }}>Create your account</h1>
<p className="text-sm" style={{ color: 'var(--fg-muted)' }}>Join Sandbox-NotebookLM</p>
<BookOpen className="w-16 h-16 text-blue-600 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-gray-900 mb-2">Sandbox-NotebookLM</h1>
<p className="text-gray-600">Create your account</p>
</div>
{/* Card */}
<div className="card p-8">
{/* Signup Form */}
<div className="bg-white rounded-lg shadow-lg p-8">
{error && (
<div className="mb-5 px-4 py-3 rounded-lg text-sm border" style={{ background: 'rgba(220,38,38,0.08)', borderColor: 'rgba(220,38,38,0.25)', color: 'var(--danger)' }}>
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--fg)' }}>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="input-base"
placeholder="you@example.com"
className="w-full border border-gray-300 rounded-lg px-4 py-2 text-gray-900 placeholder:text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--fg)' }}>
<label className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="input-base"
placeholder="yourname"
className="w-full border border-gray-300 rounded-lg px-4 py-2 text-gray-900 placeholder:text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--fg)' }}>
<label className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="input-base"
placeholder="••••••••"
className="w-full border border-gray-300 rounded-lg px-4 py-2 text-gray-900 placeholder:text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--fg)' }}>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm Password
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="input-base"
placeholder="••••••••"
className="w-full border border-gray-300 rounded-lg px-4 py-2 text-gray-900 placeholder:text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
@ -131,27 +124,27 @@ export default function SignupPage() {
<button
type="submit"
disabled={signupMutation.isPending}
className="btn-primary w-full flex items-center justify-center gap-2 py-2.5"
className="w-full bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition font-semibold disabled:opacity-50 flex items-center justify-center space-x-2"
>
<UserPlus className="w-4 h-4" />
<span>{signupMutation.isPending ? 'Creating account' : 'Create Account'}</span>
<UserPlus className="w-5 h-5" />
<span>{signupMutation.isPending ? 'Creating account...' : 'Create Account'}</span>
</button>
</form>
<div className="mt-6 text-center">
<p className="text-sm" style={{ color: 'var(--fg-muted)' }}>
<p className="text-gray-600 text-sm">
Already have an account?{' '}
<Link href="/login" className="font-semibold hover:underline" style={{ color: 'var(--primary)' }}>
<Link href="/login" className="text-blue-600 hover:underline font-semibold">
Sign in
</Link>
</p>
</div>
</div>
{/* Quick Access */}
<div className="mt-6 text-center">
<Link href="/" className="inline-flex items-center gap-1 text-sm transition" style={{ color: 'var(--fg-muted)' }}>
<ArrowLeft className="w-3.5 h-3.5" />
Back to home
<Link href="/" className="text-gray-600 hover:text-gray-900 text-sm">
Back to home
</Link>
</div>
</div>

View file

@ -27,7 +27,6 @@ const api = axios.create({
headers: {
'Content-Type': 'application/json',
},
timeout: 60_000,
});
// Helper to get JWT token from localStorage
@ -168,18 +167,6 @@ export const notebookAPI = {
api.post<StudioDataTable>(`/notebooks/${id}/studio/datatable`, opts ?? {}),
studioDownloadUrl: (id: number, type: 'slides' | 'report' | 'mindmap') =>
`${API_URL}/api/notebooks/${id}/studio/${type}/download`,
generateSlidesFromTemplate: (id: number, templateFile: File, customPrompt?: string) => {
const formData = new FormData();
formData.append('template', templateFile);
if (customPrompt) formData.append('custom_prompt', customPrompt);
return api.post<{ slides: any; is_template: boolean }>(`/notebooks/${id}/studio/slides/from-template`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
studioDownloadTemplateUrl: (id: number) =>
`${API_URL}/api/notebooks/${id}/studio/slides/template/download`,
editSlide: (id: number, slideIndex: number, customPrompt: string) =>
api.post<any>(`/notebooks/${id}/studio/slides/edit/${slideIndex}`, { custom_prompt: customPrompt }),
};
// Document API

View file

@ -3,14 +3,12 @@ import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30,
refetchOnWindowFocus: true,
refetchOnMount: true,
retry: 1,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10_000),
staleTime: 1000 * 30, // 30 seconds (short for real-time feel)
refetchOnWindowFocus: true, // Refetch when returning to page
refetchOnMount: true, // Always refetch on mount
},
mutations: {
retry: false, // never retry mutations — prevents duplicate writes
retry: false, // NEVER retry mutations to prevent duplicates
},
},
});

View file

@ -1,36 +0,0 @@
# scripts/
| Script | Purpose | Run frequency |
|---|---|---|
| `deploy.sh` | Rolling redeploy: git pull → build → migrate DB → up -d → health check | Every push to prod |
| `rollback.sh <sha>` | Revert to a previous commit and rebuild | Emergency only |
## Deploy
```bash
# SSH into optical-web-1 and run:
ssh michael_clervi@optical-web-1
cd /opt/sandbox-notebookllamalm-nextjs
sudo bash scripts/deploy.sh
# Flags:
# --no-build restart containers without rebuilding (for env-only changes)
# --backend-only rebuild + restart backend only
# --frontend-only rebuild + restart frontend only
# --branch feat/x deploy a specific branch
```
## Rollback
```bash
# Find the SHA you want:
git log --oneline -10
# Roll back:
sudo bash scripts/rollback.sh abc1234
```
## Historical scripts (do not run)
Old one-shot systemd→Docker migration scripts are in `Old Readmes/migration-2026-03/`.
Old pre-Docker systemd scripts are in `Old Readmes/pre-docker-systemd/`.

View file

@ -1,125 +0,0 @@
#!/usr/bin/env bash
# scripts/deploy.sh — Rolling redeploy for sandbox-notebookllamalm-nextjs
#
# Usage:
# bash scripts/deploy.sh # full build + deploy
# bash scripts/deploy.sh --no-build # restart containers only (env-change redeploy)
# bash scripts/deploy.sh --backend-only # build + restart backend only
# bash scripts/deploy.sh --frontend-only # build + restart frontend only
# bash scripts/deploy.sh --branch feat/x # deploy a specific git branch
set -euo pipefail
REPO_DIR="${REPO_DIR:-/opt/sandbox-notebookllamalm-nextjs}"
BRANCH="${DEPLOY_BRANCH:-main}"
BACKEND_PORT=9000
FRONTEND_PORT=4000
HEALTH_RETRIES=12
HEALTH_INTERVAL=5
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
ok() { echo -e "${GREEN}${NC} $*"; }
fail() { echo -e "${RED}${NC} $*" >&2; }
warn() { echo -e "${YELLOW}!${NC} $*"; }
NO_BUILD=false
SERVICES=""
for arg in "$@"; do
case "$arg" in
--no-build) NO_BUILD=true ;;
--backend-only) SERVICES="backend" ;;
--frontend-only) SERVICES="frontend" ;;
--branch=*) BRANCH="${arg#--branch=}" ;;
esac
done
on_error() {
local code=$?
fail "Deploy failed (exit $code at line ${BASH_LINENO[0]})"
docker compose -f "${REPO_DIR}/docker-compose.yml" logs --tail=60 2>/dev/null || true
exit $code
}
trap on_error ERR
# ── Preflight ────────────────────────────────────────────────────────────────
[[ -d "$REPO_DIR" ]] || { fail "Repo not found at ${REPO_DIR}"; exit 1; }
cd "$REPO_DIR"
docker info &>/dev/null || { fail "Docker is not running"; exit 1; }
docker compose version &>/dev/null || { fail "docker compose v2 not found"; exit 1; }
[[ -f "backend/.env" ]] || { fail "backend/.env not found"; exit 1; }
for key in OPENAI_API_KEY ANTHROPIC_API_KEY GOOGLE_API_KEY ELEVENLABS_API_KEY pgql_user pgql_psw pgql_db; do
grep -qE "^${key}=.+" backend/.env 2>/dev/null || warn "backend/.env: ${key} missing"
done
ok "Preflight"
# ── Git pull ─────────────────────────────────────────────────────────────────
GIT_USER="${SUDO_USER:-}"
_git() { [[ -n "$GIT_USER" ]] && sudo -u "$GIT_USER" git "$@" || git "$@"; }
_git diff --quiet 2>/dev/null || { warn "Unstaged changes — stashing"; _git stash -u; }
_git fetch --prune origin &>/dev/null
_git checkout "$BRANCH" &>/dev/null
_git pull --ff-only origin "$BRANCH" &>/dev/null
DEPLOYED_SHA=$(_git rev-parse --short HEAD)
ok "Git ${BRANCH} @ ${DEPLOYED_SHA}"
# ── DB migration ──────────────────────────────────────────────────────────────
docker compose up -d postgres &>/dev/null
for i in $(seq 1 20); do
docker compose exec -T postgres pg_isready -U postgres &>/dev/null 2>&1 && break
[[ $i -eq 20 ]] && { fail "Postgres did not become ready"; exit 1; }
sleep 3
done
docker compose run --rm --no-deps backend \
/app/.venv/bin/python -c "
import sys; sys.path.insert(0, '/app/src/notebookllama')
from database import run_studio_migration
run_studio_migration()
" &>/dev/null && ok "DB migration" || warn "DB migration skipped (schema may be up to date)"
# ── Build ─────────────────────────────────────────────────────────────────────
if [[ "$NO_BUILD" == "false" ]]; then
docker compose build --pull ${SERVICES}
ok "Build ${SERVICES:-all}"
else
ok "Build skipped (--no-build)"
fi
# ── Rolling restart ───────────────────────────────────────────────────────────
docker compose up -d --remove-orphans ${SERVICES} &>/dev/null
ok "Containers up"
# ── Health checks ─────────────────────────────────────────────────────────────
check_health() {
local name="$1" url="$2" attempt=0
while (( attempt < HEALTH_RETRIES )); do
attempt=$(( attempt + 1 ))
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 8 "$url" 2>/dev/null || echo "000")
[[ "$code" == "200" ]] && { ok "Health ${name}"; return 0; }
sleep "$HEALTH_INTERVAL"
done
fail "Health ${name} FAILED (HTTP ${code})"
return 1
}
FAILED=0
if [[ -z "$SERVICES" || "$SERVICES" == "backend" ]]; then
check_health "backend" "http://localhost:${BACKEND_PORT}/api/health" || FAILED=1
fi
if [[ -z "$SERVICES" || "$SERVICES" == "frontend" ]]; then
check_health "frontend" "http://localhost:${FRONTEND_PORT}/notebookllama" || FAILED=1
fi
if [[ "$FAILED" -eq 1 ]]; then
fail "Health check failed — last 50 log lines:"
docker compose logs --tail=50
exit 1
fi
echo ""
ok "Deploy complete sha=${DEPLOYED_SHA} branch=${BRANCH}"
echo " Frontend : http://localhost:${FRONTEND_PORT}/notebookllama"
echo " Backend : http://localhost:${BACKEND_PORT}/api/health"
echo " Rollback : bash scripts/rollback.sh <sha>"

View file

@ -1,56 +0,0 @@
#!/usr/bin/env bash
# scripts/rollback.sh — Roll back to a previous git SHA
#
# Usage: bash scripts/rollback.sh <git-sha>
set -euo pipefail
REPO_DIR="${REPO_DIR:-/opt/sandbox-notebookllamalm-nextjs}"
TARGET_SHA="${1:-}"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
if [[ -z "$TARGET_SHA" ]]; then
error "Usage: bash scripts/rollback.sh <git-sha>"
echo " Recent commits:"
git -C "$REPO_DIR" log --oneline -10
exit 1
fi
cd "$REPO_DIR"
CURRENT_SHA=$(git rev-parse --short HEAD)
info "Current SHA: ${CURRENT_SHA} → rolling back to ${TARGET_SHA}"
GIT_USER="${SUDO_USER:-}"
_git() { [[ -n "$GIT_USER" ]] && sudo -u "$GIT_USER" git "$@" || git "$@"; }
if ! _git diff --quiet 2>/dev/null; then
warn "Unstaged changes detected — stashing"
_git stash -u
fi
_git checkout "$TARGET_SHA"
info "Building images for ${TARGET_SHA}..."
docker compose build --pull
info "Restarting containers..."
docker compose up -d --remove-orphans
# Quick health check
sleep 10
BACKEND_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 8 "http://localhost:9000/api/health" 2>/dev/null || echo "000")
if [[ "$BACKEND_CODE" == "200" ]]; then
info "Rollback to ${TARGET_SHA} succeeded (backend healthy)"
else
error "Rollback health check failed (backend HTTP ${BACKEND_CODE})"
warn "To restore: git checkout main && docker compose build && docker compose up -d"
exit 1
fi
echo ""
info "Rolled back to ${TARGET_SHA}. To return to main: bash scripts/deploy.sh"