Compare commits

...
Sign in to create a new pull request.

42 commits

Author SHA1 Message Date
Vadym Samoilenko
974a66288e Fix document processing: fallback to LLM when LlamaExtract returns data=None
LlamaExtract can return a non-None response object but with data=None for
certain PDFs, causing 'NoneType' object has no attribute 'get' on notebook_data.
Now falls back to LLM extraction instead of failing the task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:46:36 +01:00
Vadym Samoilenko
f39ddb269f feat: keep chat WebSocket alive for 24h with heartbeat and auto-reconnect
Frontend sends ping every 30s to prevent nginx proxy_read_timeout from
closing idle connections. Auto-reconnects with 2s delay if dropped.
Backend replies with pong and ignores ping from message processing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:46:36 +01:00
Vadym Samoilenko
7c5d15cdfb Fix podcast 404: remove broken source URL without /static/ prefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:46:36 +01:00
Vadym Samoilenko
14d2e9f1a2 fix: pass user_external_id as param instead of ContextVar
ContextVar does not propagate reliably through asyncio.wait_for tasks.
Pass user email directly as a parameter to query_notebook_pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:17:04 +00:00
Vadym Samoilenko
cfa6cc3437 feat: track chat LLM usage via cost_tracker
- chat.py: resolve user email from session and set_user_ctx on WS connect
- pipeline_manager.py: record tokens after query_notebook_pipeline call,
  fallback to text-length estimation when LlamaIndex does not expose usage
- cost_tracker.py: fix upsert_user field name (external_id not user_external_id)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:52:48 +00:00
Vadym Samoilenko
400a342418 feat: integrate AI Cost Tracker for usage analytics
Created cost_tracker.py with async httpx client, ContextVar-based user
propagation, and LlamaIndex token extraction helper. Wrapped all AI call
sites — studio generators (7 types), notebook synthesis, podcast outline
+ script, ElevenLabs TTS, and podcast background task. Routes set user
context via set_user_ctx(current_user.email) before AI dispatch so every
record() call carries user identity without changing generator signatures.

Source app: Sandbox-NotebookLM
Tracker URL: https://optical-dev.oliver.solutions/cost-tracker/v1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:12:01 +01:00
Vadym Samoilenko
1f1f83c8e6 Fix podcast: bump llama-index-llms-openai 0.6.5→0.7.5, drop dead-code patches
0.7.5 natively knows gpt-5.4-2026-03-05 (1050000 ctx) so OpenAIResponses.metadata
no longer raises ValueError — fixes podcast/audio generation.
Removed obsolete LlamaIndex monkey-patches for OpenAI, Anthropic, Google (all
current package versions already support the models we use). Kept only tiktoken
MODEL_TO_ENCODING registration (tiktoken 0.12.0 still doesn't know gpt-5.4.*).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:58:09 +01:00
Vadym Samoilenko
282b1c422e Fix chat: always respond in the language of the user's question
Prepend explicit language instruction to every query so the LLM
doesn't default to document language (e.g. Chinese) instead of
matching the user's input language.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:19:58 +01:00
Vadym Samoilenko
8c5e01f660 Fix deploy.sh: health check false-failure on --backend-only / --frontend-only
`A && B || C` in bash fires C when A is false (service intentionally
skipped). Replaced with if/fi blocks so || FAILED=1 only triggers
when the health check itself fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:18:10 +01:00
Vadym Samoilenko
02fd277457 Fix tiktoken: register gpt-5.4 model names to o200k_base encoding
tiktoken.model.MODEL_TO_ENCODING has the same hardcoded-list problem as
LlamaIndex. Patch it at startup so tokenization doesn't crash on
newly-released OpenAI model IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:16:36 +01:00
Vadym Samoilenko
a4eb91c380 Fix LlamaIndex OpenAI model patch: handle unknown model IDs like gpt-5.4
Extends _patch_llamaindex_model_validators() to also patch
openai_modelname_to_contextsize in both utils and base modules.
Without this, LlamaIndex throws ValueError for newly-released OpenAI
model IDs (e.g. gpt-5.4-2026-03-05) when building the query engine,
causing "Could not initialize query engine" errors in chat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:09:13 +01:00
Vadym Samoilenko
954e05e01e Fix docker compose up blocking on frontend healthcheck
- frontend depends_on: service_started (not service_healthy) — docker compose
  up -d now returns once containers start, not after full health chain resolves
- frontend healthcheck: use node HTTP check (wget/curl not in alpine runner)
- start_period: 60s (Next.js can take time on first request)
- on_error: show 60 log lines instead of 30

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:57:12 +01:00
Vadym Samoilenko
bf3b19fcb9 Fix TypeScript error: podcastStatus circular type reference
useQuery<PodcastStatus> + refetchInterval callback avoids self-reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:47:52 +01:00
Vadym Samoilenko
096885c87f Fix LlamaIndex model patch: also patch base.py direct reference
base.py imports anthropic_modelname_to_contextsize directly, so patching
utils module attribute alone doesn't affect calls inside base.py.
Now patches both utils and base modules for Anthropic and Gemini.

Also: show docker build output in deploy.sh (was suppressed, hid errors).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:42:47 +01:00
Vadym Samoilenko
75e674eb6d Bump dependencies to latest, clean up deploy.sh output
- anthropic 0.71→0.97, llama-index-llms-anthropic 0.9→0.11, llama-index-llms-google-genai 0.8→0.9
- fastapi 0.119→0.136, uvicorn 0.37→0.46, elevenlabs 2.18→2.44, google-genai 1.67→1.73
- Add LlamaIndex model validator monkey-patch (claude-sonnet-4-6 + gemini models)
- deploy.sh: status-only output (suppress docker/git noise)
- Archive legacy one-shot deploy scripts to Old Readmes/migration-2026-03/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:38:42 +01:00
Vadym Samoilenko
6d0af9f48e Fix Claude model ID and deploy.sh sudo SSH issue
- llm_factory.py: claude-sonnet-4-6 → claude-sonnet-4-5 (4.6 doesn't
  exist in llama-index-llms-anthropic; 4.5/20250929 is the latest known)
- deploy.sh + rollback.sh: git commands now run as $SUDO_USER when
  called via sudo, so Bitbucket SSH key is found (root has no SSH key)
- frontend: update Claude label to match

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:32:20 +01:00
Vadym Samoilenko
922ea3c377 Fix model IDs, hangs, deploy script, Docker healthchecks
MODELS (Block B):
- llm_factory.py: replace hardcoded model strings with env vars
  OPENAI_CHAT_MODEL (gpt-5.4-2026-03-05), ANTHROPIC_CHAT_MODEL (claude-sonnet-4-6),
  GEMINI_CHAT_MODEL (gemini-3.1-pro-preview), GEMINI_FLASH_MODEL (gemini-3-flash-preview)
- Fix broken IDs from fc17994: gemini-3-1-pro-preview → gemini-3.1-pro-preview,
  gemini-3-1-flash-live-preview → gemini-3-flash-preview, gpt-5.4 → gpt-5.4-2026-03-05
- Replace gpt-4.1 hardcodes in audio.py + utils.py with OPENAI_LEGACY_MODEL
- Replace hardcoded claude-sonnet-4-6 in studio_generators.py PPTX-from-template
- Replace hardcoded gemini model in gemini_video.py

HANGS (Block C):
- llm_factory.py: add timeout=LLM_TIMEOUT_SECONDS to Gemini branches (was missing)
- pipeline_manager.py: wrap aquery in asyncio.wait_for(timeout=LLAMA_QUERY_TIMEOUT=120s)
- chat.py: wrap query_notebook_pipeline in asyncio.wait_for(CHAT_QUERY_TIMEOUT=130s),
  send {"type":"error"} to client on timeout instead of hanging WS
- background_tasks.py: on startup mark IN_PROGRESS tasks as FAILED ("orphaned on restart")
- api.ts: add axios timeout 60s (was 0 = infinite)
- queryClient.ts: retry:1 + exponential retryDelay (was retry:3)
- notebooks/[id]/page.tsx: podcast poll only while status=processing (was always 5s)
- docker-compose.yml: healthchecks for all services + depends_on service_healthy conditions
- backend/Dockerfile: add --proxy-headers --timeout-keep-alive 65 --ws-ping-interval/timeout

DEPLOY (Block D):
- scripts/deploy.sh: idempotent rolling redeploy (git pull → build → migrate → up → health)
- scripts/rollback.sh: revert to any git SHA
- scripts/README.md: usage table
- .dockerignore: root-level (was missing)
- Retire legacy one-shot scripts → Old Readmes/

DOCS (Block E): Update CLAUDE.md models table + deploy section with new env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:25:30 +01:00
Vadym Samoilenko
fc17994034 Update LLM models to latest versions (2025)
- gpt51-exp/gpt-5 → gpt54-exp/gpt-5.4
- claude45-exp/claude-sonnet-4-5 → claude46-exp/claude-sonnet-4-6
- gemini25-exp/gemini-3-pro-preview → gemini31-exp/gemini-3-1-pro-preview
- gemini/gemini-2.5-flash → gemini31-flash/gemini-3-1-flash-live-preview
- Remove duplicate claude slot
- gemini_video.py: gemini-2.5-pro → gemini-3-1-pro-preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:20:27 +01:00
Vadym Samoilenko
546b1edba4 Fix Studio tiles losing state after chat navigation
Move setStudioOutputs from inside queryFn into a useEffect watching
the query data, so cached results restore tile indicators immediately
on remount without waiting for a network refetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 21:08:56 +00:00
Vadym Samoilenko
6570df22b6 Replace indigo primary color with brand amber #FFC407 across the app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:03:50 +00:00
Vadym Samoilenko
326c713c4d Add PPTX-from-template preview: extract slides JSON for SlidesView
- Add extract_slides_json_from_pptx() to parse generated PPTX content
- from-template endpoint now returns JSON {slides, is_template} and saves
  slides JSON to studio_data["slides"] so SlidesView can render a preview
- Add GET /studio/slides/template/download to serve the stored base64 PPTX
- Frontend: replace blob URL state with isTemplatePptx boolean flag;
  download banner calls downloadWithAuth; SlidesView shows preview below banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:33:54 +00:00
Vadym Samoilenko
f3c71fad64 from-template: show download panel instead of auto-downloading
Store blob URL in state, open slides panel with a download banner
showing 'Presentation from template is ready' + Download button.
No more automatic download on generation complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:25:04 +00:00
Vadym Samoilenko
8a9287bbbc Fix from-template: better filename, don't open old slides panel
- Filename: notebook_{id}_from_template.pptx instead of presentation.pptx
- Remove setActiveStudio('slides') — it was showing stale slides from DB,
  not the just-downloaded template file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:19:32 +00:00
Vadym Samoilenko
5b65d5e629 Improve from-template style fidelity
- Capture font_name in analyze_pptx_template
- Forbid background-covering shapes in Claude prompt
- Instruct Claude to use transparent textbox fills
- Instruct Claude to use exact font names and colors from template
- Clarify that slide master provides the background automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:54:33 +00:00
Vadym Samoilenko
24d0de55d5 Restore setActiveStudio('slides') after from-template download
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:33:45 +00:00
Vadym Samoilenko
f4f1b68f63 Increase from-template max_tokens 8192→16000 to prevent code cut-off
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:31:42 +00:00
Vadym Samoilenko
ead4e75a6c Fix from-template: trigger file download on success
onSuccess was closing the modal but not downloading the blob.
Create object URL, click anchor, revoke URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:27:30 +00:00
Vadym Samoilenko
08135ac60c Fix analyze_pptx_template: avoid slice/index on SlideLayouts
pptx 0.6.23 SlideLayouts.__getitem__ with a slice returns a list
and then calls .rId on it → AttributeError: 'list' object has no
attribute 'rId'. Replace slide_layouts[:10] and slides[0] with
explicit iteration + early break.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:21:11 +00:00
Vadym Samoilenko
d87b2afa78 Pin python-pptx to 0.6.23, update uv.lock
python-pptx 1.0.2 has a bug in rename_slide_parts that causes
'list' object has no attribute rId when add_slide() is called.
0.6.23 is the last stable pre-1.0 release and works correctly.

Also updates uv.lock to properly include all dependencies
including python-pptx which was previously missing from the lock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:16:19 +00:00
Vadym Samoilenko
a99861b2e7 Add traceback logging to from-template error handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:13:44 +00:00
Vadym Samoilenko
de1510c93f Fix: analyze original template_path, not cleaned output_path
Our cleaned zip triggered 'list' object has no attribute 'rId' in
python-pptx 1.x during Presentation() loading. Revert to analyzing
the original template which was working before; it also gives Claude
better style context (original slides contain shape/color samples).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 12:37:37 +00:00
Vadym Samoilenko
fa90d39980 Fix clear_template_slides: use zip manipulation instead of prs.save()
python-pptx 1.x _Relationships changed its iteration protocol (now yields
keys like a Mapping), causing 'list' object has no attribute 'rId' during
prs.save(). Replace pptx-based approach with direct zip editing: remove
slide XML files, rels files, slide relationships from presentation.xml.rels,
sldId elements from presentation.xml, and Override entries from
[Content_Types].xml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 12:26:02 +00:00
Vadym Samoilenko
6c8e274a37 Fix PPTX from-template: remove orphaned slide parts to prevent format error
Move slide-clearing logic into deterministic helper clear_template_slides()
that drops both XML elements and Part relationships (drop_rel) before Claude
is invoked. Claude-generated code now only opens the pre-cleaned file and
adds slides. Also adds .ppt format guard returning HTTP 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 12:07:05 +00:00
Vadym Samoilenko
f7a97070a5 Fix from-template: use shutil.copy + blank layout + textboxes pattern
add_slide with complex layouts causes 'list has no attribute rld' in
python-pptx when layouts have hyperlinks or chart relationships.
New approach: copy template file (preserves slidemaster/theme), delete
existing slides, add new slides only via layout[0] + textboxes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:47:25 +00:00
Vadym Samoilenko
a59cc134b2 Fix from-template: forbid copy.deepcopy on pptx objects in Claude prompt
copy.deepcopy on slide layouts/shapes causes 'list has no attribute rld'
in python-pptx; explicitly prohibit it in the code-gen prompt and remove
'copy' from allowed imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:43:58 +00:00
Vadym Samoilenko
a3c0c63467 Fix from-template subprocess env and show real error in frontend
- studio_generators.py: revert broken PYTHONHOME, clean subprocess env
  so venv Python can find its own site-packages; add stderr/stdout logging
- page.tsx: read Blob response as JSON to surface real backend error detail
  (axios responseType:blob was silently swallowing 422 error messages)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:40:16 +00:00
Vadym Samoilenko
1179e04725 Fix from-template 500, download 401, chat wrong context, and theme consistency
- notebooks.py: catch exception in from-template route → return 422 with detail
- studio_generators.py: add PYTHONHOME to subprocess env, full stderr in error
- pipeline_manager.py: use is_shared_pipeline_async() in query_notebook_pipeline
  so metadata filter is correctly applied after server restart (fixes chat
  returning context from wrong notebook)
- page.tsx + globals.css: add --status-*-bg CSS vars, fix remaining inline hex
  colors in PodcastForm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:28:44 +00:00
Vadym Samoilenko
006a8d5fa4 Fix downloadWithAuth: read token from localStorage instead of useAuthStore.getState()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:14:27 +00:00
Vadym Samoilenko
0db2077bc5 Fix duplicate borderBottom property in admin tab style
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:12:35 +00:00
Vadym Samoilenko
d1e5202454 Update README for v3.1.0: theme system, new Studio features, API endpoints
- Document light/dark theme system and toggle
- Expand Slide Deck description: template generation, per-slide editing, diagrams
- Add mindmap/from-template/edit-slide API endpoints to reference
- Add note about JWT requirement on download endpoints
- Update frontend tech stack entry with CSS custom property system
- Bump version to 3.1.0, date March 15 2026

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:10:16 +00:00
Vadym Samoilenko
04b3a96832 Theme all pages + fix download 401 + fix template 500
Theme (light/dark CSS vars applied to all remaining pages):
- admin/page.tsx: stat cards, tables, tabs, badges → CSS vars
- notebooks/page.tsx: list, model selector, create form → CSS vars
- notebooks/[id]/chat/page.tsx: sidebar, bubbles, input → CSS vars
- shared/page.tsx: cards, buttons → CSS vars
- signup/page.tsx: form, card → CSS vars, matches login style
- login/callback/page.tsx: loading/error panels → CSS vars

Fixes:
- Download 401: replace bare <a href> (no JWT) with JS fetch using
  auth token via downloadWithAuth() for slides PPTX, report PDF, mindmap SVG
- Template 500: refactor generate_pptx_from_template to sync helper
  _generate_pptx_from_template_sync and wrap with asyncio.to_thread so
  the sync Claude API call + subprocess don't block the event loop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:03:03 +00:00
Vadym Samoilenko
e8e6e8240c Add PPTX template generation and per-slide AI editing
Backend:
- studio_generators.py: analyze_pptx_template() extracts layout/color/font
  info from uploaded PPTX; generate_pptx_from_template() calls Claude to write
  python-pptx code and executes it in a subprocess with auto-retry;
  regenerate_single_slide() uses LLM to update one slide without rebuilding deck
- notebooks.py: POST /{id}/studio/slides/from-template (multipart upload) →
  returns finished PPTX file; POST /{id}/studio/slides/edit/{index} → returns
  updated slide JSON and persists to studio_data

Frontend:
- page.tsx: SlidesView gains per-slide "Edit with AI" panel (textarea + update
  button) that calls editSlide API and patches local state without leaving
  preview; StudioCustomizeModal slides section gains a toggle + file picker for
  template upload, wired to slidesFromTemplateMutation
- api.ts: generateSlidesFromTemplate() (multipart), editSlide() added to
  notebookAPI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 23:55:34 +00:00
42 changed files with 2737 additions and 991 deletions

View file

@ -0,0 +1,47 @@
{
"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:*)"
]
}
}

8
.dockerignore Normal file
View file

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

View file

@ -47,22 +47,29 @@ frontend/ (Next.js 15 App Router, React 19, TypeScript, Tailwind 4)
**Server:** `optical-web-1` at `/opt/sandbox-notebookllamalm-nextjs`
```bash
# Full rebuild and deploy
git pull origin main
docker compose build backend # or 'frontend' or both
docker compose up -d
# Standard deploy (git pull + build + up + health check)
ssh michael_clervi@optical-web-1
cd /opt/sandbox-notebookllamalm-nextjs
sudo bash scripts/deploy.sh
# Rebuild only backend (faster, no frontend rebuild needed)
docker compose build backend && docker compose up -d backend
# Rebuild only backend or frontend
sudo bash scripts/deploy.sh --backend-only
sudo bash scripts/deploy.sh --frontend-only
# Check logs
# 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
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 (run when new columns added)
# DB migration (auto-runs in deploy.sh; manual fallback)
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')"
```
@ -72,6 +79,8 @@ 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`
---
@ -122,14 +131,23 @@ All results are stored as JSON in `notebooks.studio_data` TEXT column. Download
## AI Models
| 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 | |
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`)
---
@ -174,3 +192,9 @@ docker compose up -d postgres redis # starts only DB + cache
| `git pull` fails with unstaged changes | `git stash && git pull` |
| Frontend shows old UI after deploy | Frontend container wasn't rebuilt — run `docker compose build frontend && docker compose up -d frontend` |
| Health check fails in deploy script | Endpoint is `/api/health`, not `/health` |
## Knowledge Wiki
A cross-project knowledge base is maintained automatically from all Claude Code sessions.
- **Index:** `/Users/aimpress/Library/Mobile Documents/iCloud~md~obsidian/Documents/VadymSamoilenko/wiki/index.md`
- **Query:** `cd ~/.claude/memory-compiler && uv run python scripts/query.py "your question"`
- Every session in this project automatically feeds the knowledge base.

View file

@ -107,10 +107,11 @@ 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 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
- **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
@ -153,7 +154,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
**Frontend:** Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS 4, React Query, Zustand, MSAL, WebSocket, CSS custom property theme system (light/dark)
**Backend:** FastAPI, SQLAlchemy, Python 3.13, uv package manager
@ -192,6 +193,9 @@ 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
@ -208,6 +212,8 @@ 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
@ -327,4 +333,14 @@ sandbox-notebookllamalm-nextjs/
---
**Version:** 3.0.0 | **Updated:** March 2026 | **Status:** Production
---
## 🎨 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

4
backend/.env.example Normal file
View file

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

View file

@ -21,4 +21,8 @@ RUN mkdir -p conversations failed_uploads
EXPOSE 9000
CMD ["uv", "run", "uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "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"]

View file

@ -7,8 +7,8 @@ requires-python = ">=3.13"
dependencies = [
"audioop-lts>=0.2.1",
"bcrypt>=4.0.1",
"elevenlabs>=2.5.0",
"fastapi>=0.110.0",
"elevenlabs>=2.44.0",
"fastapi>=0.136.1",
"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.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-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-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.27.0",
"uvicorn[standard]>=0.46.0",
"websockets>=12.0",
"python-pptx>=1.0.0",
"python-pptx>=0.6.21,<1.0.0",
"weasyprint>=62.0",
"matplotlib>=3.8.0"
]

View file

@ -3,6 +3,8 @@
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
@ -72,6 +74,20 @@ 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,
@ -82,6 +98,11 @@ 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:
@ -97,12 +118,24 @@ 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:
response = await query_notebook_pipeline(
notebook.pipeline_id,
question,
notebook.model_type,
notebook_id=notebook_id
)
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
if not response:
await websocket.send_json({

View file

@ -1,16 +1,17 @@
"""Notebook CRUD routes for FastAPI"""
from fastapi import APIRouter, HTTPException, Query, Depends
from fastapi import APIRouter, HTTPException, Query, Depends, UploadFile, File, Form
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))
@ -411,12 +412,14 @@ 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
'voice2_id': final_voice2_id,
'user_email': current_user.email,
}
# Log podcast request with voice selection details
@ -796,6 +799,7 @@ 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)
@ -811,6 +815,7 @@ 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)
@ -826,6 +831,7 @@ 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)
@ -841,6 +847,7 @@ 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)
@ -856,6 +863,7 @@ 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)
@ -871,6 +879,7 @@ 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)
@ -886,6 +895,7 @@ 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)
@ -936,3 +946,133 @@ async def download_mindmap(notebook_id: int, current_user: User = Depends(get_cu
headers={"Content-Disposition": f"attachment; filename=notebook_{notebook_id}_mindmap.svg"}
)
class SlideEditRequest(BaseModel):
custom_prompt: str
@router.post("/{notebook_id}/studio/slides/from-template")
async def gen_slides_from_template(
notebook_id: int,
template: UploadFile = File(...),
custom_prompt: str = Form(""),
current_user: User = Depends(get_current_user),
):
"""Generate a PPTX presentation using a user-uploaded template analyzed by Claude."""
import tempfile, os
from fastapi.responses import Response
from studio_generators import generate_pptx_from_template
nb = get_notebook_by_id(notebook_id)
if not nb:
raise HTTPException(status_code=404, detail="Notebook not found")
docs = _get_doc_data(notebook_id)
if not docs:
raise HTTPException(status_code=400, detail="No documents with summaries found")
# Save uploaded template to a temp file
raw_suffix = os.path.splitext(template.filename or "")[1].lower()
if raw_suffix == ".ppt":
raise HTTPException(
status_code=400,
detail="Legacy .ppt format is not supported. Please convert to .pptx first."
)
suffix = raw_suffix if raw_suffix else ".pptx"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_in:
tmp_in.write(await template.read())
template_path = tmp_in.name
with tempfile.NamedTemporaryFile(delete=False, suffix=".pptx") as tmp_out:
output_path = tmp_out.name
try:
await generate_pptx_from_template(
template_path=template_path,
doc_summaries=docs,
model_type=nb.model_type,
custom_prompt=custom_prompt or "",
output_path=output_path,
)
with open(output_path, "rb") as f:
pptx_bytes = f.read()
except Exception as exc:
import traceback as _tb
print(f"[from-template] ERROR: {_tb.format_exc()}", flush=True)
raise HTTPException(status_code=422, detail=str(exc))
finally:
for p in (template_path, output_path):
try:
os.unlink(p)
except Exception:
pass
# Store base64 of the PPTX so the user can re-download later
import base64
from studio_generators import extract_slides_json_from_pptx
_save_studio_key(notebook_id, "slides_template_pptx", {"data": base64.b64encode(pptx_bytes).decode()})
# Extract slide content as JSON for in-browser preview via SlidesView
try:
slides_json = extract_slides_json_from_pptx(pptx_bytes)
except Exception:
slides_json = {"presentation_title": "Presentation from template", "subtitle": "", "slides": []}
_save_studio_key(notebook_id, "slides", slides_json)
return {"slides": slides_json, "is_template": True}
@router.get("/{notebook_id}/studio/slides/template/download")
async def download_template_slides(notebook_id: int, current_user: User = Depends(get_current_user)):
"""Download the PPTX generated from a user template."""
from fastapi.responses import Response
import base64
studio = _get_studio_data(notebook_id)
entry = studio.get("slides_template_pptx")
if not entry or "data" not in entry:
raise HTTPException(status_code=404, detail="No template presentation found")
pptx_bytes = base64.b64decode(entry["data"])
return Response(
content=pptx_bytes,
media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
headers={"Content-Disposition": f"attachment; filename=notebook_{notebook_id}_from_template.pptx"},
)
@router.post("/{notebook_id}/studio/slides/edit/{slide_index}")
async def edit_single_slide(
notebook_id: int,
slide_index: int,
req: SlideEditRequest,
current_user: User = Depends(get_current_user),
):
"""Regenerate a single slide (0-based index) using an AI prompt without rebuilding the whole deck."""
from studio_generators import regenerate_single_slide
nb = get_notebook_by_id(notebook_id)
if not nb:
raise HTTPException(status_code=404, detail="Notebook not found")
studio = _get_studio_data(notebook_id)
if "slides" not in studio or "slides" not in studio["slides"]:
raise HTTPException(status_code=404, detail="Slides not generated yet")
slides = studio["slides"]["slides"]
if slide_index < 0 or slide_index >= len(slides):
raise HTTPException(status_code=400, detail=f"Slide index {slide_index} out of range (0{len(slides)-1})")
docs = _get_doc_data(notebook_id)
updated_slide = await regenerate_single_slide(
current_slide=slides[slide_index],
slide_index=slide_index,
total_slides=len(slides),
doc_summaries=docs or [],
model_type=nb.model_type,
custom_prompt=req.custom_prompt,
)
slides[slide_index] = updated_slide
studio["slides"]["slides"] = slides
_save_studio_key(notebook_id, "slides", studio["slides"])
return updated_slide

View file

@ -86,6 +86,9 @@ 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")
@ -101,6 +104,10 @@ 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":
@ -164,8 +171,9 @@ 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="gpt-4.1", api_key=os.getenv("OPENAI_API_KEY")
model=OPENAI_LEGACY_MODEL, api_key=os.getenv("OPENAI_API_KEY")
).as_structured_llm(MultiTurnConversation)
EL_CLIENT = AsyncElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY"))
PODCAST_GEN = PodcastGenerator(llm=SLLM, client=EL_CLIENT)

View file

@ -621,13 +621,13 @@ async def execute_document_processing_task(task_id: int):
)
extract_duration = time.time() - extract_start
if extraction_output:
if extraction_output and extraction_output.data:
notebook_data = extraction_output.data
logger.info(f" ✓ [LLAMAEXTRACT] aextract → Success ({extract_duration:.1f}s)")
else:
logger.error(f" [LLAMAEXTRACT] aextract → No data returned ({extract_duration:.1f}s)")
update_task_status(task_id, TaskStatus.FAILED, error="LlamaExtract failed")
return
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)
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,6 +700,8 @@ 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')
@ -895,9 +897,20 @@ def get_notebook_processing_tasks(notebook_id: int) -> list:
def retry_pending_tasks():
"""Retry all PENDING document processing tasks on startup"""
"""On startup: mark orphaned IN_PROGRESS tasks as FAILED, then retry PENDING ones."""
db = get_db_session()
try:
# Tasks left IN_PROGRESS from a previous crash will never complete — mark them failed.
stuck = db.query(BackgroundTask).filter(
BackgroundTask.status == TaskStatus.IN_PROGRESS
).all()
if stuck:
print(f"Found {len(stuck)} orphaned IN_PROGRESS task(s) from previous run — marking FAILED")
for task in stuck:
task.status = TaskStatus.FAILED
task.error_message = "orphaned on restart"
db.commit()
pending_tasks = db.query(BackgroundTask).filter(
BackgroundTask.task_type == 'document_processing',
BackgroundTask.status == TaskStatus.PENDING

View file

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

View file

@ -180,13 +180,14 @@ 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 2.5 Pro for analysis (new SDK)
print(f" 🔌 [GEMINI] models.generate_content(model=gemini-2.5-pro, file={uploaded_file.name})")
# 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})")
analysis_start = time.time()
try:
response = client.models.generate_content(
model='gemini-2.5-pro',
model=GEMINI_CHAT_MODEL,
contents=prompt_parts
)
analysis_duration = time.time() - analysis_start

View file

@ -5,115 +5,103 @@ Supports per-notebook model selection
import os
from dotenv import load_dotenv
from typing import Optional, Type
from typing import 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: 'gpt5', 'gpt4o', 'gpt4', 'claude45', 'claude4', 'gemini25', 'gemini', etc.
model_type: 'gpt54-exp', 'gpt4o', 'gpt4', 'claude46-exp', 'gemini31-exp', 'gemini31-flash'
Returns:
LLM instance
"""
# Newest experimental models (may not work if LlamaIndex not updated)
if model_type == 'gpt51-exp':
if model_type == 'gpt54-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")
# 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)
return OpenAI(model=OPENAI_CHAT_MODEL, api_key=api_key, temperature=0.7, timeout=LLM_TIMEOUT)
elif model_type == 'claude45-exp':
elif model_type == 'claude46-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="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",
model=ANTHROPIC_CHAT_MODEL,
api_key=api_key,
temperature=0.7,
max_tokens=8192,
timeout=900.0
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,
)
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)
return OpenAI(
model="gpt-4o",
api_key=api_key,
temperature=0.7,
timeout=900.0
)
else: # gpt4 (legacy)
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=900.0
)
return OpenAI(model="gpt-4", api_key=api_key, temperature=0.7, timeout=LLM_TIMEOUT)
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: 'openai' or 'gemini'
model_type: model alias string
output_class: Pydantic model class for structured output
Returns:
@ -126,47 +114,40 @@ 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 = {
# 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
'gpt54-exp': f'GPT-5.4',
'claude46-exp': 'Claude Sonnet 4.5',
'gemini31-exp': 'Gemini 3.1 Pro Preview',
'gpt4o': 'OpenAI GPT-4o',
'gpt4': 'OpenAI GPT-4',
'claude': 'Claude Sonnet 4.0',
'gemini': 'Google Gemini 2.5 Flash',
'openai': 'OpenAI GPT-4' # Legacy
'gemini31-flash': 'Gemini 3 Flash',
'openai': 'OpenAI GPT-4', # legacy
}
return names.get(model_type, 'Unknown Model')
return names.get(model_type, model_type)
def get_model_emoji(model_type: str) -> str:
"""Get emoji for model type"""
emojis = {
'gpt51-exp': '🚀',
'claude45-exp': '🧠',
'gemini25-exp': '💎',
'gpt54-exp': '🚀',
'claude46-exp': '🧠',
'gemini31-exp': '💎',
'gpt4o': '',
'gpt4': '🤖',
'claude': '🧠',
'gemini': '',
'openai': '🤖'
'gemini31-flash': '',
'openai': '🤖',
}
return emojis.get(model_type, '🤖')
# Cost estimates per 1M tokens
# Cost estimates per 1M tokens (approximate)
MODEL_COSTS = {
# 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'}
'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'},
}

View file

@ -101,8 +101,11 @@ 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
@ -184,7 +187,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 ['claude45-exp', 'claude', 'gemini25-exp', 'gemini']:
if model_type in ['claude46-exp', 'gemini31-exp', 'gemini31-flash']:
llm = get_llm_by_type(model_type)
# Modify the last message to explicitly request JSON
@ -208,6 +211,9 @@ 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]}")
@ -232,6 +238,9 @@ 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
@ -309,6 +318,9 @@ Format as a natural conversation with approximately {target_turns} speaking turn
try:
llm = get_llm_by_type(model_type)
response = await llm.achat(messages=messages)
from cost_tracker import record, get_user_ctx, model_id_for, extract_llama_tokens
inp, out = extract_llama_tokens(response)
await record(model=model_id_for(model_type), user_external_id=get_user_ctx(), input_tokens=inp, output_tokens=out)
script = response.message.content
print(f" ✓ Script generated: {len(script)} chars (~{target_turns} turns)")
return script

View file

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

View file

@ -12,6 +12,7 @@ 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
@ -461,7 +462,7 @@ def get_notebook_query_engine(
Args:
pipeline_id: The notebook's pipeline ID
model_type: 'gpt5-exp', 'gpt4o', 'claude', 'gemini', etc.
model_type: 'gpt54-exp', 'gpt4o', 'claude46-exp', 'gemini31-flash', etc.
notebook_id: Optional notebook ID for metadata filtering (used with shared pipeline)
Returns:
@ -516,7 +517,8 @@ async def query_notebook_pipeline(
pipeline_id: str,
question: str,
model_type: str = 'gpt4o',
notebook_id: Optional[int] = None
notebook_id: Optional[int] = None,
user_external_id: Optional[str] = None,
) -> Optional[str]:
"""
Query a specific notebook's pipeline
@ -537,7 +539,18 @@ async def query_notebook_pipeline(
print("Error: No pipeline_id provided")
return "Error: Notebook has no pipeline configured."
query_engine = get_notebook_query_engine(pipeline_id, model_type, notebook_id)
# 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)
if not query_engine:
print(f"Error: Could not create query engine for pipeline {pipeline_id}")
@ -545,7 +558,17 @@ async def query_notebook_pipeline(
print(f"Querying pipeline {pipeline_id} with {model_type} model, question: {question[:50]}...")
response = await query_engine.aquery(question)
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."
print(f"Response object type: {type(response)}")
print(f"Response has .response attr: {hasattr(response, 'response')}")
@ -553,6 +576,26 @@ async def query_notebook_pipeline(
print(f"Response.response value: {response.response[:100] if response.response else 'None or empty'}")
print(f"Response has source_nodes: {hasattr(response, 'source_nodes')}")
# cost tracking
try:
from cost_tracker import record, model_id_for, extract_llama_tokens
if user_external_id:
_inp, _out = extract_llama_tokens(response)
if not _inp and not _out:
_inp = len(augmented_question) // 4
_out = len(response.response or "") // 4
print(f"[CT] recording model={model_id_for(model_type)} user={user_external_id} inp={_inp} out={_out}")
import asyncio as _asyncio
_asyncio.ensure_future(record(
model=model_id_for(model_type),
user_external_id=user_external_id,
input_tokens=_inp,
output_tokens=_out,
metadata={"notebook_id": notebook_id},
))
except Exception as _e:
print(f"cost_tracker chat record error (ignored): {_e}")
if not response or not response.response:
print("WARNING: Response is empty or None!")
return "Sorry, I couldn't find an answer to your question."

View file

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

View file

@ -81,7 +81,8 @@ if (
and os.getenv("LLAMACLOUD_PIPELINE_ID", None)
and os.getenv("OPENAI_API_KEY", None)
):
LLM = OpenAIResponses(model="gpt-4.1", api_key=os.getenv("OPENAI_API_KEY"))
from llm_factory import OPENAI_LEGACY_MODEL
LLM = OpenAIResponses(model=OPENAI_LEGACY_MODEL, 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")

751
backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,12 @@ 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
@ -20,6 +26,12 @@ 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:
@ -38,9 +50,17 @@ services:
- podcasts_data:/app/conversations
- uploads_data:/app/failed_uploads
depends_on:
- postgres
- redis
postgres:
condition: service_healthy
redis:
condition: service_healthy
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:
@ -55,8 +75,15 @@ 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
backend:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:4000/notebookllama', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
interval: 20s
timeout: 10s
retries: 5
start_period: 60s
jaeger:
image: jaegertracing/all-in-one:latest

View file

@ -15,11 +15,11 @@ export default function AdminPage() {
// Check if user is admin - strict check
if (!user || !user.is_admin) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div style={{ minHeight: '100vh', background: 'var(--bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center">
<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">
<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)' }}>
Go to My Notebooks
</Link>
</div>
@ -85,87 +85,107 @@ export default function AdminPage() {
});
return (
<div className="min-h-screen bg-gray-50 py-8">
<div style={{ minHeight: '100vh', background: 'var(--bg)', paddingTop: '2rem', paddingBottom: '2rem' }}>
<div className="container mx-auto px-4 max-w-7xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2"> Admin Dashboard</h1>
<p className="text-gray-700">System statistics and monitoring</p>
<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>
</div>
{/* System Health */}
<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" />
<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)' }} />
<span>System Health</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<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'}`}>
<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)',
}}>
{health?.status || 'unknown'}
</span>
</div>
<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 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>
<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 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>
</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="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" />
<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" />
</div>
{/* Cost Estimation */}
{costs && (
<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" />
<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)' }} />
<span>Estimated Costs</span>
</h2>
<div className="grid grid-cols-4 gap-4">
<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 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>
<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 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>
<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 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>
<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 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>
</div>
</div>
)}
{/* Recent Activity Tabs */}
<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">
<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' }}>
{['users', 'notebooks', 'documents'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
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'}`}
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',
}}
>
{tab}
</button>
@ -173,27 +193,31 @@ export default function AdminPage() {
</nav>
</div>
<div className="mt-4">
<div style={{ marginTop: '1rem' }}>
{/* Users Tab */}
{activeTab === 'users' && users && (
<div className="space-y-2">
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{users.map((u: any) => (
<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 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>
<p className="font-medium text-gray-900">
<p style={{ fontWeight: 500, color: 'var(--fg)' }}>
{u.username}
{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>}
{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>
)}
</p>
<p className="text-sm text-gray-700">{u.email}</p>
<p className="text-xs text-gray-700">
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>{u.email}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--fg-muted)' }}>
{formatDistanceToNow(new Date(u.created_at), { addSuffix: true })}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
onClick={async () => {
const newStatus = !u.is_admin;
@ -206,11 +230,18 @@ export default function AdminPage() {
}
}
}}
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'
}`}
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)',
}}
>
{u.is_admin ? 'Make User' : 'Make Admin'}
</button>
@ -227,11 +258,19 @@ export default function AdminPage() {
}
}}
disabled={u.id === user?.id}
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`}
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)',
}}
>
{u.is_suspended ? 'Unsuspend' : 'Suspend'}
</button>
@ -247,7 +286,17 @@ export default function AdminPage() {
}
}}
disabled={u.id === user?.id}
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"
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)',
}}
>
Delete
</button>
@ -259,17 +308,17 @@ export default function AdminPage() {
{/* Notebooks Tab */}
{activeTab === 'notebooks' && notebooks && (
<div className="space-y-2">
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{notebooks.map((nb: any) => (
<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 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>
<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>
<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>
</div>
</div>
<span className="text-sm text-gray-700">
<span style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>
{formatDistanceToNow(new Date(nb.created_at), { addSuffix: true })}
</span>
</div>
@ -279,17 +328,17 @@ export default function AdminPage() {
{/* Documents Tab */}
{activeTab === 'documents' && documents && (
<div className="space-y-2">
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{documents.map((doc: any) => (
<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 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>
<p className="font-medium text-gray-900">{doc.filename}</p>
<p className="text-sm text-gray-700">by {doc.owner_username}</p>
<p style={{ fontWeight: 500, color: 'var(--fg)' }}>{doc.filename}</p>
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>by {doc.owner_username}</p>
</div>
</div>
<span className="text-sm text-gray-700">
<span style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>
{formatDistanceToNow(new Date(doc.created_at), { addSuffix: true })}
</span>
</div>
@ -301,22 +350,22 @@ export default function AdminPage() {
{/* Background Tasks */}
{tasks && tasks.length > 0 && (
<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">
<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' }}>
{tasks.map((task: any) => (
<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">
<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' }}>
{getTaskIcon(task.status)}
<span className="font-medium text-gray-900">{task.task_type}</span>
<span style={{ fontWeight: 500, color: 'var(--fg)' }}>{task.task_type}</span>
</div>
{task.error_message && (
<p className="text-xs text-red-600 mt-1">{task.error_message}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--danger)', marginTop: '0.25rem' }}>{task.error_message}</p>
)}
</div>
<div className="flex items-center space-x-4 text-sm text-gray-700">
<span className={getTaskStatusClass(task.status)}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', fontSize: '0.875rem', color: 'var(--fg-muted)' }}>
<span style={getTaskStatusStyle(task.status)}>
{task.status}
</span>
<span>{formatDistanceToNow(new Date(task.created_at), { addSuffix: true })}</span>
@ -332,20 +381,26 @@ export default function AdminPage() {
}
function StatCard({ icon: Icon, title, value, color }: any) {
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',
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)',
};
return (
<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 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>
<p className="text-gray-700 text-sm mb-1">{title}</p>
<p className="text-3xl font-bold text-gray-900">{value.toLocaleString()}</p>
<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>
</div>
);
}
@ -353,24 +408,24 @@ function StatCard({ icon: Icon, title, value, color }: any) {
function getTaskIcon(status: string) {
switch (status) {
case 'completed':
return <span className="text-green-600"></span>;
return <span style={{ color: 'var(--success)' }}></span>;
case 'in_progress':
return <span className="text-blue-600">🔵</span>;
return <span style={{ color: 'var(--info)' }}>🔵</span>;
case 'pending':
return <span className="text-yellow-600">🟡</span>;
return <span style={{ color: 'var(--warning)' }}>🟡</span>;
case 'failed':
return <span className="text-red-600"></span>;
return <span style={{ color: 'var(--danger)' }}></span>;
default:
return <span></span>;
}
}
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',
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 },
};
return classes[status] || 'text-gray-700';
return styles[status] || { color: 'var(--fg-muted)' };
}

View file

@ -20,11 +20,11 @@
--border: #E2E8F0;
--border-strong: #CBD5E1;
/* Brand / Primary — indigo */
--primary: #6366F1;
--primary-fg: #FFFFFF;
--primary-hover: #4F46E5;
--primary-light: #EEF2FF;
/* Brand / Primary — amber */
--primary: #FFC407;
--primary-fg: #1E293B;
--primary-hover: #E6B000;
--primary-light: rgba(255, 196, 7, 0.12);
/* Accent — amber (brand color) */
--accent: #FFC407;
@ -35,6 +35,10 @@
--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);
@ -68,11 +72,11 @@
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.15);
/* Brand / Primary — lighter indigo for dark bg */
--primary: #818CF8;
--primary-fg: #0F0F1A;
--primary-hover: #A5B4FC;
--primary-light: rgba(99, 102, 241, 0.12);
/* Brand / Primary — amber */
--primary: #FFC407;
--primary-fg: #0A0A0F;
--primary-hover: #FFD040;
--primary-light: rgba(255, 196, 7, 0.12);
/* Accent */
--accent: #FFC407;
@ -83,6 +87,10 @@
--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);
@ -172,7 +180,7 @@ body {
.input-base::placeholder { color: var(--fg-subtle); }
.input-base:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
box-shadow: 0 0 0 3px rgba(255, 196, 7, 0.25);
}
/* ── Prose (markdown) ────────────────────────────────────────────────────── */

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import { authAPI } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { BookOpen, UserPlus } from 'lucide-react';
import { BookOpen, UserPlus, ArrowLeft } from 'lucide-react';
export default function SignupPage() {
const router = useRouter();
@ -51,72 +51,79 @@ export default function SignupPage() {
};
return (
<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">
<div className="min-h-screen flex items-center justify-center py-12 px-4" style={{ background: 'var(--bg)' }}>
<div style={{ width: '100%', maxWidth: 400 }}>
{/* Logo */}
<div className="text-center mb-8">
<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 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>
</div>
{/* Signup Form */}
<div className="bg-white rounded-lg shadow-lg p-8">
{/* Card */}
<div className="card p-8">
{error && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg">
<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)' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--fg)' }}>
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
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"
className="input-base"
placeholder="you@example.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--fg)' }}>
Username
</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
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"
className="input-base"
placeholder="yourname"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--fg)' }}>
Password
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
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"
className="input-base"
placeholder="••••••••"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--fg)' }}>
Confirm Password
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
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"
className="input-base"
placeholder="••••••••"
required
/>
</div>
@ -124,27 +131,27 @@ export default function SignupPage() {
<button
type="submit"
disabled={signupMutation.isPending}
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"
className="btn-primary w-full flex items-center justify-center gap-2 py-2.5"
>
<UserPlus className="w-5 h-5" />
<span>{signupMutation.isPending ? 'Creating account...' : 'Create Account'}</span>
<UserPlus className="w-4 h-4" />
<span>{signupMutation.isPending ? 'Creating account' : 'Create Account'}</span>
</button>
</form>
<div className="mt-6 text-center">
<p className="text-gray-600 text-sm">
<p className="text-sm" style={{ color: 'var(--fg-muted)' }}>
Already have an account?{' '}
<Link href="/login" className="text-blue-600 hover:underline font-semibold">
<Link href="/login" className="font-semibold hover:underline" style={{ color: 'var(--primary)' }}>
Sign in
</Link>
</p>
</div>
</div>
{/* Quick Access */}
<div className="mt-6 text-center">
<Link href="/" className="text-gray-600 hover:text-gray-900 text-sm">
Back to home
<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>
</div>
</div>

View file

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

View file

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

36
scripts/README.md Normal file
View file

@ -0,0 +1,36 @@
# 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/`.

125
scripts/deploy.sh Executable file
View file

@ -0,0 +1,125 @@
#!/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>"

56
scripts/rollback.sh Executable file
View file

@ -0,0 +1,56 @@
#!/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"