diff --git a/.gitignore b/.gitignore index 95c65fc..9c0e677 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ data/ reproduce_auth_500.py debug_list_notes.py +DEPLOY_TO_HF.md diff --git a/Dockerfile b/Dockerfile index c2fc652..d3da5b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,11 @@ FROM python:3.11-slim WORKDIR /app -# Install Node.js for frontend build +# Install Node.js 20.x for frontend build (required for React 19, Vite 7) RUN apt-get update && \ - apt-get install -y nodejs npm curl && \ + apt-get install -y curl gnupg && \ + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y nodejs && \ rm -rf /var/lib/apt/lists/* # Copy and install frontend dependencies diff --git a/GITSUMMARY.md b/GITSUMMARY.md new file mode 100644 index 0000000..f000b25 --- /dev/null +++ b/GITSUMMARY.md @@ -0,0 +1,85 @@ +# Git Summary - Delete Note Feature + +## Overview +Implemented complete delete note functionality with confirmation dialog to prevent accidental deletions. + +## Changes Made + +### Backend (`backend/src/api/routes/notes.py`) +- **Added DELETE endpoint** (`DELETE /api/notes/{path}`) + - Deletes note file from vault filesystem + - Removes all related index entries (metadata, tags, links) + - Updates index health statistics + - Returns `204 No Content` on success + - Returns `404 Not Found` if note doesn't exist + - Returns `500 Internal Server Error` for other failures + +### Frontend + +#### 1. API Service (`frontend/src/services/api.ts`) +- **Added `deleteNote()` function** + - Accepts note path parameter + - Makes DELETE request to `/api/notes/{path}` + - URL-encodes path to handle special characters + - Returns Promise (no content response) + +#### 2. New Component (`frontend/src/components/DeleteConfirmationDialog.tsx`) +- **DeleteConfirmationDialog component** + - Reusable dialog component for confirming note deletion + - Props: + - `isOpen`: Controls dialog visibility + - `noteName`: Name of note being deleted (for confirmation message) + - `isDeleting`: Loading state during deletion + - `onConfirm`: Callback when user confirms deletion + - `onCancel`: Callback when user cancels + - Features: + - Displays note name in warning message + - "Cancel" button to abort deletion + - "Delete" button with destructive styling (red) + - Disables buttons during deletion request + - Shows "Deleting..." text while loading + +#### 3. Main App (`frontend/src/pages/MainApp.tsx`) +- **Added state management** + - `isDeleteDialogOpen`: Boolean for dialog visibility + - `isDeleting`: Boolean for deletion loading state + +- **Added handler function `handleDeleteNote()`** + - Calls `deleteNote()` API with current note path + - Refreshes notes list after successful deletion + - Clears current note selection + - Auto-selects first available note + - Shows success/error toast notifications + - Extracts display name from note title or path + - Handles errors gracefully with error messages + +- **Connected UI** + - Passed `onDelete={() => setIsDeleteDialogOpen(true)}` to NoteViewer component + - Added DeleteConfirmationDialog component to page + - Dialog displays current note's title and handles confirmation + +## User Flow +1. User clicks trash icon button in note viewer +2. DeleteConfirmationDialog modal appears showing note name +3. User can: + - Click "Cancel" to abort deletion + - Click "Delete" to confirm and delete the note +4. On deletion: + - Note is removed from filesystem and database + - Notes list refreshes + - UI auto-selects next available note + - Success toast confirms deletion + +## Files Modified/Created +- βœ… `backend/src/api/routes/notes.py` - Added DELETE endpoint +- βœ… `frontend/src/services/api.ts` - Added deleteNote function +- βœ… `frontend/src/components/DeleteConfirmationDialog.tsx` - New component +- βœ… `frontend/src/pages/MainApp.tsx` - Integrated delete flow + +## Testing Recommendations +- Test deleting a note and verify it's removed from UI +- Test that deleting updates note count in footer +- Test error handling (e.g., network failure) +- Test that deletion dialog closes after confirmation +- Test auto-selection of next note after deletion +- Test deletion of last note (should show empty state) diff --git a/README.md b/README.md index 5b00932..2e21b74 100644 --- a/README.md +++ b/README.md @@ -1,358 +1,121 @@ -# Document Viewer - -A multi-tenant Obsidian-like documentation system with AI agent integration via Model Context Protocol (MCP). - -## 🎯 Overview - -Document Viewer enables both humans and AI agents to create, browse, and search documentation with powerful features like: - -- πŸ“ **Markdown Notes** with YAML frontmatter -- πŸ”— **Wikilinks** - `[[Note Name]]` style internal linking with auto-resolution -- πŸ” **Full-Text Search** - BM25 ranking with recency bonus -- ↩️ **Backlinks** - Automatic tracking of which notes reference each other -- 🏷️ **Tags** - Organize notes with frontmatter tags -- ✏️ **Split-Pane Editor** - Live markdown preview with optimistic concurrency -- πŸ€– **MCP Integration** - AI agents can read/write docs via FastMCP -- πŸ‘₯ **Multi-Tenant** - Isolated vaults per user (production ready with HF OAuth) - -## πŸ—οΈ Tech Stack - -### Backend -- **FastAPI** - HTTP API server -- **FastMCP** - MCP server for AI agent integration -- **SQLite FTS5** - Full-text search with BM25 ranking -- **python-frontmatter** - YAML frontmatter parsing -- **PyJWT** - Token-based authentication - -### Frontend -- **React + Vite** - Modern web framework -- **shadcn/ui** - Beautiful UI components -- **Tailwind CSS** - Utility-first styling -- **react-markdown** - Markdown rendering with custom wikilink support -- **TypeScript** - Type-safe frontend code - -## πŸ“¦ Local Setup - -### Prerequisites -- Python 3.11+ -- Node.js 18+ -- `uv` (Python package manager) or `pip` - -### 1. Clone Repository - -```bash -git clone -cd Document-MCP -``` - -### 2. Backend Setup - -```bash -cd backend - -# Create virtual environment -uv venv -# or: python -m venv .venv - -# Install dependencies -uv pip install -e . -# or: .venv/bin/pip install -e . - -# Initialize database -cd .. -VIRTUAL_ENV=backend/.venv backend/.venv/bin/python -c "from backend.src.services.database import init_database; init_database()" -``` - -### 3. Frontend Setup - -```bash -cd frontend - -# Install dependencies -npm install -``` - -### 4. Environment Configuration - -The project includes development scripts that set environment variables automatically. For manual configuration, create a `.env` file in the backend directory: - -```bash -# backend/.env -JWT_SECRET_KEY=your-secret-key-here -VAULT_BASE_PATH=/path/to/Document-MCP/data/vaults -``` - -See `.env.example` for all available options. - -## πŸš€ Running the Application - -### Easy Start (Recommended) - -Use the provided scripts to start both servers: - -```bash -# Start frontend and backend -./start-dev.sh - -# Check status -./status-dev.sh - -# Stop servers -./stop-dev.sh - -# View logs -tail -f backend.log frontend.log -``` - -### Manual Start - -#### Running Backend - -Start the HTTP API server: - -```bash -cd backend -JWT_SECRET_KEY="local-dev-secret-key-123" \ -VAULT_BASE_PATH="$(pwd)/../data/vaults" \ -.venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --reload -``` - -Backend will be available at: `http://localhost:8000` +--- +title: Document Viewer +emoji: πŸ“š +colorFrom: blue +colorTo: purple +sdk: docker +pinned: false +license: mit +--- -API docs (Swagger): `http://localhost:8000/docs` +# Document Viewer - AI-Powered Documentation System -#### Running MCP Server (STDIO Mode) +An Obsidian-style documentation system where AI agents and humans collaborate on creating and maintaining documentation. -For AI agent integration via MCP: +## ⚠️ Demo Mode -```bash -cd backend -JWT_SECRET_KEY="local-dev-secret-key-123" \ -VAULT_BASE_PATH="$(pwd)/../data/vaults" \ -.venv/bin/python -m src.mcp.server -``` +**This is a demonstration instance with ephemeral storage.** -#### Running Frontend +- All data is temporary and resets on server restart +- Demo content is automatically seeded on each startup +- For production use, deploy your own instance with persistent storage -```bash -cd frontend -npm run dev -``` +## 🎯 Features -Frontend will be available at: `http://localhost:5173` +- **Wikilinks** - Link between notes using `[[Note Name]]` syntax +- **Full-Text Search** - BM25 ranking with recency bonus +- **Backlinks** - Automatically track note references +- **Split-Pane Editor** - Live markdown preview +- **MCP Integration** - AI agents can read/write via Model Context Protocol +- **Multi-Tenant** - Each user gets an isolated vault (HF OAuth) -## πŸ€– MCP Client Configuration +## πŸš€ Getting Started -To use the Document Viewer with AI agents (Claude Desktop, Cline, etc.), add this to your MCP configuration: +1. Click **"Sign in with Hugging Face"** to authenticate +2. Browse the pre-seeded demo notes +3. Try searching, creating, and editing notes +4. Check out the wikilinks between documents -### Claude Desktop / Cline +## πŸ€– AI Agent Access (MCP) -Add to `~/.cursor/mcp.json` (or Claude Desktop settings): +After signing in, go to **Settings** to get your API token for MCP access: ```json { "mcpServers": { "obsidian-docs": { - "command": "python", - "args": ["-m", "backend.src.mcp.server"], - "cwd": "/path/to/Document-MCP", - "env": { - "BEARER_TOKEN": "local-dev-token", - "FASTMCP_SHOW_CLI_BANNER": "false", - "PYTHONPATH": "/path/to/Document-MCP", - "JWT_SECRET_KEY": "local-dev-secret-key-123", - "VAULT_BASE_PATH": "/path/to/Document-MCP/data/vaults" + "url": "https://YOUR_USERNAME-Document-MCP.hf.space/mcp", + "transport": "http", + "headers": { + "Authorization": "Bearer YOUR_JWT_TOKEN" } } } } ``` -**Note:** In production, use actual JWT tokens instead of `local-dev-token`. - -### Available MCP Tools - -AI agents can use these tools: +For local experiments you can still run the MCP server via STDIOβ€”use the "Local Development" snippet shown in Settings. -- `list_notes` - List all notes in vault -- `read_note` - Read a specific note -- `write_note` - Create or update a note -- `delete_note` - Remove a note -- `search_notes` - Full-text search with BM25 ranking -- `get_backlinks` - Find notes linking to a target -- `get_tags` - List all tags with usage counts +AI agents can then use these tools: +- `list_notes` - Browse vault +- `read_note` - Read note content +- `write_note` - Create/update notes +- `search_notes` - Full-text search +- `get_backlinks` - Find references +- `get_tags` - List all tags -## πŸ›οΈ Architecture - -### Data Model - -**Note Structure:** -```yaml ---- -title: My Note -tags: [guide, tutorial] -created: 2025-01-15T10:00:00Z -updated: 2025-01-15T14:30:00Z ---- - -# My Note - -Content with [[Wikilinks]] to other notes. -``` - -**Vault Structure:** -``` -data/vaults/ -β”œβ”€β”€ local-dev/ # Development user vault -β”‚ β”œβ”€β”€ Getting Started.md -β”‚ β”œβ”€β”€ API Documentation.md -β”‚ └── ... -└── {user_id}/ # Production user vaults - └── *.md -``` +## πŸ—οΈ Tech Stack -**Index Tables (SQLite):** -- `note_metadata` - Note versions, titles, timestamps -- `note_fts` - FTS5 full-text search index -- `note_tags` - Tag associations -- `note_links` - Wikilink graph (resolved/unresolved) -- `index_health` - Index statistics per user - -### Key Features - -**Wikilink Resolution:** -- Normalizes titles to slugs: `[[Getting Started]]` β†’ `getting-started` -- Matches against both title and filename -- Prefers same-folder matches -- Tracks broken links for UI styling - -**Search Ranking:** -- BM25 algorithm with title-weighted scoring (3x title, 1x body) -- Recency bonus: +1.0 for notes updated in last 7 days, +0.5 for last 30 days -- Returns highlighted snippets with `` tags - -**Optimistic Concurrency:** -- Version-based conflict detection for note edits -- Prevents data loss from concurrent edits -- Returns 409 Conflict with helpful message - -## πŸ”’ Authentication - -### Local Development -Uses a static token: `local-dev-token` - -### Production (Hugging Face OAuth) -- Multi-tenant with per-user isolated vaults -- JWT tokens with user_id claims -- Automatic vault initialization on first login - -See deployment documentation for HF OAuth setup. - -## πŸ“Š Performance Considerations - -**SQLite Optimizations:** -- FTS5 with prefix indexes (`prefix='2 3'`) for fast autocomplete and substring matching -- Recommended: Enable WAL mode for concurrent reads/writes: - ```sql - PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - ``` -- Normalized slug indexes (`normalized_title_slug`, `normalized_path_slug`) for O(1) wikilink resolution -- BM25 ranking weights: 3.0 for title matches, 1.0 for body matches - -**Rate Limiting:** -- ⚠️ **Production Recommendation**: Add per-user rate limits to prevent abuse -- API endpoints currently have no rate limiting -- Consider implementing: - - `/api/notes` (POST): 100 requests/hour per user - - `/api/index/rebuild` (POST): 10 requests/day per user - - `/api/search`: 1000 requests/hour per user -- Use libraries like `slowapi` or Redis-based rate limiting - -**Scaling:** -- **Single-server**: SQLite handles 100K+ notes efficiently -- **Multi-server**: Migrate to PostgreSQL with `pg_trgm` or `pgvector` for FTS -- **Caching**: Add Redis for: - - Session tokens (reduce DB lookups) - - Frequently accessed notes - - Search result caching (TTL: 5 minutes) -- **CDN**: Serve frontend assets via CDN for global performance - -## πŸ§ͺ Development - -### Project Structure +**Backend:** +- FastAPI - HTTP API server +- FastMCP - MCP server for AI integration +- SQLite FTS5 - Full-text search +- python-frontmatter - YAML metadata -``` -Document-MCP/ -β”œβ”€β”€ backend/ -β”‚ β”œβ”€β”€ src/ -β”‚ β”‚ β”œβ”€β”€ api/ # FastAPI routes & middleware -β”‚ β”‚ β”œβ”€β”€ mcp/ # FastMCP server -β”‚ β”‚ β”œβ”€β”€ models/ # Pydantic models -β”‚ β”‚ └── services/ # Business logic -β”‚ └── tests/ # Backend tests -β”œβ”€β”€ frontend/ -β”‚ β”œβ”€β”€ src/ -β”‚ β”‚ β”œβ”€β”€ components/ # React components -β”‚ β”‚ β”œβ”€β”€ pages/ # Page components -β”‚ β”‚ β”œβ”€β”€ services/ # API client -β”‚ β”‚ └── types/ # TypeScript types -β”‚ └── tests/ # Frontend tests -β”œβ”€β”€ data/ -β”‚ β”œβ”€β”€ vaults/ # User markdown files -β”‚ └── index.db # SQLite database -β”œβ”€β”€ specs/ # Feature specifications -└── start-dev.sh # Development startup script -``` +**Frontend:** +- React + Vite - Modern web framework +- shadcn/ui - UI components +- Tailwind CSS - Styling +- react-markdown - Markdown rendering -### Adding a New Note (via UI) +## πŸ“– Documentation -1. Click "New Note" button -2. Enter note name (`.md` extension optional) -3. Edit in split-pane editor -4. Save with Cmd/Ctrl+S +Key demo notes to explore: -### Adding a New Note (via MCP) +- **Getting Started** - Introduction and overview +- **API Documentation** - REST API reference +- **MCP Integration** - AI agent configuration +- **Wikilink Examples** - How linking works +- **Architecture Overview** - System design +- **Search Features** - Full-text search details -```python -# AI agent writes a note -write_note( - path="guides/my-guide.md", - body="# My Guide\n\nContent here with [[links]]", - title="My Guide", - metadata={"tags": ["guide", "tutorial"]} -) -``` +## βš™οΈ Deploy Your Own -## πŸ› Troubleshooting +Want persistent storage and full control? Deploy your own instance: -**Backend won't start:** -- Ensure virtual environment is activated -- Check environment variables are set -- Verify database is initialized +1. Clone the repository +2. Set up HF OAuth app +3. Configure environment variables +4. Deploy to HF Spaces or any Docker host -**Frontend shows connection errors:** -- Ensure backend is running on port 8000 -- Check Vite proxy configuration in `frontend/vite.config.ts` +See [DEPLOYMENT.md](https://github.com/YOUR_REPO/Document-MCP/blob/main/DEPLOYMENT.md) for detailed instructions. -**Search returns no results:** -- Verify notes are indexed (check Settings β†’ Index Health) -- Try rebuilding the index via Settings page +## πŸ”’ Privacy & Data -**MCP tools not showing in Claude:** -- Verify MCP configuration path is correct -- Check `PYTHONPATH` includes project root -- Restart Claude Desktop after config changes +- **Multi-tenant**: Each HF user gets an isolated vault +- **Demo data**: Resets on restart (ephemeral storage) +- **OAuth**: Secure authentication via Hugging Face +- **No tracking**: We don't collect analytics or personal data ## πŸ“ License -[Add license information] +MIT License - See LICENSE file for details ## 🀝 Contributing -[Add contributing guidelines] +Contributions welcome! Open an issue or submit a PR. -## πŸ“§ Contact +--- -[Add contact information] +Built with ❀️ for the AI-human documentation collaboration workflow diff --git a/backend/src/api/main.py b/backend/src/api/main.py index 97052b1..aa17848 100644 --- a/backend/src/api/main.py +++ b/backend/src/api/main.py @@ -21,7 +21,7 @@ from fastmcp.server.http import StreamableHTTPSessionManager from fastapi.responses import FileResponse -from .routes import auth, index, notes, search, graph +from .routes import auth, index, notes, search, graph, demo from ..mcp.server import mcp from ..services.seed import init_and_seed @@ -95,6 +95,7 @@ async def internal_error_handler(request: Request, exc: Exception): app.include_router(search.router, tags=["search"]) app.include_router(index.router, tags=["index"]) app.include_router(graph.router, tags=["graph"]) +app.include_router(demo.router, tags=["demo"]) # Hosted MCP HTTP endpoint (mounted Starlette app) diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py index 1977ebc..23f1cec 100644 --- a/backend/src/api/routes/__init__.py +++ b/backend/src/api/routes/__init__.py @@ -1,5 +1,5 @@ """HTTP API route handlers.""" -from . import auth, index, notes, search +from . import auth, index, notes, search, graph, demo -__all__ = ["auth", "notes", "search", "index"] +__all__ = ["auth", "notes", "search", "index", "graph", "demo"] diff --git a/backend/src/api/routes/demo.py b/backend/src/api/routes/demo.py new file mode 100644 index 0000000..3035380 --- /dev/null +++ b/backend/src/api/routes/demo.py @@ -0,0 +1,39 @@ +"""Public endpoints for demo mode access.""" + +from __future__ import annotations + +from datetime import timedelta + +from fastapi import APIRouter + +from ...services.auth import AuthService +from ...services.seed import ensure_welcome_note + +DEMO_USER_ID = "demo-user" +DEMO_TOKEN_TTL_HOURS = 12 + +router = APIRouter() +auth_service = AuthService() + + +@router.get("/api/demo/token") +async def issue_demo_token(): + """ + Issue a short-lived JWT for the shared demo vault. + + The caller can use this token to explore the application in read-only mode. + """ + ensure_welcome_note(DEMO_USER_ID) + token, expires_at = auth_service.issue_token_response( + DEMO_USER_ID, expires_in=timedelta(hours=DEMO_TOKEN_TTL_HOURS) + ) + return { + "token": token, + "token_type": "bearer", + "expires_at": expires_at.isoformat(), + "user_id": DEMO_USER_ID, + } + + +__all__ = ["router", "DEMO_USER_ID"] + diff --git a/backend/src/api/routes/index.py b/backend/src/api/routes/index.py index 482cead..14a934b 100644 --- a/backend/src/api/routes/index.py +++ b/backend/src/api/routes/index.py @@ -14,6 +14,19 @@ from ...services.vault import VaultService from ..middleware import AuthContext, get_auth_context +DEMO_USER_ID = "demo-user" + + +def _ensure_index_mutation_allowed(user_id: str) -> None: + if user_id == DEMO_USER_ID: + raise HTTPException( + status_code=403, + detail={ + "error": "demo_read_only", + "message": "Demo mode does not allow index rebuilds. Sign in to manage the index.", + }, + ) + router = APIRouter() @@ -79,6 +92,7 @@ async def rebuild_index(auth: AuthContext = Depends(get_auth_context)): """Rebuild the entire index from scratch.""" start_time = time.time() user_id = auth.user_id + _ensure_index_mutation_allowed(user_id) vault_service = VaultService() indexer_service = IndexerService() diff --git a/backend/src/api/routes/notes.py b/backend/src/api/routes/notes.py index e8db84f..f0b835b 100644 --- a/backend/src/api/routes/notes.py +++ b/backend/src/api/routes/notes.py @@ -16,6 +16,19 @@ router = APIRouter() +DEMO_USER_ID = "demo-user" + + +def _ensure_write_allowed(user_id: str) -> None: + if user_id == DEMO_USER_ID: + raise HTTPException( + status_code=403, + detail={ + "error": "demo_read_only", + "message": "Demo mode is read-only. Sign in with Hugging Face to make changes.", + }, + ) + class ConflictError(Exception): """Raised when optimistic concurrency check fails.""" @@ -60,19 +73,28 @@ async def list_notes( async def create_note(create: NoteCreate, auth: AuthContext = Depends(get_auth_context)): """Create a new note.""" user_id = auth.user_id + _ensure_write_allowed(user_id) vault_service = VaultService() indexer_service = IndexerService() db_service = DatabaseService() try: note_path = create.note_path - + # Check if note already exists try: vault_service.read_note(user_id, note_path) - raise HTTPException(status_code=409, detail=f"Note already exists: {note_path}") + raise HTTPException( + status_code=409, + detail={ + "error": "note_already_exists", + "message": f"A note with the name '{note_path}' already exists. Please choose a different name.", + } + ) except FileNotFoundError: pass # Good, note doesn't exist + except HTTPException: + raise # Re-raise HTTP exceptions # Prepare metadata metadata = create.metadata.model_dump() if create.metadata else {} @@ -102,15 +124,27 @@ async def create_note(create: NoteCreate, auth: AuthContext = Depends(get_auth_c # Return created note created = written_note["metadata"].get("created") updated_ts = written_note["metadata"].get("updated") - - if isinstance(created, str): - created = datetime.fromisoformat(created.replace("Z", "+00:00")) - elif not isinstance(created, datetime): + + # Parse created timestamp + try: + if isinstance(created, str): + created = datetime.fromisoformat(created.replace("Z", "+00:00")) + elif isinstance(created, datetime): + pass # Already a datetime + else: + created = datetime.now() + except (ValueError, TypeError): created = datetime.now() - - if isinstance(updated_ts, str): - updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00")) - elif not isinstance(updated_ts, datetime): + + # Parse updated timestamp + try: + if isinstance(updated_ts, str): + updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00")) + elif isinstance(updated_ts, datetime): + pass # Already a datetime + else: + updated_ts = created + except (ValueError, TypeError): updated_ts = created return Note( @@ -162,15 +196,27 @@ async def get_note(path: str, auth: AuthContext = Depends(get_auth_context)): metadata = note_data.get("metadata", {}) created = metadata.get("created") updated = metadata.get("updated") - - if isinstance(created, str): - created = datetime.fromisoformat(created.replace("Z", "+00:00")) - elif not isinstance(created, datetime): + + # Parse created timestamp + try: + if isinstance(created, str): + created = datetime.fromisoformat(created.replace("Z", "+00:00")) + elif isinstance(created, datetime): + pass # Already a datetime + else: + created = datetime.now() + except (ValueError, TypeError): created = datetime.now() - - if isinstance(updated, str): - updated = datetime.fromisoformat(updated.replace("Z", "+00:00")) - elif not isinstance(updated, datetime): + + # Parse updated timestamp + try: + if isinstance(updated, str): + updated = datetime.fromisoformat(updated.replace("Z", "+00:00")) + elif isinstance(updated, datetime): + pass # Already a datetime + else: + updated = created + except (ValueError, TypeError): updated = created return Note( @@ -198,6 +244,7 @@ async def update_note( ): """Update a note with optimistic concurrency control.""" user_id = auth.user_id + _ensure_write_allowed(user_id) vault_service = VaultService() indexer_service = IndexerService() db_service = DatabaseService() @@ -252,15 +299,27 @@ async def update_note( # Return updated note created = written_note["metadata"].get("created") updated_ts = written_note["metadata"].get("updated") - - if isinstance(created, str): - created = datetime.fromisoformat(created.replace("Z", "+00:00")) - elif not isinstance(created, datetime): + + # Parse created timestamp + try: + if isinstance(created, str): + created = datetime.fromisoformat(created.replace("Z", "+00:00")) + elif isinstance(created, datetime): + pass # Already a datetime + else: + created = datetime.now() + except (ValueError, TypeError): created = datetime.now() - - if isinstance(updated_ts, str): - updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00")) - elif not isinstance(updated_ts, datetime): + + # Parse updated timestamp + try: + if isinstance(updated_ts, str): + updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00")) + elif isinstance(updated_ts, datetime): + pass # Already a datetime + else: + updated_ts = created + except (ValueError, TypeError): updated_ts = created return Note( @@ -284,5 +343,143 @@ async def update_note( raise HTTPException(status_code=500, detail=f"Failed to update note: {str(e)}") +from pydantic import BaseModel + + +class NoteMoveRequest(BaseModel): + """Request payload for moving/renaming a note.""" + new_path: str + + +@router.patch("/api/notes/{path:path}", response_model=Note) +async def move_note( + path: str, + move_request: NoteMoveRequest, + auth: AuthContext = Depends(get_auth_context), +): + """Move or rename a note to a new path.""" + user_id = auth.user_id + _ensure_write_allowed(user_id) + vault_service = VaultService() + indexer_service = IndexerService() + db_service = DatabaseService() + + try: + # URL decode the old path + old_path = unquote(path) + new_path = move_request.new_path + + # Move the note in the vault + moved_note = vault_service.move_note(user_id, old_path, new_path) + + # Delete old note index entries + conn = db_service.connect() + try: + with conn: + # Delete from all index tables + conn.execute("DELETE FROM note_metadata WHERE user_id = ? AND note_path = ?", (user_id, old_path)) + conn.execute("DELETE FROM note_links WHERE user_id = ? AND source_path = ?", (user_id, old_path)) + conn.execute("DELETE FROM note_tags WHERE user_id = ? AND note_path = ?", (user_id, old_path)) + finally: + conn.close() + + # Index the note at new location + new_version = indexer_service.index_note(user_id, moved_note) + + # Update index health + conn = db_service.connect() + try: + with conn: + indexer_service.update_index_health(conn, user_id) + finally: + conn.close() + + # Parse metadata + metadata = moved_note.get("metadata", {}) + created = metadata.get("created") + updated = metadata.get("updated") + + # Parse created timestamp + try: + if isinstance(created, str): + created = datetime.fromisoformat(created.replace("Z", "+00:00")) + elif isinstance(created, datetime): + pass # Already a datetime + else: + created = datetime.now() + except (ValueError, TypeError): + created = datetime.now() + + # Parse updated timestamp + try: + if isinstance(updated, str): + updated = datetime.fromisoformat(updated.replace("Z", "+00:00")) + elif isinstance(updated, datetime): + pass # Already a datetime + else: + updated = created + except (ValueError, TypeError): + updated = created + + return Note( + user_id=user_id, + note_path=new_path, + version=new_version, + title=moved_note["title"], + metadata=metadata, + body=moved_note["body"], + created=created, + updated=updated, + size_bytes=moved_note.get("size_bytes", len(moved_note["body"].encode("utf-8"))), + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Note not found: {path}") + except FileExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to move note: {str(e)}") + + +@router.delete("/api/notes/{path:path}", status_code=204) +async def delete_note(path: str): + """Delete a note.""" + user_id = get_user_id() + vault_service = VaultService() + indexer_service = IndexerService() + db_service = DatabaseService() + + try: + # URL decode the path + note_path = unquote(path) + + # Delete note from vault + vault_service.delete_note(user_id, note_path) + + # Delete from index + conn = db_service.connect() + try: + with conn: + # Delete from all index tables + conn.execute("DELETE FROM note_metadata WHERE user_id = ? AND note_path = ?", (user_id, note_path)) + conn.execute("DELETE FROM note_links WHERE user_id = ? AND source_path = ?", (user_id, note_path)) + conn.execute("DELETE FROM note_tags WHERE user_id = ? AND note_path = ?", (user_id, note_path)) + finally: + conn.close() + + # Update index health + conn = db_service.connect() + try: + with conn: + indexer_service.update_index_health(conn, user_id) + finally: + conn.close() + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Note not found: {path}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete note: {str(e)}") + + __all__ = ["router", "ConflictError"] diff --git a/backend/src/services/vault.py b/backend/src/services/vault.py index b01ecd1..477bcd5 100644 --- a/backend/src/services/vault.py +++ b/backend/src/services/vault.py @@ -208,11 +208,11 @@ def write_note( def delete_note(self, user_id: str, note_path: str) -> None: """Delete a note from the vault.""" start_time = time.time() - + absolute_path = self.resolve_note_path(user_id, note_path) try: absolute_path.unlink() - + duration_ms = (time.time() - start_time) * 1000 logger.info( "Note deleted successfully", @@ -230,6 +230,54 @@ def delete_note(self, user_id: str, note_path: str) -> None: ) raise FileNotFoundError(f"Note not found: {note_path}") from exc + def move_note(self, user_id: str, old_path: str, new_path: str) -> VaultNote: + """Move or rename a note to a new path.""" + start_time = time.time() + + # Validate both paths + is_valid_old, msg_old = validate_note_path(old_path) + if not is_valid_old: + raise ValueError(f"Invalid source path: {msg_old}") + + is_valid_new, msg_new = validate_note_path(new_path) + if not is_valid_new: + raise ValueError(f"Invalid destination path: {msg_new}") + + # Resolve absolute paths + old_absolute = self.resolve_note_path(user_id, old_path) + new_absolute = self.resolve_note_path(user_id, new_path) + + # Check if source exists + if not old_absolute.exists(): + raise FileNotFoundError(f"Source note not found: {old_path}") + + # Check if destination already exists + if new_absolute.exists(): + raise FileExistsError(f"Destination note already exists: {new_path}") + + # Create destination directory if needed + new_absolute.parent.mkdir(parents=True, exist_ok=True) + + # Move the file + old_absolute.rename(new_absolute) + + # Read and return the note from new location + note = self.read_note(user_id, new_path) + + duration_ms = (time.time() - start_time) * 1000 + logger.info( + "Note moved successfully", + extra={ + "user_id": user_id, + "old_path": old_path, + "new_path": new_path, + "operation": "move", + "duration_ms": f"{duration_ms:.2f}" + } + ) + + return note + def list_notes(self, user_id: str, folder: str | None = None) -> List[Dict[str, Any]]: """List notes (optionally scoped to a folder) with titles and timestamps.""" base = self.initialize_vault(user_id).resolve() diff --git a/frontend/.vite/deps/_metadata.json b/frontend/.vite/deps/_metadata.json new file mode 100644 index 0000000..766c1f6 --- /dev/null +++ b/frontend/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "a27d90aa", + "configHash": "acf43111", + "lockfileHash": "7dd1e565", + "browserHash": "f059b2e5", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/frontend/.vite/deps/package.json b/frontend/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/frontend/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1649da8..346d572 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,11 +30,13 @@ "react-router-dom": "^7.9.6", "remark-gfm": "^4.0.1", "shadcn-ui": "^0.9.0", + "sonner": "^2.0.7", "typescript": "~5.9.3", "vite": "^7.2.2" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/d3-force": "^3.0.10", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", @@ -120,7 +122,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -712,7 +713,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1738,7 +1738,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3271,6 +3270,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3332,7 +3338,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3342,7 +3347,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3353,7 +3357,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3417,7 +3420,6 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -3699,7 +3701,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3967,24 +3968,28 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -4030,7 +4035,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4730,7 +4734,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5148,7 +5151,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5417,7 +5419,6 @@ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -6206,9 +6207,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6216,6 +6217,10 @@ }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -6539,7 +6544,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8409,7 +8413,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8720,29 +8723,11 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8752,7 +8737,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9519,6 +9503,16 @@ "dev": true, "license": "MIT" }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9833,7 +9827,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9971,7 +9964,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10151,7 +10143,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10554,7 +10545,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10745,7 +10735,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -10872,7 +10861,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 0e40e12..4dcbd5d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,11 +32,13 @@ "react-router-dom": "^7.9.6", "remark-gfm": "^4.0.1", "shadcn-ui": "^0.9.0", + "sonner": "^2.0.7", "typescript": "~5.9.3", "vite": "^7.2.2" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/d3-force": "^3.0.10", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e2a95fe..251c64b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,9 @@ import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from import { MainApp } from './pages/MainApp'; import { Login } from './pages/Login'; import { Settings } from './pages/Settings'; -import { isAuthenticated, getCurrentUser, setAuthTokenFromHash } from './services/auth'; +import { isAuthenticated, getCurrentUser, setAuthTokenFromHash, ensureDemoToken, isDemoSession } from './services/auth'; +import { AuthLoadingSkeleton } from './components/AuthLoadingSkeleton'; +import { Toaster } from './components/ui/toaster'; import './App.css'; // Protected route wrapper with auth check @@ -26,13 +28,21 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { } if (!isAuthenticated()) { - setHasToken(false); - setIsChecking(false); - return; + const demoReady = await ensureDemoToken(); + if (!demoReady) { + setHasToken(false); + setIsChecking(false); + return; + } } setHasToken(true); + if (isDemoSession()) { + setIsChecking(false); + return; + } + const token = localStorage.getItem('auth_token'); // Skip validation for local dev token if (token === 'local-dev-token') { @@ -44,7 +54,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { // Verify the token is valid by calling getCurrentUser await getCurrentUser(); setIsChecking(false); - } catch (err) { + } catch { // Token is invalid (401), redirect to login console.warn('Authentication failed, redirecting to login'); localStorage.removeItem('auth_token'); @@ -60,11 +70,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { }, [navigate, location]); if (isChecking) { - return ( -
-
Loading...
-
- ); + return ; } if (!hasToken) { @@ -76,27 +82,30 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { function App() { return ( - - - } /> - - - - } - /> - - - - } - /> - - + <> + + + } /> + + + + } + /> + + + + } + /> + + + + ); } diff --git a/frontend/src/components/AuthLoadingSkeleton.tsx b/frontend/src/components/AuthLoadingSkeleton.tsx new file mode 100644 index 0000000..b0e9b2e --- /dev/null +++ b/frontend/src/components/AuthLoadingSkeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function AuthLoadingSkeleton() { + return ( +
+
+ {/* Logo/header area */} +
+ + +
+ + {/* Loading animation indicator */} +
+
+
+
+
+ + {/* Subtle loading text */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/DeleteConfirmationDialog.tsx b/frontend/src/components/DeleteConfirmationDialog.tsx new file mode 100644 index 0000000..b997b32 --- /dev/null +++ b/frontend/src/components/DeleteConfirmationDialog.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface DeleteConfirmationDialogProps { + isOpen: boolean; + noteName: string; + isDeleting?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export function DeleteConfirmationDialog({ + isOpen, + noteName, + isDeleting = false, + onConfirm, + onCancel, +}: DeleteConfirmationDialogProps) { + return ( + + + + Delete Note + + Are you sure you want to delete "{noteName}"? This action cannot be undone. + + + + + + + + + ); +} diff --git a/frontend/src/components/DirectoryTree.tsx b/frontend/src/components/DirectoryTree.tsx index 9e6aaa9..d5a9da4 100644 --- a/frontend/src/components/DirectoryTree.tsx +++ b/frontend/src/components/DirectoryTree.tsx @@ -20,6 +20,7 @@ interface DirectoryTreeProps { notes: NoteSummary[]; selectedPath?: string; onSelectNote: (path: string) => void; + onMoveNote?: (oldPath: string, newFolderPath: string) => void; } /** @@ -89,10 +90,46 @@ interface TreeNodeItemProps { depth: number; selectedPath?: string; onSelectNote: (path: string) => void; + onMoveNote?: (oldPath: string, newFolderPath: string) => void; } -function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemProps) { +function TreeNodeItem({ node, depth, selectedPath, onSelectNote, onMoveNote }: TreeNodeItemProps) { const [isOpen, setIsOpen] = useState(depth < 2); // Auto-expand first 2 levels + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (node.type === 'folder') { + setIsDragOver(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + if (node.type === 'folder') { + const draggedPath = e.dataTransfer.getData('application/note-path'); + if (draggedPath && onMoveNote) { + onMoveNote(draggedPath, node.path); + } + } + }; + + const handleDragStart = (e: React.DragEvent) => { + if (node.type === 'file') { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('application/note-path', node.path); + } + }; if (node.type === 'folder') { return ( @@ -101,10 +138,14 @@ function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemP variant="ghost" className={cn( "w-full justify-start font-normal px-2 h-8", - "hover:bg-accent" + "hover:bg-accent transition-colors duration-200", + isDragOver && "bg-accent ring-2 ring-primary" )} style={{ paddingLeft: `${depth * 12 + 8}px` }} onClick={() => setIsOpen(!isOpen)} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} > {isOpen ? ( @@ -123,6 +164,7 @@ function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemP depth={depth + 1} selectedPath={selectedPath} onSelectNote={onSelectNote} + onMoveNote={onMoveNote} /> ))}
@@ -141,11 +183,14 @@ function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemP variant="ghost" className={cn( "w-full justify-start font-normal px-2 h-8", - "hover:bg-accent", - isSelected && "bg-accent" + "hover:bg-accent transition-colors duration-200", + isSelected && "bg-accent animate-highlight-pulse", + "cursor-move" )} style={{ paddingLeft: `${depth * 12 + 8}px` }} onClick={() => onSelectNote(node.path)} + draggable + onDragStart={handleDragStart} > {displayName} @@ -153,7 +198,7 @@ function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemP ); } -export function DirectoryTree({ notes, selectedPath, onSelectNote }: DirectoryTreeProps) { +export function DirectoryTree({ notes, selectedPath, onSelectNote, onMoveNote }: DirectoryTreeProps) { const tree = useMemo(() => buildTree(notes), [notes]); if (notes.length === 0) { @@ -174,6 +219,7 @@ export function DirectoryTree({ notes, selectedPath, onSelectNote }: DirectoryTr depth={0} selectedPath={selectedPath} onSelectNote={onSelectNote} + onMoveNote={onMoveNote} /> ))}
diff --git a/frontend/src/components/DirectoryTreeSkeleton.tsx b/frontend/src/components/DirectoryTreeSkeleton.tsx new file mode 100644 index 0000000..fc9887c --- /dev/null +++ b/frontend/src/components/DirectoryTreeSkeleton.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function DirectoryTreeSkeleton() { + return ( +
+ {/* Main folders */} + {[1, 2, 3].map((i) => ( +
+ {/* Folder item */} +
+ + +
+ + {/* Sub-items */} + {[1, 2].map((j) => ( +
+ + +
+ ))} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx index a589bb9..b0329a2 100644 --- a/frontend/src/components/GraphView.tsx +++ b/frontend/src/components/GraphView.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, useMemo } from 'react'; import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d'; import { forceRadial } from 'd3-force'; -import type { GraphData, GraphNode } from '@/types/graph'; +import type { GraphData } from '@/types/graph'; import { getGraphData } from '@/services/api'; import { Loader2, AlertCircle } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -14,7 +14,7 @@ export function GraphView({ onSelectNote }: GraphViewProps) { const [data, setData] = useState({ nodes: [], links: [] }); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const graphRef = useRef(); + const graphRef = useRef(undefined); // Theme detection would go here, simplified for MVP const isDark = document.documentElement.classList.contains('dark'); diff --git a/frontend/src/components/NoteViewer.tsx b/frontend/src/components/NoteViewer.tsx index f4b4aec..9ca4f51 100644 --- a/frontend/src/components/NoteViewer.tsx +++ b/frontend/src/components/NoteViewer.tsx @@ -40,7 +40,7 @@ export function NoteViewer({ // [[Link|Alias]] -> [Alias](wikilink:Link) const processedBody = useMemo(() => { if (!note.body) return ''; - const processed = note.body.replace(/\[\[([^\]]+)\]\]/g, (match, content) => { + const processed = note.body.replace(/\[\[([^\]]+)\]\]/g, (_match, content) => { const [link, alias] = content.split('|'); const displayText = alias || link; const href = `wikilink:${encodeURIComponent(link)}`; @@ -63,13 +63,13 @@ export function NoteViewer({ return (
{/* Header */} -
+
-

{note.title}

-

{note.note_path}

+

{note.title}

+

{note.note_path}

-
+
{onEdit && ( - - - - Create New Note - - Enter a name for your new note. The .md extension will be added automatically if not provided. - - -
-
- - setNewNoteName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleCreateNote(); - } - }} - /> -
-
- - - -
- + {isDemoMode && ( + + )} +
+ )} {/* Left sidebar */}
+ + + + + + + Create New Note + + Enter a name for your new note. The .md extension will be added automatically if not provided. + + +
+
+ + setNewNoteName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreateNote(); + } + }} + /> +
+
+ + + + +
+
+ + + + + + + Create New Folder + + Enter a name for your new folder. You can use forward slashes for nested folders (e.g., "Projects/Work"). + + +
+
+ + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreateFolder(); + } + }} + /> +
+
+ + + + +
+
{isLoadingNotes ? ( -
- Loading notes... -
+ ) : ( )}
@@ -316,7 +622,7 @@ export function MainApp() {
)} - + {isGraphView ? ( { handleSelectNote(path); @@ -324,9 +630,7 @@ export function MainApp() { }} /> ) : ( isLoadingNote ? ( -
-
Loading note...
-
+ ) : currentNote ? ( isEditMode ? ( setIsDeleteDialogOpen(true)} onWikilinkClick={handleWikilinkClick} /> ) @@ -362,7 +667,7 @@ export function MainApp() {
{/* Footer with Index Health */} -
+
{indexHealth ? ( @@ -390,6 +695,15 @@ export function MainApp() { )}
+ + {/* Delete Confirmation Dialog */} + setIsDeleteDialogOpen(false)} + />
); } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 1cf9107..4825326 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -10,7 +10,8 @@ import { Input } from '@/components/ui/input'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; -import { getCurrentUser, getToken, logout, getStoredToken } from '@/services/auth'; +import { SettingsSectionSkeleton } from '@/components/SettingsSectionSkeleton'; +import { getCurrentUser, getToken, logout, getStoredToken, isDemoSession, AUTH_TOKEN_CHANGED_EVENT } from '@/services/auth'; import { getIndexHealth, rebuildIndex, type RebuildResponse } from '@/services/api'; import type { User } from '@/types/user'; import type { IndexHealth } from '@/types/search'; @@ -24,11 +25,18 @@ export function Settings() { const [isRebuilding, setIsRebuilding] = useState(false); const [rebuildResult, setRebuildResult] = useState(null); const [error, setError] = useState(null); + const [isDemoMode, setIsDemoMode] = useState(isDemoSession()); useEffect(() => { loadData(); }, []); + useEffect(() => { + const handler = () => setIsDemoMode(isDemoSession()); + window.addEventListener(AUTH_TOKEN_CHANGED_EVENT, handler); + return () => window.removeEventListener(AUTH_TOKEN_CHANGED_EVENT, handler); + }, []); + const loadData = async () => { try { const token = getStoredToken(); @@ -59,6 +67,10 @@ export function Settings() { }; const handleGenerateToken = async () => { + if (isDemoMode) { + setError('Demo mode is read-only. Sign in to generate new tokens.'); + return; + } try { setError(null); const tokenResponse = await getToken(); @@ -80,6 +92,10 @@ export function Settings() { }; const handleRebuildIndex = async () => { + if (isDemoMode) { + setError('Demo mode is read-only. Sign in to rebuild the index.'); + return; + } setIsRebuilding(true); setError(null); setRebuildResult(null); @@ -124,6 +140,13 @@ export function Settings() { {/* Content */}
+ {isDemoMode && ( + + + You are viewing the shared demo vault. Sign in with Hugging Face from the main app to enable token generation and index management. + + + )} {error && ( {error} @@ -131,13 +154,13 @@ export function Settings() { )} {/* Profile */} - - - Profile - Your account information - - - {user ? ( + {user ? ( + + + Profile + Your account information + +
@@ -158,11 +181,14 @@ export function Settings() { Sign Out
- ) : ( -
Loading user data...
- )} -
-
+
+
+ ) : ( + + )} {/* API Token */} @@ -199,7 +225,7 @@ export function Settings() {
- @@ -244,61 +270,62 @@ export function Settings() { {/* Index Health */} - - - Index Health - - Full-text search index status and maintenance - - - - {indexHealth ? ( - <> -
-
-
Notes Indexed
-
{indexHealth.note_count}
-
-
-
Last Updated
-
{formatDate(indexHealth.last_incremental_update)}
-
+ {indexHealth ? ( + + + Index Health + + Full-text search index status and maintenance + + + +
+
+
Notes Indexed
+
{indexHealth.note_count}
- - -
-
Last Full Rebuild
-
{formatDate(indexHealth.last_full_rebuild)}
+
Last Updated
+
{formatDate(indexHealth.last_incremental_update)}
+
- {rebuildResult && ( - - - Index rebuilt successfully! Indexed {rebuildResult.notes_indexed} notes in {rebuildResult.duration_ms}ms - - - )} + - +
+
Last Full Rebuild
+
{formatDate(indexHealth.last_full_rebuild)}
+
-
- Rebuilding the index will re-scan all notes and update the full-text search database. - This may take a few seconds for large vaults. -
- - ) : ( -
Loading index health...
- )} -
-
+ {rebuildResult && ( + + + Index rebuilt successfully! Indexed {rebuildResult.notes_indexed} notes in {rebuildResult.duration_ms}ms + + + )} + + + +
+ Rebuilding the index will re-scan all notes and update the full-text search database. + This may take a few seconds for large vaults. +
+ + + ) : ( + + )}
); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6cf76bf..6f679a0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -71,7 +71,19 @@ async function apiFetch( if (!response.ok) { let errorData: APIError; try { - errorData = await response.json(); + const jsonData = await response.json(); + // Handle both standard APIError format and FastAPI HTTPException format + if ('detail' in jsonData && typeof jsonData.detail === 'string') { + // FastAPI HTTPException with detail as string + errorData = { + error: jsonData.error || 'Error', + message: jsonData.detail, + detail: jsonData.detail as any, + }; + } else { + // Standard APIError format + errorData = jsonData as APIError; + } } catch { errorData = { error: 'Unknown error', @@ -139,6 +151,17 @@ export async function getBacklinks(path: string): Promise { return apiFetch(`/api/backlinks/${encodedPath}`); } +export interface DemoTokenResponse { + token: string; + token_type: string; + expires_at: string; + user_id: string; +} + +export async function getDemoToken(): Promise { + return apiFetch('/api/demo/token'); +} + /** * T070: Get all tags with counts */ @@ -196,3 +219,24 @@ export async function rebuildIndex(): Promise { }); } +/** + * Move or rename a note to a new path + */ +export async function moveNote(oldPath: string, newPath: string): Promise { + const encodedPath = encodeURIComponent(oldPath); + return apiFetch(`/api/notes/${encodedPath}`, { + method: 'PATCH', + body: JSON.stringify({ new_path: newPath }), + }); +} + +/** + * Delete a note + */ +export async function deleteNote(path: string): Promise { + const encodedPath = encodeURIComponent(path); + return apiFetch(`/api/notes/${encodedPath}`, { + method: 'DELETE', + }); +} + diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index 55128df..ca29942 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -3,9 +3,42 @@ */ import type { User } from '@/types/user'; import type { TokenResponse } from '@/types/auth'; +import type { DemoTokenResponse } from '@/services/api'; +import { getDemoToken } from '@/services/api'; + +const AUTH_TOKEN_KEY = 'auth_token'; +const AUTH_TOKEN_SOURCE_KEY = 'auth_token_source'; +const AUTH_TOKEN_EXPIRES_KEY = 'auth_token_expires_at'; +export const AUTH_TOKEN_CHANGED_EVENT = 'auth-token-changed'; + +type TokenSource = 'user' | 'demo'; const API_BASE = ''; +function notifyTokenChange(): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(AUTH_TOKEN_CHANGED_EVENT)); + } +} + +function storeAuthToken(token: string, source: TokenSource, expiresAt?: string): void { + localStorage.setItem(AUTH_TOKEN_KEY, token); + localStorage.setItem(AUTH_TOKEN_SOURCE_KEY, source); + if (expiresAt) { + localStorage.setItem(AUTH_TOKEN_EXPIRES_KEY, expiresAt); + } else { + localStorage.removeItem(AUTH_TOKEN_EXPIRES_KEY); + } + notifyTokenChange(); +} + +function clearStoredAuthToken(): void { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_TOKEN_SOURCE_KEY); + localStorage.removeItem(AUTH_TOKEN_EXPIRES_KEY); + notifyTokenChange(); +} + /** * T105: Redirect to HF OAuth login */ @@ -17,7 +50,7 @@ export function login(): void { * Logout - clear token and redirect */ export function logout(): void { - localStorage.removeItem('auth_token'); + clearStoredAuthToken(); window.location.href = '/'; } @@ -25,7 +58,7 @@ export function logout(): void { * T106: Get current authenticated user */ export async function getCurrentUser(): Promise { - const token = localStorage.getItem('auth_token'); + const token = localStorage.getItem(AUTH_TOKEN_KEY); const response = await fetch(`${API_BASE}/api/me`, { headers: { @@ -45,7 +78,7 @@ export async function getCurrentUser(): Promise { * T107: Generate new API token for MCP access */ export async function getToken(): Promise { - const token = localStorage.getItem('auth_token'); + const token = localStorage.getItem(AUTH_TOKEN_KEY); const response = await fetch(`${API_BASE}/api/tokens`, { method: 'POST', @@ -62,7 +95,7 @@ export async function getToken(): Promise { const tokenResponse: TokenResponse = await response.json(); // Store the new token - localStorage.setItem('auth_token', tokenResponse.token); + storeAuthToken(tokenResponse.token, 'user', tokenResponse.expires_at); return tokenResponse; } @@ -71,14 +104,25 @@ export async function getToken(): Promise { * Check if user is authenticated */ export function isAuthenticated(): boolean { - return !!localStorage.getItem('auth_token'); + const token = localStorage.getItem(AUTH_TOKEN_KEY); + if (!token) { + return false; + } + if (isDemoSession()) { + return !demoTokenExpired(); + } + return true; } /** * Get stored token */ export function getStoredToken(): string | null { - return localStorage.getItem('auth_token'); + return localStorage.getItem(AUTH_TOKEN_KEY); +} + +export function isDemoSession(): boolean { + return localStorage.getItem(AUTH_TOKEN_SOURCE_KEY) === 'demo'; } /** @@ -91,7 +135,7 @@ export function setAuthTokenFromHash(): boolean { if (hash.startsWith('#token=')) { const token = hash.substring(7); // Remove '#token=' if (token) { - localStorage.setItem('auth_token', token); + storeAuthToken(token, 'user'); // Clean up the URL window.history.replaceState(null, '', window.location.pathname); return true; @@ -100,3 +144,38 @@ export function setAuthTokenFromHash(): boolean { return false; } +function demoTokenExpired(): boolean { + const expiresAt = localStorage.getItem(AUTH_TOKEN_EXPIRES_KEY); + if (!expiresAt) { + return false; + } + const now = Date.now(); + return new Date(expiresAt).getTime() <= now; +} + +async function requestDemoToken(): Promise { + return getDemoToken(); +} + +export async function ensureDemoToken(): Promise { + // If we already have a user token, nothing to do + if (isAuthenticated() && !isDemoSession()) { + return true; + } + + // If we have a demo token that hasn't expired, reuse it + if (isAuthenticated() && isDemoSession() && !demoTokenExpired()) { + return true; + } + + try { + const demoResponse = await requestDemoToken(); + storeAuthToken(demoResponse.token, 'demo', demoResponse.expires_at); + return true; + } catch (error) { + console.warn('Failed to obtain demo token', error); + clearStoredAuthToken(); + return false; + } +} + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 71cad11..3dde64c 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -63,10 +63,37 @@ export default { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, + "fade-in": { + from: { opacity: "0" }, + to: { opacity: "1" }, + }, + "slide-in-up": { + from: { opacity: "0", transform: "translateY(10px)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + "slide-in-down": { + from: { opacity: "0", transform: "translateY(-10px)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + "highlight-pulse": { + "0%": { backgroundColor: "transparent" }, + "50%": { backgroundColor: "hsl(var(--accent))" }, + "100%": { backgroundColor: "transparent" }, + }, + "skeleton-pulse": { + "0%": { opacity: "1" }, + "50%": { opacity: "0.5" }, + "100%": { opacity: "1" }, + }, }, animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", + "accordion-down": "accordion-down 0.3s ease-out", + "accordion-up": "accordion-up 0.3s ease-out", + "fade-in": "fade-in 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)", + "slide-in-up": "slide-in-up 1s cubic-bezier(0.34, 1.56, 0.64, 1)", + "slide-in-down": "slide-in-down 1s cubic-bezier(0.34, 1.56, 0.64, 1)", + "highlight-pulse": "highlight-pulse 1s ease-in-out", + "skeleton-pulse": "skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", }, }, }, diff --git a/specs/003-ai-chat-window/checklists/requirements.md b/specs/003-ai-chat-window/checklists/requirements.md new file mode 100644 index 0000000..77d5599 --- /dev/null +++ b/specs/003-ai-chat-window/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: AI Chat Window + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-11-26 +**Feature**: [Link to spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` \ No newline at end of file diff --git a/specs/003-ai-chat-window/contracts/chat-api.yaml b/specs/003-ai-chat-window/contracts/chat-api.yaml new file mode 100644 index 0000000..1baf205 --- /dev/null +++ b/specs/003-ai-chat-window/contracts/chat-api.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.0 +info: + title: Document MCP Chat API + version: 1.0.0 +paths: + /api/chat: + post: + summary: Send a message to the AI agent + description: Streams the response using Server-Sent Events (SSE). + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + history: + type: array + items: + type: object + properties: + role: + type: string + enum: [user, assistant, system] + content: + type: string + persona: + type: string + default: default + model: + type: string + responses: + '200': + description: Stream of tokens + content: + text/event-stream: + schema: + type: string + example: "data: {\"type\": \"token\", \"content\": \"Hello\"}\n\n" + /api/chat/personas: + get: + summary: List available personas + responses: + '200': + description: List of personas + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string diff --git a/specs/003-ai-chat-window/data-model.md b/specs/003-ai-chat-window/data-model.md new file mode 100644 index 0000000..1933470 --- /dev/null +++ b/specs/003-ai-chat-window/data-model.md @@ -0,0 +1,51 @@ +# Data Model: AI Chat Window + +## Entities + +### ChatMessage +Represents a single message in the conversation history. + +| Field | Type | Description | +|-------|------|-------------| +| `role` | `enum` | `user`, `assistant`, `system` | +| `content` | `string` | The text content of the message | +| `timestamp` | `datetime` | ISO 8601 timestamp of creation | + +### ChatRequest +The payload sent from Frontend to Backend to initiate/continue a chat. + +| Field | Type | Description | +|-------|------|-------------| +| `message` | `string` | The new user message | +| `history` | `List[ChatMessage]` | Previous conversation context | +| `persona` | `string` | ID of the selected persona (e.g., "default", "auto-linker") | +| `model` | `string` | Optional: Specific OpenRouter model ID | + +### ChatResponseChunk (SSE) +The streaming data format received by the frontend. + +| Field | Type | Description | +|-------|------|-------------| +| `type` | `enum` | `token` (text chunk) or `tool_call` (tool execution status) | +| `content` | `string` | The text fragment or status message | +| `done` | `boolean` | True if generation is complete | + +## Persistence (Markdown Format) +Saved in `data/vaults/{user_id}/Chat Logs/{timestamp}.md` + +```markdown +--- +title: Chat Session - {timestamp} +date: {date} +tags: [chat-log, {persona}] +model: {model_id} +--- + +# Chat Session + +## User ({time}) +What is the summary of... + +## Assistant ({time}) +Based on your notes... +``` diff --git a/specs/003-ai-chat-window/plan.md b/specs/003-ai-chat-window/plan.md new file mode 100644 index 0000000..7088bd6 --- /dev/null +++ b/specs/003-ai-chat-window/plan.md @@ -0,0 +1,83 @@ +# Implementation Plan: AI Chat Window + +**Branch**: `003-ai-chat-window` | **Date**: 2025-11-26 | **Spec**: [specs/003-ai-chat-window/spec.md](spec.md) +**Input**: Feature specification from `/specs/003-ai-chat-window/spec.md` + +## Summary + +Implement an integrated AI Chat Window powered by OpenRouter. This involves a new backend `POST /api/chat` endpoint that uses the `openai` client to communicate with LLMs, exposing internal `VaultService` methods as tools. The frontend will receive a new `ChatWindow` component with streaming support (SSE) and persona selection. Chat history will be persisted as Markdown files in the vault. + +## Technical Context + +**Language/Version**: Python 3.11+ (Backend), TypeScript/React 18 (Frontend) +**Primary Dependencies**: +- Backend: `openai` (for OpenRouter), `fastapi` (StreamingResponse) +- Frontend: `fetch` (Streaming body reading), Tailwind CSS +**Storage**: +- Active Session: In-memory (or transient SQLite) +- Persistence: Markdown files in `Chat Logs/` folder +**Testing**: `pytest` (Backend), Manual/E2E (Frontend) +**Target Platform**: Web Application (Linux Dev Environment) +**Project Type**: Full-stack (FastAPI + React) +**Performance Goals**: <3s time-to-first-token +**Constraints**: Must reuse existing `VaultService` logic; no new database services (keep it lightweight). + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] **Brownfield Integration**: Reuses `VaultService` and `IndexerService`. Matches `backend/src` and `frontend/src` structure. +- [x] **Test-Backed Development**: Backend logic will be unit tested. +- [x] **Incremental Delivery**: New API route and independent UI component. +- [x] **Specification-Driven**: All features map to `spec.md` requirements. + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-ai-chat-window/ +β”œβ”€β”€ plan.md # This file +β”œβ”€β”€ research.md # Phase 0 output +β”œβ”€β”€ data-model.md # Phase 1 output +β”œβ”€β”€ quickstart.md # Phase 1 output +β”œβ”€β”€ contracts/ # Phase 1 output +└── tasks.md # Phase 2 output +``` + +### Source Code (repository root) + +```text +backend/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ api/ +β”‚ β”‚ └── routes/ +β”‚ β”‚ └── chat.py # NEW: Chat endpoint logic +β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”œβ”€β”€ chat.py # NEW: Chat orchestration service (OpenAI wrapper) +β”‚ β”‚ └── prompts.py # NEW: System prompts/personas definitions +β”‚ └── models/ +β”‚ └── chat.py # NEW: Pydantic models for Chat requests/responses +└── tests/ + └── unit/ + └── test_chat_service.py # NEW: Tests for chat logic + +frontend/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”œβ”€β”€ chat/ # NEW: Chat UI Components +β”‚ β”‚ β”‚ β”œβ”€β”€ ChatWindow.tsx +β”‚ β”‚ β”‚ β”œβ”€β”€ ChatMessage.tsx +β”‚ β”‚ β”‚ └── PersonaSelector.tsx +β”‚ └── services/ +β”‚ └── api.ts # UPDATE: Add chat endpoints +└── tests/ +``` + +**Structure Decision**: Standard Full-stack separation. Backend adds a dedicated `chat` service and route to isolate LLM logic from core data services. Frontend adds a self-contained `chat/` directory for UI components. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| N/A | | | \ No newline at end of file diff --git a/specs/003-ai-chat-window/quickstart.md b/specs/003-ai-chat-window/quickstart.md new file mode 100644 index 0000000..992ad3c --- /dev/null +++ b/specs/003-ai-chat-window/quickstart.md @@ -0,0 +1,32 @@ +# Quickstart: AI Chat Window + +## Prerequisites +1. **OpenRouter Key**: Get an API key from [openrouter.ai](https://openrouter.ai). +2. **Environment**: Set `OPENROUTER_API_KEY` in `backend/.env`. + +## Testing the Backend +1. **Start Server**: + ```bash + cd backend + source .venv/bin/activate + uvicorn src.api.main:app --reload + ``` +2. **Test Endpoint**: + ```bash + curl -X POST http://localhost:8000/api/chat \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello", "history": []}' + ``` + *Note: This will output raw SSE stream data.* + +## Testing the Frontend +1. **Start Client**: + ```bash + cd frontend + npm run dev + ``` +2. **Open UI**: Go to `http://localhost:5173`. +3. **Chat**: Click the "Chat" button in the sidebar. Select a persona and send a message. + +## Verification +1. **Check Logs**: After a chat, check `data/vaults/{user}/Chat Logs/` to see the saved Markdown file. diff --git a/specs/003-ai-chat-window/research.md b/specs/003-ai-chat-window/research.md new file mode 100644 index 0000000..993d32a --- /dev/null +++ b/specs/003-ai-chat-window/research.md @@ -0,0 +1,48 @@ +# Phase 0: Research & Design Decisions + +**Feature**: AI Chat Window (`003-ai-chat-window`) + +## 1. OpenRouter Integration +**Question**: What is the best way to integrate OpenRouter in Python? +**Finding**: OpenRouter is API-compatible with OpenAI. The standard `openai` Python client library is recommended, configured with `base_url="https://openrouter.ai/api/v1"` and the OpenRouter API key. +**Decision**: Use `openai` Python package. +**Rationale**: Industry standard, robust, async support. +**Alternatives**: `requests` (too manual), `langchain` (too heavy/complex for this specific need). + +## 2. Real-time Streaming +**Question**: How to stream LLM tokens from FastAPI to React? +**Finding**: Server-Sent Events (SSE) is the standard for unidirectional text streaming. FastAPI supports this via `StreamingResponse`. +**Decision**: Use `StreamingResponse` with a generator that yields SSE-formatted data (`data: ...\n\n`). +**Rationale**: Simpler than WebSockets, works well through proxies/firewalls, native support in modern browsers (`EventSource` or `fetch` with readable streams). + +## 3. Tool Execution Strategy +**Question**: How to invoke existing MCP tools (`list_notes`, `read_note`) from the chat endpoint? +**Finding**: The tools are defined as decorated functions in `backend/src/mcp/server.py`. We can import them directly. However, `FastMCP` wraps them. We might need to access the underlying function or just call the wrapper if it allows direct invocation. +**Decision**: Import the `mcp` object from `backend/src/mcp/server.py`. Use `mcp.list_tools()` to dynamically get tool definitions for the system prompt. Call the underlying functions directly if exposed, or use the `mcp.call_tool()` API if available. *Fallback*: Re-import the service functions (`vault_service.read_note`) directly if the MCP wrapper adds too much overhead/complexity for internal calls. +**Refinement**: The `server.py` defines tools using `@mcp.tool`. The most robust way is to import the `vault_service` and `indexer_service` instances directly from `server.py` (or a shared module) and wrap them in a simple "Agent Tool" registry for the LLM, mirroring the MCP definitions. This avoids "fake" network calls to localhost. + +## 4. Frontend UI Components +**Question**: What UI library to use for the chat interface? +**Finding**: Project uses Tailwind + generic React. +**Decision**: Build a custom `ChatWindow` component using Tailwind. Use a scrollable container for messages and a sticky footer for the input. +**Rationale**: Lightweight, full control over styling. + +## 5. Chat History Persistence +**Question**: How to store chat history? +**Finding**: Spec requires saving to Markdown files in the vault. +**Decision**: +1. **In-Memory/Database**: Use a simple `sqlite` table (or just in-memory if stateless) to hold the *active* conversation state for the UI. +2. **Persistence**: On "End Session" or auto-save (debounced), dump the conversation to `Chat Logs/{timestamp}-{title}.md`. +**Rationale**: Markdown is the source of truth. The database is just for the "hot" state to avoid parsing MD files on every new message. + +## 6. System Prompts & Personas +**Question**: How to manage prompts? +**Decision**: Store prompts in a simple dictionary or JSON file in `backend/src/services/prompts.py`. +**Structure**: +```python +PERSONAS = { + "default": "You are a helpful assistant...", + "auto-linker": "You are an expert editor. Your goal is to densely connect notes...", +} +``` + diff --git a/specs/003-ai-chat-window/spec.md b/specs/003-ai-chat-window/spec.md new file mode 100644 index 0000000..2482e0e --- /dev/null +++ b/specs/003-ai-chat-window/spec.md @@ -0,0 +1,85 @@ +# Feature Specification: AI Chat Window + +**Feature Branch**: `003-ai-chat-window` +**Created**: 2025-11-26 +**Status**: Draft +**Input**: User description: "Add an AI Chat Window using OpenRouter as the LLM provider. The system should reuse existing MCP tools (backend agent) to manage the vault. Include a 'Persona/Mode' selector to allow users to choose specialized system prompts for tasks like reindexing, cross-linking, and summarization. Chat history should be persisted to the vault." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - General Q&A with Vault Context (Priority: P1) + +As a user, I want to ask questions about my notes so that I can quickly find information or synthesize concepts without manually searching. + +**Why this priority**: This is the core value propositionβ€”enabling natural language interaction with the knowledge base. + +**Independent Test**: Can be tested by asking a question about a known note and verifying the answer cites the correct information. + +**Acceptance Scenarios**: + +1. **Given** the chat window is open, **When** I ask "What is the summary of project X?", **Then** the agent searches the vault and returns a summary based on the note content. +2. **Given** a specific note is open, **When** I ask "Summarize this", **Then** the agent reads the current note context and provides a summary. + +--- + +### User Story 2 - Vault Management via Personas (Priority: P2) + +As a power user, I want to select specialized "Personas" (e.g., Auto-Linker, Tag Gardener) so that I can perform complex maintenance tasks with optimized prompts. + +**Why this priority**: Distinguishes this from a simple "chatbot" by adding workflow automation capabilities. + +**Independent Test**: Select a persona, give a relevant command, and verify the specific tool (write/update) is called. + +**Acceptance Scenarios**: + +1. **Given** the "Auto-Linker" persona is selected, **When** I ask "Fix links in Note A", **Then** the agent identifies unlinked concepts and updates the note with `[[WikiLinks]]`. +2. **Given** the "Tag Gardener" persona is selected, **When** I ask "Clean up tags", **Then** the agent identifies synonymous tags and standardizes them across affected notes. + +--- + +### User Story 3 - Chat History Persistence (Priority: P3) + +As a user, I want my chat conversations to be saved in the vault so that I can reference past insights or continue working later. + +**Why this priority**: Ensures work isn't lost and integrates chat logs as first-class citizens in the vault. + +**Independent Test**: Refresh the browser and verify the previous conversation is still visible. + +**Acceptance Scenarios**: + +1. **Given** I have had a conversation, **When** I refresh the page, **Then** the chat history is restored. +2. **Given** a conversation is finished, **When** I look in the vault file explorer, **Then** I see a new Markdown file (e.g., in `Chat Logs/`) containing the transcript. + +--- + +### Edge Cases + +- **Network Failure**: What happens if OpenRouter is down? (System should show error and allow retry). +- **Large Context**: What happens if the vault search returns too much text? (Agent should truncate or summarize input). +- **Invalid Tool Use**: What happens if the agent tries to write a file with invalid characters? (System should catch error and ask agent to retry). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide a UI interface for Chat (floating or sidebar) that persists across navigation. +- **FR-002**: System MUST allow users to configure an OpenRouter API Key (via env vars or UI settings). +- **FR-003**: System MUST expose existing internal MCP tools (`read_note`, `write_note`, `search_notes`, etc.) to the LLM. +- **FR-004**: System MUST support selecting "Personas" that inject specific system prompts (Auto-Linker, Tag Gardener, etc.) into the context. +- **FR-005**: Chat sessions MUST be automatically saved to the vault as Markdown files (e.g., in a `Chat Logs` folder). +- **FR-006**: System MUST stream LLM responses to the UI for real-time feedback. +- **FR-007**: System MUST support creating new chat sessions and switching between past sessions. + +### Key Entities + +- **Chat Session**: Represents a conversation thread. Properties: ID, Title, Created Date, Messages (User/Assistant/Tool). +- **Persona**: A preset configuration of System Prompt + available Tools. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Agent responses start streaming within 3 seconds of user input. +- **SC-002**: 95% of "Auto-Linker" requests result in valid WikiLinks being added without syntax errors. +- **SC-003**: Users can switch between active chat and past history (refresh/reload) with zero data loss. +- **SC-004**: System can handle a context window of at least 16k tokens (supporting moderate-sized note analysis). \ No newline at end of file diff --git a/specs/003-ai-chat-window/tasks.md b/specs/003-ai-chat-window/tasks.md new file mode 100644 index 0000000..5daef6b --- /dev/null +++ b/specs/003-ai-chat-window/tasks.md @@ -0,0 +1,74 @@ +# Tasks: AI Chat Window + +**Feature Branch**: `003-ai-chat-window` +**Spec**: [specs/003-ai-chat-window/spec.md](spec.md) +**Plan**: [specs/003-ai-chat-window/plan.md](plan.md) + +## Phase 1: Setup +*Goal: Initialize project structure and install dependencies.* + +- [ ] T001 Create contracts directory and API spec at specs/003-ai-chat-window/contracts/chat-api.yaml +- [ ] T002 [P] Create directory backend/src/services/chat +- [ ] T003 [P] Create directory frontend/src/components/chat +- [ ] T004 Add openai dependency to backend/requirements.txt + +## Phase 2: Foundational +*Goal: Core backend logic for Chat (Service Layer).* + +- [ ] T005 [US1] Define ChatMessage and ChatRequest models in backend/src/models/chat.py +- [ ] T006 [US1] Define Persona and Prompt models in backend/src/models/chat.py +- [ ] T007 [US2] Implement prompt storage (dictionary of personas) in backend/src/services/prompts.py +- [ ] T008 [US1] Create ChatService class skeleton in backend/src/services/chat.py + +## Phase 3: User Story 1 - General Q&A with Vault Context +*Goal: Enable basic chat interactions with streaming and tool use.* +*Test Criteria: Can ask a question and get a streaming response citing vault notes.* + +- [ ] T009 [US1] Implement OpenAI client initialization in backend/src/services/chat.py +- [ ] T010 [US1] Implement tool registry (wrap VaultService/IndexerService) in backend/src/services/chat.py +- [ ] T011 [US1] Implement stream_chat method with SSE generator in backend/src/services/chat.py +- [ ] T012 [US1] Create unit tests for ChatService in backend/tests/unit/test_chat_service.py +- [ ] T013 [US1] Implement POST /api/chat endpoint in backend/src/api/routes/chat.py +- [ ] T014 [US1] Register chat router in backend/src/api/main.py +- [ ] T015 [P] [US1] Create ChatMessage component in frontend/src/components/chat/ChatMessage.tsx +- [ ] T016 [US1] Create ChatWindow component skeleton in frontend/src/components/chat/ChatWindow.tsx +- [ ] T017 [US1] Implement streaming fetch logic in frontend/src/services/api.ts +- [ ] T018 [US1] Connect ChatWindow to API and handle SSE stream in frontend/src/components/chat/ChatWindow.tsx + +## Phase 4: User Story 2 - Vault Management via Personas +*Goal: Allow users to select specialized agents for maintenance tasks.* +*Test Criteria: Selecting "Auto-Linker" injects the correct system prompt and tools.* + +- [ ] T019 [US2] Add GET /api/chat/personas endpoint to backend/src/api/routes/chat.py +- [ ] T020 [P] [US2] Create PersonaSelector component in frontend/src/components/chat/PersonaSelector.tsx +- [ ] T021 [US2] Add persona selection state to frontend/src/components/chat/ChatWindow.tsx +- [ ] T022 [US2] Update ChatService to accept and apply persona ID in backend/src/services/chat.py + +## Phase 5: User Story 3 - Chat History Persistence +*Goal: Save conversation logs to the vault.* +*Test Criteria: Chat logs appear as Markdown files in "Chat Logs/" folder.* + +- [ ] T023 [US3] Implement save_chat_log method in backend/src/services/chat.py (Markdown formatting) +- [ ] T024 [US3] Update POST /api/chat to auto-save on completion (or session end) in backend/src/api/routes/chat.py +- [ ] T025 [US3] Add logic to restore history from ChatRequest.history in backend/src/services/chat.py +- [ ] T026 [US3] Add "Clear History" or "New Chat" button in frontend/src/components/chat/ChatWindow.tsx + +## Phase 6: Polish & Cross-Cutting Concerns +*Goal: Final UI touches and error handling.* + +- [ ] T027 [P] Style ChatWindow with Tailwind (responsive sidebar/floating) in frontend/src/components/chat/ChatWindow.tsx +- [ ] T028 Implement error handling for OpenRouter failures in backend/src/services/chat.py +- [ ] T029 Add tool execution status messages to UI stream in frontend/src/components/chat/ChatMessage.tsx + +## Dependencies + +- **US1** depends on Setup & Foundational tasks. +- **US2** extends US1 (can be parallelized after US1 backend is stable). +- **US3** extends US1 backend logic. + +## Implementation Strategy + +1. **MVP (US1)**: Get the chat bubble working with a hardcoded "Hello World" stream, then hook up OpenRouter. +2. **Tools**: Enable `read_note` and `search_notes` so the agent isn't blind. +3. **Personas (US2)**: Add the dropdown and the specialized prompts. +4. **Persistence (US3)**: Add the file writing logic last. diff --git a/start-project-windows.bat b/start-project-windows.bat new file mode 100644 index 0000000..24a6c7d --- /dev/null +++ b/start-project-windows.bat @@ -0,0 +1,33 @@ +@echo off +REM Document-MCP Start Script for Windows +REM This script opens two terminal windows - one for backend and one for frontend + +echo Starting Document-MCP... +echo. + +REM Get the project root directory +set PROJECT_ROOT=%~dp0 + +REM Start Backend in a new terminal window +echo Starting Backend (FastAPI on port 8000)... +start "Document-MCP Backend" cmd /k "cd /d "%PROJECT_ROOT%backend" && .venv\Scripts\activate && uv run uvicorn main:app --reload --host 0.0.0.0 --port 8000" + +REM Wait a moment before starting frontend +timeout /t 3 /nobreak + +REM Start Frontend in a new terminal window +echo Starting Frontend (Vite on port 5173)... +start "Document-MCP Frontend" cmd /k "cd /d "%PROJECT_ROOT%frontend" && npm run dev" + +echo. +echo ============================================ +echo Document-MCP is starting! +echo ============================================ +echo. +echo Backend: http://localhost:8000 +echo Frontend: http://localhost:5173 +echo. +echo Both services should open in separate terminal windows. +echo Press Ctrl+C in each window to stop the services. +echo. +pause