Compare commits
No commits in common. "main" and "worktree-agent-a751d770" have entirely different histories.
main
...
worktree-a
42 changed files with 997 additions and 2743 deletions
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
.git
|
||||
.claude
|
||||
Old Readmes
|
||||
scripts
|
||||
*.md
|
||||
!README.md
|
||||
.vscode
|
||||
.DS_Store
|
||||
56
CLAUDE.md
56
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
755
backend/uv.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)' }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
|
@ -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>"
|
||||
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue