diff --git a/.gitignore b/.gitignore index a213e3f..d028be8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ data/notegen.db-shm # DB 备份(e2e/迁移留存),同样含 bcrypt 哈希,严禁入库 data/*.e2ebak data/user_notes/ +backups/*.zip +.playwright-cli/ models/ models_msc_cache/ wheels/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a2b4244 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +All notable project changes are tracked here. + +## v1.0.0-demo - 2026-06-25 + +This is the first demo checkpoint for a reproducible local NoteGen release. The goal is not feature expansion; it is a stable version that can be built, smoked, demonstrated, and rolled back. + +### Added + +- Added `scripts/smoke_local.ps1` for local release smoke checks: + - Redis TCP availability. + - API `/api/health`. + - single worker process check. + - public notes API. + - optional login and private notes check. + - DOCX export endpoint. + - production web build. +- Added local release documentation: + - `docs/SMOKE_TEST.md` + - `docs/DEPLOY_LOCAL.md` +- Added v1 demo artifacts under `output/playwright/`: + - `v1-home.png` + - `v1-login-gate.png` + - `v1-workspace.png` + - `v1-demo.gif` + - `v1-export.docx` + +### Changed + +- Migrated the core QA entry in `web/components/ChatPanel.tsx` from legacy `--bg` / `--fg` / `--accent` tokens to Warm Fold `--wf-*` tokens. +- Migrated the `/notes/[id]` workspace shell in `web/components/NoteWorkspace.tsx` to Warm Fold tokens for error state, skeleton state, top controls, right-side tools, and the mobile chapter drawer. +- Migrated `web/components/CreateNotePanel.tsx`, used by `/notebooks`, from legacy tokens and `apple-button` to Warm Fold tokens. +- Reworked `web/lib/progress.ts` to read chapter progress through `useSyncExternalStore`, keeping localStorage behavior while clearing the React hooks lint baseline. +- Removed unused legacy visual components after confirming no runtime references: + - `web/components/AskBar.tsx` + - `web/components/FluidBG.tsx` + - `web/components/ParticleBG.tsx` + +### Fixed + +- Cleared blocking ESLint errors in: + - `web/components/ChatPanel.tsx` + - `web/lib/progress.ts` +- Adjusted local smoke worker detection for Windows virtualenv behavior, where a venv shim and the real Python child process can both contain `run_worker.py` in their command line. +- Adjusted DOCX smoke verification to use `Invoke-WebRequest -UseBasicParsing`, avoiding a Windows PowerShell null-reference failure on binary responses. + +### Verified + +Latest verified local commands: + +```powershell +cd web +npm run lint +npm run test +npx tsc --noEmit +npm run build +``` + +Latest local smoke command: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\smoke_local.ps1 +``` + +Smoke result at checkpoint time: + +- Failures: `0` +- Warnings: login skipped unless `NOTEGEN_SMOKE_EMAIL` and `NOTEGEN_SMOKE_PASSWORD` are provided. + +### Known Baseline + +- `npm run lint` exits successfully but still reports non-blocking warnings for existing `` usage and one unused ESLint disable directive outside the files touched for this checkpoint. +- Learning progress is still localStorage-backed, not account-synchronized. +- Full GPU video generation is intentionally not part of the automated smoke script; the script checks service readiness and lightweight API behavior. diff --git a/README.md b/README.md index f4739db..57d2282 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,12 @@ cd web; npm run dev # 4. Web → http://localhost:3000 输出在 `data/outputs/.large-v3.neural.texttile.mm.vl.md`。 +## v1 checkpoint docs + +- [CHANGELOG.md](CHANGELOG.md) records the `v1.0.0-demo` checkpoint and known baseline. +- [docs/SMOKE_TEST.md](docs/SMOKE_TEST.md) explains the local smoke path and expected results. +- [docs/DEPLOY_LOCAL.md](docs/DEPLOY_LOCAL.md) documents the local deployment/startup flow. + ## API 概览 | 分组 | 端点 | diff --git a/docs/DEPLOY_LOCAL.md b/docs/DEPLOY_LOCAL.md new file mode 100644 index 0000000..2642e86 --- /dev/null +++ b/docs/DEPLOY_LOCAL.md @@ -0,0 +1,199 @@ +# Local Deployment + +This document describes how to run NoteGen locally for the v1 demo checkpoint. + +## Architecture + +The local stack has four moving parts: + +```text +Browser -> Next.js web (:3000) + -> FastAPI API (:8000) + -> Redis/RQ (:6379) + -> one Python worker for qa + default queues +``` + +The worker stays on the host machine because it owns the GPU path. Redis can run in Docker. The API and web app can run either directly on the host or through the `full` Docker Compose profile. + +## Prerequisites + +- Windows 11. +- NVIDIA GPU for full video pipeline runs. +- Python 3.10 virtualenv at `.venv`. +- Node.js 18+ with dependencies installed in `web`. +- Docker Desktop. +- `ffmpeg` available on `PATH`. +- Local model directories under `models/` for full pipeline and QA generation. + +Install dependencies: + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +cd web +npm install +``` + +Install the CUDA PyTorch build separately when setting up a new machine: + +```powershell +pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 +``` + +## Recommended Local Demo Startup + +Open separate terminals from the repository root. + +Terminal 1: Redis + +```powershell +docker compose up -d redis +``` + +Terminal 2: API + +```powershell +.\.venv\Scripts\python.exe server.py +``` + +For a clean smoke run without automatic maintenance or backup side effects: + +```powershell +$env:NOTEGEN_AUTO_MAINTENANCE="0" +$env:NOTEGEN_AUTO_BACKUP="0" +.\.venv\Scripts\python.exe server.py +``` + +Terminal 3: Worker + +```powershell +.\.venv\Scripts\python.exe scripts\run_worker.py +``` + +Only run one worker. The v1 GPU model assumes serial execution across QA and generation jobs. + +Terminal 4: Web + +```powershell +cd web +npm run dev +``` + +Open: + +```text +http://localhost:3000 +``` + +## Production Build Check + +Before a demo checkpoint, verify the web app builds: + +```powershell +cd web +npm run lint +npm run test +npx tsc --noEmit +npm run build +``` + +## Full Compose Option + +Redis, API, and web can be started through Docker Compose: + +```powershell +docker compose --profile full up -d --build +``` + +The worker still runs on the host: + +```powershell +.\.venv\Scripts\python.exe scripts\run_worker.py +``` + +Use the full Compose path when you want to test container wiring. Use the host path when you want the shortest local iteration loop. + +## Environment Variables + +Common local variables: + +| Variable | Default | Purpose | +|---|---:|---| +| `NOTEGEN_REDIS_URL` | `redis://127.0.0.1:6379/0` | Redis queue backend. | +| `NOTEGEN_DB_PATH` | `data/notegen.db` | SQLite database path. | +| `NOTEGEN_COOKIE_SECURE` | `0` | Set to `1` only behind HTTPS. | +| `NOTEGEN_VERIFY_BASE` | `http://localhost:3000` | Base URL used in email verification links. | +| `NOTEGEN_SMTP_HOST` / `NOTEGEN_SMTP_USER` / `NOTEGEN_SMTP_PASS` | unset | SMTP settings for real email verification. | +| `NOTEGEN_AUTO_MAINTENANCE` | `1` | Set to `0` to stop startup/daily raw cleanup. | +| `NOTEGEN_AUTO_BACKUP` | `1` | Set to `0` to stop startup/daily backup. | +| `NOTEGEN_BACKUP_DIR` | `backups/` | Backup output directory. | +| `NOTEGEN_MIN_FREE_RATIO` | `0.15` | Disk low-water mark. | +| `NEXT_PUBLIC_API_URL` | `http://localhost:8000` | API URL baked into the web build. | + +## Health Check + +Verify API and Redis: + +```powershell +Invoke-RestMethod http://127.0.0.1:8000/api/health +``` + +Expected shape: + +```json +{ + "ok": true, + "redis": true, + "queue_depth": 0, + "disk": { + "low": false + } +} +``` + +Run the smoke script after all services are up: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\smoke_local.ps1 +``` + +## Shutdown + +Stop Docker Redis: + +```powershell +docker compose down +``` + +Stop host API, worker, and web processes with `Ctrl+C` in their terminals. + +If they were started in the background, find them with: + +```powershell +Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -match 'server\.py|run_worker\.py|next' } | + Select-Object ProcessId,CommandLine +``` + +Then stop a specific process: + +```powershell +Stop-Process -Id +``` + +## Git Hygiene + +Running the local stack can change runtime data: + +- Redis writes to `data/redis`. +- API startup can create backups in `backups/`. +- maintenance can remove old files under `data/raw` and `data/audio`. + +Before committing, check: + +```powershell +git status --short +``` + +Do not include runtime data in a v1 code/docs commit unless that is the explicit intent. diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md new file mode 100644 index 0000000..e87d5f8 --- /dev/null +++ b/docs/SMOKE_TEST.md @@ -0,0 +1,166 @@ +# Local Smoke Test + +This guide describes the v1 local smoke path for NoteGen. It verifies that the local stack is ready for a demo without running a full GPU video pipeline. + +## What It Checks + +`scripts/smoke_local.ps1` checks: + +- Redis is reachable on `127.0.0.1:6379`. +- API health responds from `http://127.0.0.1:8000/api/health`. +- Exactly one root `scripts/run_worker.py` process is running. +- Public notes can be read. +- Optional login works when smoke credentials are provided. +- Private notes can be read after login. +- DOCX export returns a valid binary response. +- `web` can produce a production build unless `-SkipBuild` is passed. + +The script does not enqueue a full video generation job and does not run the GPU-heavy pipeline. + +## Prerequisites + +- Windows PowerShell. +- Docker Desktop running. +- Python virtualenv installed at `.venv`. +- Frontend dependencies installed in `web/node_modules`. +- Redis, API, and one worker started. + +Start the services in separate terminals: + +```powershell +docker compose up -d redis +.\.venv\Scripts\python.exe server.py +.\.venv\Scripts\python.exe scripts\run_worker.py +cd web +npm run dev +``` + +For a release smoke where you do not want startup maintenance or automatic backups to change local data, start the API with: + +```powershell +$env:NOTEGEN_AUTO_MAINTENANCE="0" +$env:NOTEGEN_AUTO_BACKUP="0" +.\.venv\Scripts\python.exe server.py +``` + +## Run The Smoke + +Run the full smoke from the repository root: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\smoke_local.ps1 +``` + +Run a faster smoke that skips the web production build: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\smoke_local.ps1 -SkipBuild +``` + +Run against a different API base: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\smoke_local.ps1 -ApiBase http://127.0.0.1:8000 +``` + +## Optional Login Check + +Set a verified test account before running the script: + +```powershell +$env:NOTEGEN_SMOKE_EMAIL="demo@example.com" +$env:NOTEGEN_SMOKE_PASSWORD="your-password" +powershell -ExecutionPolicy Bypass -File scripts\smoke_local.ps1 +``` + +If these variables are not set, the login and private-notes checks are skipped with a warning. That warning is acceptable for an unauthenticated infrastructure smoke. + +## Expected Result + +A passing infrastructure smoke ends with: + +```text +== Summary == +Failures: 0 +``` + +Warnings are acceptable when they are intentional, for example: + +- login skipped because no smoke credentials were provided. +- web build skipped because `-SkipBuild` was passed. + +## Manual Browser Checks + +After the script passes, use the browser for the parts that require real UI interaction: + +1. Open `http://localhost:3000/notebooks`. +2. Confirm public notes and the create-note entry render. +3. Open an existing note. +4. Confirm video playback and seeking. +5. Confirm chapter navigation and transcript highlighting. +6. Export Markdown. +7. Export Word. +8. Create or copy a share link and open `/s/{token}`. +9. If logged in, ask one QA question and confirm timestamp citations seek the video. + +The current v1 demo artifacts are stored in `output/playwright/`: + +- `v1-home.png` +- `v1-login-gate.png` +- `v1-workspace.png` +- `v1-demo.gif` +- `v1-export.docx` + +## Troubleshooting + +### Redis TCP Fails + +Start Redis: + +```powershell +docker compose up -d redis +``` + +### API Health Fails + +Start the API from the repository root: + +```powershell +.\.venv\Scripts\python.exe server.py +``` + +Then verify: + +```powershell +Invoke-RestMethod http://127.0.0.1:8000/api/health +``` + +### Worker Check Fails + +Start exactly one worker: + +```powershell +.\.venv\Scripts\python.exe scripts\run_worker.py +``` + +Do not run multiple workers on one GPU. The current v1 architecture assumes single-worker serial execution. + +### DOCX Export Fails + +Check that the API is running and that `python-docx` is installed in the virtualenv: + +```powershell +.\.venv\Scripts\python.exe -c "import docx; print('ok')" +``` + +### Web Build Fails + +Run the frontend checks directly: + +```powershell +cd web +npm run lint +npm run test +npx tsc --noEmit +npm run build +``` diff --git a/docs/frontend-redesign.md b/docs/frontend-redesign.md index 5085174..362c067 100644 --- a/docs/frontend-redesign.md +++ b/docs/frontend-redesign.md @@ -1,5 +1,10 @@ # NoteGen 前端重设计方案(NotebookLM 风格) +> 2026-06-25 v1 收敛更新:`FluidBG.tsx`、`ParticleBG.tsx`、`AskBar.tsx` +> 已确认无运行代码引用并删除。核心页面已完成 Warm Fold token 迁移: +> `ChatPanel`、`NoteWorkspace`、`CreateNotePanel`。剩余未完成项集中在浏览器 +> walkthrough、截图/GIF、a11y 复查和可提交工作树收敛。 + > 2026-06-10 · 参照 [NotebookLM](https://notebooklm.google/) · 配套视觉稿见会话内 mockup > 范围:首页(笔记本库)+ 笔记详情(三栏工作台)两个核心页,其余页面只做 token 级统一 > 2026-06-15 状态更新:P0/P1 主体已落地,实际产品入口调整为 `/` 精简 landing、 diff --git a/docs/memory/2026-06-25-v1-demo-checkpoint.md b/docs/memory/2026-06-25-v1-demo-checkpoint.md new file mode 100644 index 0000000..60ceddf --- /dev/null +++ b/docs/memory/2026-06-25-v1-demo-checkpoint.md @@ -0,0 +1,165 @@ +# 2026-06-25 v1 Demo Checkpoint + +## Context + +This memory records the v1 release-convergence pass after the Warm Fold UI work, +QA/export/share implementation, and local smoke automation work. + +The goal was not to add more product surface. The goal was to make the current +project easier to verify, demonstrate, and roll back. + +## Completed + +- Cleared the active frontend lint baseline in `web/components/ChatPanel.tsx` + and `web/lib/progress.ts`. +- Migrated the core workspace UI away from mixed legacy visual tokens: + - `web/components/ChatPanel.tsx` + - `web/components/NoteWorkspace.tsx` + - `web/components/CreateNotePanel.tsx` +- Removed unused legacy visual components: + - `web/components/AskBar.tsx` + - `web/components/FluidBG.tsx` + - `web/components/ParticleBG.tsx` +- Added `scripts/smoke_local.ps1` for repeatable local infrastructure smoke. +- Added release docs: + - `CHANGELOG.md` + - `docs/SMOKE_TEST.md` + - `docs/DEPLOY_LOCAL.md` +- Linked the v1 checkpoint docs from `README.md`. +- Ignored generated backup zip files with `backups/*.zip`. + +## Verification + +Frontend verification passed after the code changes: + +```powershell +cd web +npm run lint +npm run test +npx tsc --noEmit +npm run build +``` + +Results: + +- ESLint completed with 0 errors. +- The remaining ESLint output was the existing warning baseline: + - `` usage warnings in image-heavy UI files. + - One unused eslint-disable warning in `TranscriptPanel`. +- Vitest passed 6 files / 17 tests. +- TypeScript passed. +- Next production build passed. + +Token migration scan passed for the migrated core surfaces: + +```powershell +rg "var\(--(bg|fg|accent|border|shadow|on-accent)|apple-card|apple-button|tag-chip" ` + web\components\ChatPanel.tsx ` + web\components\NoteWorkspace.tsx ` + web\components\CreateNotePanel.tsx ` + web\app\notebooks ` + web\app\login ` + web\app\register ` + web\app\page.tsx -n +``` + +Result: no matches. + +## Smoke Status + +`scripts/smoke_local.ps1` passed earlier in this pass when Redis, API, and one +worker were running. It verified: + +- Redis TCP availability. +- API `/api/health`. +- exactly one root worker process. +- public notes API. +- DOCX export. +- web production build. + +After Redis was started again, the final rerun also passed: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\smoke_local.ps1 +``` + +Result: + +- Failures: 0 +- Warnings: 1 +- Warning: authenticated session was skipped because no + `NOTEGEN_SMOKE_EMAIL` / `NOTEGEN_SMOKE_PASSWORD` credentials were provided. + +Current honest status: + +- Code-level v1 checkpoint verification is green. +- Automated local smoke exists and passes with Redis, API, and one worker. +- Authenticated login/private-notes smoke still needs configured smoke + credentials. +- Browser walkthrough screenshots and a lightweight demo GIF are captured. + +## Browser Walkthrough + +After the automated smoke passed, a production Next server was started with +Redis, API, and one worker available. Playwright CLI was used at a 1440 x 1000 +viewport. + +Captured artifacts: + +- `output/playwright/v1-home.png` +- `output/playwright/v1-login-gate.png` +- `output/playwright/v1-workspace.png` +- `output/playwright/v1-demo.gif` +- `output/playwright/v1-export.docx` + +Validated: + +- `/` loads with title `NoteGen · 教学视频结构化笔记`. +- `/notebooks` redirects unauthenticated users to `/login?next=%2Fnotebooks`. +- `/notes/BV141Ly6LE7x_p0` loads the three-column workspace. +- The source/chapter rail, note body, video panel, and tools panel render. +- The next-chapter control seeks the video to `01:07` and updates the current + chapter card to `AI 订阅详情`. +- Word export downloads a DOCX file successfully. + +Observed console errors: + +- `401 Unauthorized` for `/api/auth/me` in unauthenticated mode. +- `401 Unauthorized` for `/api/bookmarks` in unauthenticated mode. + +These match the current unauthenticated walkthrough. Authenticated notebooks, +bookmarks, share creation, and QA polling still require a configured smoke user. + +## Worktree Notes + +The expected code/docs changes are: + +- `.gitignore` +- `README.md` +- `CHANGELOG.md` +- `docs/SMOKE_TEST.md` +- `docs/DEPLOY_LOCAL.md` +- `docs/memory/2026-06-25-v1-demo-checkpoint.md` +- `scripts/smoke_local.ps1` +- `web/components/ChatPanel.tsx` +- `web/components/CreateNotePanel.tsx` +- `web/components/NoteWorkspace.tsx` +- `web/lib/progress.ts` +- deleted `web/components/AskBar.tsx` +- deleted `web/components/FluidBG.tsx` +- deleted `web/components/ParticleBG.tsx` +- demo artifacts under `output/playwright/` + +Runtime Redis files may also appear modified after local service runs: + +- `data/redis/appendonlydir/appendonly.aof.1.incr.aof` +- `data/redis/dump.rdb` + +Do not include those Redis runtime files in a code/docs checkpoint commit unless +the intent is to update repository-seeded Redis state. + +## Next + +1. Add smoke credentials and rerun authenticated smoke. +2. Commit code/docs and demo artifacts without Redis runtime state. +3. Tag or branch the resulting checkpoint as `v1.0.0-demo`. diff --git a/output/playwright/v1-demo.gif b/output/playwright/v1-demo.gif new file mode 100644 index 0000000..a295644 Binary files /dev/null and b/output/playwright/v1-demo.gif differ diff --git a/output/playwright/v1-export.docx b/output/playwright/v1-export.docx new file mode 100644 index 0000000..0ddc3b4 Binary files /dev/null and b/output/playwright/v1-export.docx differ diff --git a/output/playwright/v1-home.png b/output/playwright/v1-home.png new file mode 100644 index 0000000..8181113 Binary files /dev/null and b/output/playwright/v1-home.png differ diff --git a/output/playwright/v1-login-gate.png b/output/playwright/v1-login-gate.png new file mode 100644 index 0000000..153300b Binary files /dev/null and b/output/playwright/v1-login-gate.png differ diff --git a/output/playwright/v1-workspace.png b/output/playwright/v1-workspace.png new file mode 100644 index 0000000..a39fd00 Binary files /dev/null and b/output/playwright/v1-workspace.png differ diff --git a/scripts/smoke_local.ps1 b/scripts/smoke_local.ps1 new file mode 100644 index 0000000..0306ee2 --- /dev/null +++ b/scripts/smoke_local.ps1 @@ -0,0 +1,157 @@ +param( + [string]$ApiBase = "http://127.0.0.1:8000", + [string]$WebDir = (Join-Path $PSScriptRoot "..\web"), + [string]$Email = $env:NOTEGEN_SMOKE_EMAIL, + [string]$Password = $env:NOTEGEN_SMOKE_PASSWORD, + [switch]$SkipBuild +) + +$ErrorActionPreference = "Continue" +$failures = New-Object System.Collections.Generic.List[string] +$warnings = New-Object System.Collections.Generic.List[string] + +function Pass([string]$Message) { + Write-Host " OK $Message" -ForegroundColor Green +} + +function Warn([string]$Message) { + $warnings.Add($Message) | Out-Null + Write-Host " WARN $Message" -ForegroundColor Yellow +} + +function Fail([string]$Message) { + $failures.Add($Message) | Out-Null + Write-Host " FAIL $Message" -ForegroundColor Red +} + +function Invoke-SmokeCheck([string]$Name, [scriptblock]$Check) { + Write-Host "" + Write-Host "== $Name ==" + try { + & $Check + } catch { + Fail "$Name`: $($_.Exception.Message)" + } +} + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { + throw $Message + } +} + +Write-Host "NoteGen local smoke" +Write-Host "API: $ApiBase" +Write-Host "Web: $WebDir" + +Invoke-SmokeCheck "Redis TCP" { + $redisOnline = Test-NetConnection -ComputerName "127.0.0.1" -Port 6379 -InformationLevel Quiet -WarningAction SilentlyContinue + Assert-True $redisOnline "127.0.0.1:6379 is not reachable. Start it with: docker compose up -d redis" + Pass "127.0.0.1:6379 accepts TCP connections" +} + +Invoke-SmokeCheck "API health" { + $health = Invoke-RestMethod -Uri "$ApiBase/api/health" -Method Get -TimeoutSec 8 + Assert-True ($null -ne $health) "empty health response" + Assert-True ([bool]$health.redis) "health.redis is false" + Assert-True (-not [bool]$health.disk.low) "disk.low is true" + Pass "redis=$($health.redis), queue_depth=$($health.queue_depth), disk.low=$($health.disk.low)" +} + +Invoke-SmokeCheck "Worker process" { + $workers = Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -match "scripts[\\/]+run_worker\.py" } + $workerIds = @($workers | ForEach-Object { $_.ProcessId }) + $rootWorkers = @($workers | Where-Object { $workerIds -notcontains $_.ParentProcessId }) + if (-not $rootWorkers) { + Warn "no run_worker.py process found. Start one worker with: .\.venv\Scripts\python.exe scripts\run_worker.py" + return + } + Assert-True ($rootWorkers.Count -eq 1) "expected exactly one worker, found $($rootWorkers.Count)" + Pass "one run_worker.py process is running (pid=$($rootWorkers[0].ProcessId))" +} + +Invoke-SmokeCheck "Public notes API" { + $notes = Invoke-RestMethod -Uri "$ApiBase/api/notes/public" -Method Get -TimeoutSec 8 + Assert-True ($notes -is [array]) "public notes response is not an array" + Pass "public notes endpoint returned $($notes.Count) item(s)" +} + +Invoke-SmokeCheck "Authenticated session" { + if ([string]::IsNullOrWhiteSpace($Email) -or [string]::IsNullOrWhiteSpace($Password)) { + Warn "skip login. Set NOTEGEN_SMOKE_EMAIL and NOTEGEN_SMOKE_PASSWORD, or pass -Email/-Password" + return + } + + $body = @{ email = $Email; password = $Password } | ConvertTo-Json + $login = Invoke-RestMethod ` + -Uri "$ApiBase/api/auth/login" ` + -Method Post ` + -ContentType "application/json" ` + -Body $body ` + -SessionVariable session ` + -TimeoutSec 8 + Assert-True ($null -ne $login.id) "login response has no user id" + + $mine = Invoke-RestMethod -Uri "$ApiBase/api/notes/mine" -Method Get -WebSession $session -TimeoutSec 8 + Assert-True ($mine -is [array]) "private notes response is not an array" + Pass "login ok for $Email; private notes returned $($mine.Count) item(s)" +} + +Invoke-SmokeCheck "DOCX export" { + $body = @{ + filename = "smoke.docx" + markdown = "# Smoke Test`n`n- DOCX export endpoint is available." + } | ConvertTo-Json + $response = Invoke-WebRequest ` + -UseBasicParsing ` + -Uri "$ApiBase/api/export/docx" ` + -Method Post ` + -ContentType "application/json" ` + -Body $body ` + -TimeoutSec 15 + $contentLength = $response.Content.Length + Assert-True ($response.StatusCode -eq 200) "expected 200, got $($response.StatusCode)" + Assert-True ($contentLength -gt 1000) "DOCX response is unexpectedly small: $contentLength bytes" + Pass "DOCX export returned $contentLength bytes" +} + +Invoke-SmokeCheck "Web production build" { + if ($SkipBuild) { + Warn "skip web build because -SkipBuild was passed" + return + } + Assert-True (Test-Path $WebDir) "web directory not found: $WebDir" + Push-Location $WebDir + try { + npm run build + Assert-True ($LASTEXITCODE -eq 0) "npm run build exited with $LASTEXITCODE" + Pass "npm run build completed" + } finally { + Pop-Location + } +} + +Write-Host "" +Write-Host "== Summary ==" +Write-Host "Failures: $($failures.Count)" +Write-Host "Warnings: $($warnings.Count)" + +if ($warnings.Count -gt 0) { + Write-Host "" + Write-Host "Warnings:" + foreach ($warning in $warnings) { + Write-Host " - $warning" + } +} + +if ($failures.Count -gt 0) { + Write-Host "" + Write-Host "Failures:" + foreach ($failure in $failures) { + Write-Host " - $failure" + } + exit 1 +} + +exit 0 diff --git a/web/README.md b/web/README.md index 12da347..6485b15 100644 --- a/web/README.md +++ b/web/README.md @@ -22,7 +22,8 @@ app/ page.tsx # landing:粒子背景 + hero + demo 卡片 notes/[id]/page.tsx # 详情:左视频 + 右笔记 components/ - ParticleBG.tsx # canvas 流动粒子 + NoteWorkspace.tsx # 三栏工作台:章节、笔记、视频与工具 + ChatPanel.tsx # 时间戳问答入口 VideoPlayer.tsx # Plyr 包装,dynamic import 避开 SSR ChapterChip.tsx # Dynamic Island 风格章节浮窗 NotesContent.tsx # 笔记主体(顶部卡 + 知识点速览 + 章节 + 术语表) diff --git a/web/components/AskBar.tsx b/web/components/AskBar.tsx deleted file mode 100644 index b91fe27..0000000 --- a/web/components/AskBar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; -import { MessageCircle } from "lucide-react"; -import { useLang } from "./LangContext"; - -/** - * 「对视频提问」输入条占位(Phase 2,接口契约见 docs/frontend-redesign.md §5)。 - * 后端 /api/notes/{id}/ask 落地后替换为真实 ChatPanel 入口。 - */ -export default function AskBar() { - const { lang } = useLang(); - return ( -
- - - {lang === "en" - ? "Ask about this video — answers will cite timestamps…" - : "对这个视频提问,回答附时间戳引用…"} - - - {lang === "en" ? "Coming soon" : "规划中"} - -
- ); -} diff --git a/web/components/ChatPanel.tsx b/web/components/ChatPanel.tsx index 353f455..0f38900 100644 --- a/web/components/ChatPanel.tsx +++ b/web/components/ChatPanel.tsx @@ -25,6 +25,14 @@ interface Props { const POLL_MS = 1500; const POLL_MAX_MS = 10 * 60 * 1000; // 与后端 QA_TIMEOUT 对齐 +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function now() { + return Date.now(); +} + /** * 「对视频提问」面板(docs/frontend-redesign.md §5 落地)。 * 提交 → /api/notes/{id}/ask 入队 → 轮询 /api/qa/{id} → 答案 + 时间戳引用 chip。 @@ -80,9 +88,9 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) { setStatusText(t("提交中…", "Submitting…")); try { const { qa_id } = await postAsk(noteId, q, lang, history.slice(-2)); - const started = Date.now(); - while (aliveRef.current && Date.now() - started < POLL_MAX_MS) { - await new Promise(r => setTimeout(r, POLL_MS)); + const started = now(); + while (aliveRef.current && now() - started < POLL_MAX_MS) { + await sleep(POLL_MS); if (!aliveRef.current) return; const st = await fetchQa(qa_id); if (st.status === "done" && st.result) { @@ -131,14 +139,14 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) { // 未登录(公开笔记游客):提示登录后可提问 if (!user) { return ( -
- - +
+ + {t("登录后可对这个视频提问,回答附时间戳引用", "Sign in to ask about this video")} + className="shrink-0 text-xs font-semibold text-[var(--wf-accent)] hover:underline"> {t("去登录", "Sign in")}
@@ -146,23 +154,23 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) { } return ( -
+
{/* 历史消息 */} {msgs.length > 0 && (
{msgs.map((m, i) => m.role === "user" ? (
-
+
{m.text}
) : (
-
+ ? "bg-[var(--wf-danger-surface)] text-[var(--wf-danger)]" + : "bg-[var(--wf-surface-muted)] text-[var(--wf-text)]"}`}>

{m.text}

{!!m.citations?.length && (
@@ -172,10 +180,10 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) { type="button" onClick={() => onSeek(c.start)} title={c.quote || undefined} - className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-elevated)] - px-2 py-0.5 text-[11px] tabular-nums text-[var(--accent)] - border border-[var(--border)] transition-colors - hover:bg-[var(--accent)] hover:text-[var(--on-accent)]" + className="inline-flex items-center gap-1 rounded-[var(--wf-radius-full)] bg-[var(--wf-surface)] + px-2 py-0.5 text-[11px] tabular-nums text-[var(--wf-accent)] + border border-[var(--wf-border)] transition-colors + hover:bg-[var(--wf-accent)] hover:text-[var(--wf-on-accent)]" > ▶ {formatTime(c.start)} @@ -186,7 +194,7 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) {
))} {busy && ( -
+
{statusText}
)} @@ -201,9 +209,9 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) { key={s} type="button" onClick={() => submit(s)} - className="inline-flex max-w-full items-center gap-1 rounded-full border border-[var(--border)] - bg-[var(--bg-muted)] px-3 py-1.5 text-xs text-[var(--fg-secondary)] - transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]" + className="inline-flex max-w-full items-center gap-1 rounded-[var(--wf-radius-full)] border border-[var(--wf-border)] + bg-[var(--wf-surface-muted)] px-3 py-1.5 text-xs text-[var(--wf-text-secondary)] + transition-colors hover:border-[var(--wf-accent)] hover:text-[var(--wf-accent)]" > {s} @@ -214,7 +222,7 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) { {/* 输入行 */}
- + ) : ( -
+
- +
{file.name}
-
+
{formatBytes(file.size)}
@@ -266,7 +266,7 @@ export default function CreateNotePanel({ next = "/notebooks" }: { next?: string type="button" onClick={() => { setFile(null); setFileTitle(""); setUploadPct(null); if (fileInputRef.current) fileInputRef.current.value = ""; }} disabled={submitting} - className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--bg-muted)] text-[var(--fg-tertiary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--fg)] disabled:opacity-40" + className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--wf-surface-muted)] text-[var(--wf-text-tertiary)] transition-colors hover:bg-[var(--wf-border)] hover:text-[var(--wf-text)] disabled:opacity-40" title="移除" > @@ -277,16 +277,16 @@ export default function CreateNotePanel({ next = "/notebooks" }: { next?: string placeholder="视频标题(可选,留空用文件名)" value={fileTitle} onChange={e => setFileTitle(e.target.value)} - className="w-full rounded-[8px] border border-[var(--border)] bg-[var(--bg-muted)] px-3 py-2 text-sm outline-none transition-colors placeholder:text-[var(--fg-tertiary)] focus:border-[var(--accent)]" + className="w-full rounded-[8px] border border-[var(--wf-border)] bg-[var(--wf-surface-muted)] px-3 py-2 text-sm outline-none transition-colors placeholder:text-[var(--wf-text-tertiary)] focus:border-[var(--wf-accent)]" /> {uploadPct !== null && uploadPct < 1 && (
-
+
上传中 {Math.round(uploadPct * 100)}%
-
-
+
@@ -294,7 +294,7 @@ export default function CreateNotePanel({ next = "/notebooks" }: { next?: string @@ -381,8 +381,8 @@ export default function CreateNotePanel({ next = "/notebooks" }: { next?: string
) : probed.heights.length === 1 ? ( -
- 只有 1 种画质可下:{probed.heights[0]}p +
+ 只有 1 种画质可下:{probed.heights[0]}p
) : (
@@ -399,7 +399,7 @@ export default function CreateNotePanel({ next = "/notebooks" }: { next?: string initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }} - className="mt-3 text-center text-xs text-[var(--fg-tertiary)]" + className="mt-3 text-center text-xs text-[var(--wf-text-tertiary)]" > {hint} diff --git a/web/components/FluidBG.tsx b/web/components/FluidBG.tsx deleted file mode 100644 index b64968c..0000000 --- a/web/components/FluidBG.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -/** - * 流体背景:3 个大圆 blob,blur(80px) 给奶油般弥散感。淡灰 / 淡灰蓝 / 极浅暖白 - * 三色,alpha 0.35-0.5。slow morph 30-40s 一周期,呼吸般缓慢移动 + scale。 - * 朴素配色,避免 "AI landing page" 鲜艳渐变 vibe。 - */ -export default function FluidBG() { - return ( -
-
-
-
- - -
- ); -} diff --git a/web/components/NoteWorkspace.tsx b/web/components/NoteWorkspace.tsx index 9b63244..c85208f 100644 --- a/web/components/NoteWorkspace.tsx +++ b/web/components/NoteWorkspace.tsx @@ -37,11 +37,11 @@ interface Props { /** 加载失败卡(/notes/[id] 与 /s/[token] 共用) */ export function WorkspaceError({ error, backHref }: { error: string; backHref: string }) { return ( -
-
+
+

加载失败

-

{error}

- 返回 +

{error}

+ 返回
); @@ -50,44 +50,44 @@ export function WorkspaceError({ error, backHref }: { error: string; backHref: s /** 三栏同构骨架屏(/notes/[id] 与 /s/[token] 共用) */ export function WorkspaceSkeleton({ backHref }: { backHref: string }) { return ( -
+
+ className="inline-flex items-center gap-1 text-xs text-[var(--wf-text-tertiary)] + hover:text-[var(--wf-text)] transition-colors shrink-0"> 返回 - · - 加载中… + · + 加载中…
-
-
-
+
+
+
{Array.from({ length: 5 }).map((_, i) => ( -
+
))}
-
-
-
+
+
+
{Array.from({ length: 4 }).map((_, i) => ( -
-
-
+
+
+
))}
-
-
-
-
+
+
+
+
@@ -378,8 +378,8 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false
+ className="inline-flex items-center gap-1 text-xs text-[var(--wf-text-tertiary)] + hover:text-[var(--wf-text)] transition-colors shrink-0"> 返回 - · + · {title} {shared && ( - + {lang === "en" ? "Shared" : "分享页"} )}
)} {bundle.chapters.length > 0 && currentChapter >= 0 && ( -
+
{formatTime(currentTime)} · @@ -523,23 +523,23 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false {shareHint && ( -

{shareHint}

+

{shareHint}

)} - + {lang === "en" ? "My bookmarks" : "我的书签"} @@ -616,7 +616,7 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false aria-modal="true" aria-label={lang === "en" ? "Chapter navigation" : "章节导航"} className="absolute left-0 top-0 h-full w-72 overflow-y-auto border-r - border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-4" + border-[var(--wf-border)] bg-[var(--wf-surface)] px-3 py-4" onClick={e => e.stopPropagation()} >
@@ -626,7 +626,7 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false onClick={() => setRailOpen(false)} aria-label={lang === "en" ? "Close" : "关闭"} className="inline-flex h-11 w-11 items-center justify-center rounded-full - text-[var(--fg-tertiary)] hover:bg-[var(--bg-muted)] hover:text-[var(--fg)]" + text-[var(--wf-text-tertiary)] hover:bg-[var(--wf-surface-muted)] hover:text-[var(--wf-text)]" > diff --git a/web/components/ParticleBG.tsx b/web/components/ParticleBG.tsx deleted file mode 100644 index fe51bd8..0000000 --- a/web/components/ParticleBG.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; -import { useEffect, useRef } from "react"; - -/** - * 流动粒子背景 — canvas 自实现,无 npm 依赖。 - * 设计:~80 个小粒子缓慢向右上漂浮,浅蓝/紫粉渐变,鼠标范围内被排斥。 - * 性能:requestAnimationFrame + 离屏 canvas 缩放,1080p 上 < 1% CPU。 - */ -export default function ParticleBG({ density = 0.00005, mouseRepelR = 120 }: { - density?: number; mouseRepelR?: number; -}) { - const ref = useRef(null); - const mouseRef = useRef({ x: -9999, y: -9999, active: false }); - - useEffect(() => { - const canvas = ref.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - let raf = 0; - let particles: { x: number; y: number; vx: number; vy: number; r: number; hue: number; sat: number; light: number; alpha: number }[] = []; - const dpr = Math.min(window.devicePixelRatio || 1, 2); - - function resize() { - if (!canvas || !ctx) return; - const { innerWidth: w, innerHeight: h } = window; - canvas.width = w * dpr; - canvas.height = h * dpr; - canvas.style.width = w + "px"; - canvas.style.height = h + "px"; - ctx.scale(dpr, dpr); - // 重建粒子数 ∝ 像素数 - const n = Math.max(40, Math.min(200, Math.round(w * h * density))); - particles = Array.from({ length: n }, () => spawn(w, h)); - } - - // 检测是否 dark mode,给粒子不同 lightness(dark 下浅色粒子才在黑底可见) - const isDark = () => - typeof document !== "undefined" && - document.documentElement.dataset.theme === "dark"; - - function spawn(w: number, h: number) { - // 中性色调:低饱和灰蓝 + 偶有暖白。r 大 + alpha 低 → soft glow。 - const useWarm = Math.random() < 0.3; - const a = Math.random() * Math.PI * 2; - const v = 0.02 + Math.random() * 0.05; - const dark = isDark(); - return { - x: Math.random() * w, - y: Math.random() * h, - vx: Math.cos(a) * v, - vy: Math.sin(a) * v, - r: 3 + Math.random() * 5, - hue: useWarm ? 30 + Math.random() * 15 : 210 + Math.random() * 20, - sat: useWarm ? 30 : 25, - // dark 下 light 提到 78-82 让粒子在黑底上看得见;light 模式保持 62-68 - light: dark ? (useWarm ? 80 : 76) : (useWarm ? 68 : 62), - alpha: dark ? 0.18 + Math.random() * 0.20 : 0.12 + Math.random() * 0.18, - }; - } - - function tick() { - if (!canvas || !ctx) return; - const w = window.innerWidth, h = window.innerHeight; - ctx.clearRect(0, 0, w, h); - const mouse = mouseRef.current; - // 流场:基于 stream function ψ 的旋度 (curl),保证 divergence-free—— - // 即 ∂vx/∂x + ∂vy/∂y = 0,理论上不会有 sink(粒子不会汇聚到角落)。 - // ψ(x,y,t) ≈ sin(kx + t) · cos(ky + t');curl 取 (∂ψ/∂y, -∂ψ/∂x)。 - const t = performance.now() * 0.00007; - const k = 0.0022; - const fieldStrength = 0.10; - for (const p of particles) { - // 鼠标排斥(弱化) - if (mouse.active) { - const dx = p.x - mouse.x; - const dy = p.y - mouse.y; - const d = Math.hypot(dx, dy); - if (d < mouseRepelR && d > 0.1) { - const force = (mouseRepelR - d) / mouseRepelR * 0.35; - p.vx += (dx / d) * force * 0.025; - p.vy += (dy / d) * force * 0.025; - } - } - // stream function ψ = sin(k·x + t) · cos(k·y + 1.1·t) - // ∂ψ/∂y = -k · sin(k·x + t) · sin(k·y + 1.1·t) - // ∂ψ/∂x = k · cos(k·x + t) · cos(k·y + 1.1·t) - const a = k * p.x + t; - const b = k * p.y + 1.1 * t; - const fx = -Math.sin(a) * Math.sin(b); - const fy = -Math.cos(a) * Math.cos(b); - // 弱 force + 强 damping → 缓慢蜷曲流动 - p.vx = p.vx * 0.94 + fx * fieldStrength; - p.vy = p.vy * 0.94 + fy * fieldStrength; - // 限速 - const speed = Math.hypot(p.vx, p.vy); - const maxSpeed = 0.35; - if (speed > maxSpeed) { - p.vx = p.vx / speed * maxSpeed; - p.vy = p.vy / speed * maxSpeed; - } - p.x += p.vx; - p.y += p.vy; - // 边界 wrap(任意方向都可能出界) - if (p.x < -10) p.x = w + 10; - if (p.x > w + 10) p.x = -10; - if (p.y < -10) p.y = h + 10; - if (p.y > h + 10) p.y = -10; - // 绘制 - ctx.beginPath(); - ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); - const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 4); - grad.addColorStop(0, `hsla(${p.hue}, ${p.sat}%, ${p.light}%, ${p.alpha})`); - grad.addColorStop(1, `hsla(${p.hue}, ${p.sat}%, ${p.light}%, 0)`); - ctx.fillStyle = grad; - ctx.fillRect(p.x - p.r * 4, p.y - p.r * 4, p.r * 8, p.r * 8); - } - raf = requestAnimationFrame(tick); - } - - function onMove(e: MouseEvent) { - mouseRef.current.x = e.clientX; - mouseRef.current.y = e.clientY; - mouseRef.current.active = true; - } - function onLeave() { mouseRef.current.active = false; } - - // theme 切换时重 spawn 让粒子色调跟随 - const themeObserver = new MutationObserver(() => resize()); - themeObserver.observe(document.documentElement, { - attributes: true, attributeFilter: ["data-theme"] - }); - - resize(); - window.addEventListener("resize", resize); - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseleave", onLeave); - tick(); - return () => { - cancelAnimationFrame(raf); - themeObserver.disconnect(); - window.removeEventListener("resize", resize); - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseleave", onLeave); - }; - }, [density, mouseRepelR]); - - return ( - - ); -} diff --git a/web/lib/progress.ts b/web/lib/progress.ts index 1917224..ba6eb14 100644 --- a/web/lib/progress.ts +++ b/web/lib/progress.ts @@ -1,29 +1,93 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useSyncExternalStore } from "react"; + +const STORAGE_PREFIX = "notegen-chapter-done:"; +const EMPTY_DONE: number[] = []; + +type CacheEntry = { + raw: string | null; + value: number[]; +}; + +const snapshotCache = new Map(); +const listeners = new Set<() => void>(); + +function storageKey(noteId: string) { + return `${STORAGE_PREFIX}${noteId}`; +} + +function parseDone(raw: string | null) { + if (!raw) return EMPTY_DONE; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(n => Number.isInteger(n)) : EMPTY_DONE; + } catch { + return EMPTY_DONE; + } +} + +function readDone(key: string) { + if (typeof window === "undefined") return EMPTY_DONE; + let raw: string | null = null; + try { + raw = window.localStorage.getItem(key); + } catch { + return EMPTY_DONE; + } + + const cached = snapshotCache.get(key); + if (cached?.raw === raw) return cached.value; + + const value = parseDone(raw); + snapshotCache.set(key, { raw, value }); + return value; +} + +function emitProgressChange() { + for (const listener of listeners) listener(); +} + +function subscribeProgress(listener: () => void) { + listeners.add(listener); + + function onStorage(event: StorageEvent) { + if (!event.key?.startsWith(STORAGE_PREFIX)) return; + emitProgressChange(); + } + + window.addEventListener("storage", onStorage); + return () => { + listeners.delete(listener); + window.removeEventListener("storage", onStorage); + }; +} + +function writeDone(key: string, next: number[]) { + try { + window.localStorage.setItem(key, JSON.stringify(next)); + } catch { + // Ignore private browsing or storage quota failures. + } + snapshotCache.delete(key); + emitProgressChange(); +} /** - * 章节学习进度(「已学完」勾选),localStorage 按笔记隔离。 + * 章节学习进度(“已学完”勾选),localStorage 按笔记隔离。 * 单页内只在 page 层调一次,向下传 done/toggle,避免多实例状态漂移。 */ export function useChapterProgress(noteId: string) { - const key = `notegen-chapter-done:${noteId}`; - const [done, setDone] = useState([]); - - useEffect(() => { - try { - const raw = localStorage.getItem(key); - setDone(raw ? (JSON.parse(raw) as number[]).filter(n => Number.isInteger(n)) : []); - } catch { - setDone([]); - } - }, [key]); + const key = storageKey(noteId); + const done = useSyncExternalStore( + subscribeProgress, + () => readDone(key), + () => EMPTY_DONE, + ); const toggle = useCallback((idx: number) => { - setDone(prev => { - const next = prev.includes(idx) ? prev.filter(i => i !== idx) : [...prev, idx]; - try { localStorage.setItem(key, JSON.stringify(next)); } catch { /* 隐私模式静默 */ } - return next; - }); + const current = readDone(key); + const next = current.includes(idx) ? current.filter(i => i !== idx) : [...current, idx]; + writeDone(key, next); }, [key]); return { done, toggle };