From bde4e4caa9fe02a3351b30aa1214f09071726058 Mon Sep 17 00:00:00 2001 From: Clawd Date: Thu, 5 Mar 2026 21:12:37 +0000 Subject: [PATCH] Add capture page: quick logs + document upload, NAS vault storage --- docker-compose.full.yml | 4 +- services/rag-api/main.py | 3 +- services/rag-api/routers/capture.py | 224 ++++++++++++++ services/web-ui/app/capture/page.tsx | 281 ++++++++++++++++++ services/web-ui/components/layout/Sidebar.tsx | 3 +- 5 files changed, 511 insertions(+), 4 deletions(-) create mode 100644 services/rag-api/routers/capture.py create mode 100644 services/web-ui/app/capture/page.tsx diff --git a/docker-compose.full.yml b/docker-compose.full.yml index c5a5477..523de19 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -74,7 +74,7 @@ services: CHUNK_SIZE: ${CHUNK_SIZE:-700} CHUNK_OVERLAP: ${CHUNK_OVERLAP:-70} volumes: - - /opt/second-brain-vault:/vault:ro + - /mnt/2brain:/vault depends_on: postgres: condition: service_healthy @@ -92,7 +92,7 @@ services: VAULT_PATH: /vault CHAT_MODEL: ${CHAT_MODEL:-qwen3.5:2b} volumes: - - /opt/second-brain-vault:/vault:ro + - /mnt/2brain:/vault depends_on: postgres: condition: service_healthy diff --git a/services/rag-api/main.py b/services/rag-api/main.py index b29ea56..3d0a5df 100644 --- a/services/rag-api/main.py +++ b/services/rag-api/main.py @@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware from core.database import create_pool, close_pool from core.settings import Settings -from routers import search, chat, documents, index, meta +from routers import search, chat, documents, index, meta, capture # Global settings instance (imported by routers via dependency) app_settings = Settings() @@ -57,3 +57,4 @@ app.include_router(chat.router, prefix='/api/v1') app.include_router(documents.router, prefix='/api/v1') app.include_router(index.router, prefix='/api/v1') app.include_router(meta.router, prefix='/api/v1') +app.include_router(capture.router, prefix='/api/v1') diff --git a/services/rag-api/routers/capture.py b/services/rag-api/routers/capture.py new file mode 100644 index 0000000..c8dbb68 --- /dev/null +++ b/services/rag-api/routers/capture.py @@ -0,0 +1,224 @@ +""" +capture.py — Quick log entries and file uploads to the vault. +""" + +from __future__ import annotations + +import os +import re +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from pydantic import BaseModel + +router = APIRouter(tags=['capture']) +logger = logging.getLogger(__name__) + +# Vault path from environment +VAULT_PATH = Path(os.getenv('VAULT_PATH', '/vault')) + + +class LogEntry(BaseModel): + """Quick log/note entry.""" + content: str + tags: Optional[list[str]] = None + title: Optional[str] = None + + +class LogResponse(BaseModel): + """Response after creating a log entry.""" + success: bool + file_path: str + timestamp: str + message: str + + +class UploadResponse(BaseModel): + """Response after file upload.""" + success: bool + file_path: str + filename: str + size_bytes: int + message: str + + +def sanitize_filename(name: str) -> str: + """Remove unsafe characters from filename.""" + # Keep alphanumeric, dots, dashes, underscores + safe = re.sub(r'[^\w\-.]', '_', name) + return safe[:200] # Limit length + + +@router.post('/capture/log', response_model=LogResponse) +async def create_log_entry(entry: LogEntry): + """ + Create a quick log entry. Entries are appended to daily log files + in the logs/ directory with automatic timestamps. + """ + now = datetime.now(timezone.utc) + date_str = now.strftime('%Y-%m-%d') + time_str = now.strftime('%H:%M:%S') + + logs_dir = VAULT_PATH / 'logs' + logs_dir.mkdir(parents=True, exist_ok=True) + + log_file = logs_dir / f'{date_str}.md' + + # Format the entry + lines = [] + + # If file doesn't exist, add header + if not log_file.exists(): + lines.append(f'# Log — {date_str}\n\n') + + # Entry header with timestamp + lines.append(f'## {time_str} UTC') + if entry.title: + lines.append(f' — {entry.title}') + lines.append('\n\n') + + # Tags + if entry.tags: + tag_str = ' '.join(f'#{tag}' for tag in entry.tags) + lines.append(f'{tag_str}\n\n') + + # Content + lines.append(entry.content.strip()) + lines.append('\n\n---\n\n') + + content = ''.join(lines) + + try: + with open(log_file, 'a', encoding='utf-8') as f: + f.write(content) + + logger.info('Log entry created: %s', log_file) + + return LogResponse( + success=True, + file_path=str(log_file.relative_to(VAULT_PATH)), + timestamp=now.isoformat(), + message=f'Log entry added to {date_str}.md' + ) + except Exception as e: + logger.error('Failed to write log: %s', e) + raise HTTPException(status_code=500, detail=f'Failed to write log: {e}') + + +@router.post('/capture/upload', response_model=UploadResponse) +async def upload_document( + file: UploadFile = File(...), + folder: str = Form(default='uploads'), + tags: Optional[str] = Form(default=None), +): + """ + Upload a document to the vault. Supports markdown, text, and PDF files. + + - folder: Subfolder in vault (default: uploads) + - tags: Comma-separated tags to add as frontmatter (for .md files) + """ + if not file.filename: + raise HTTPException(status_code=400, detail='No filename provided') + + # Sanitize inputs + safe_folder = sanitize_filename(folder) + safe_filename = sanitize_filename(file.filename) + + # Allowed extensions + allowed_ext = {'.md', '.txt', '.pdf', '.json', '.yaml', '.yml', '.csv'} + ext = Path(safe_filename).suffix.lower() + + if ext not in allowed_ext: + raise HTTPException( + status_code=400, + detail=f'File type {ext} not allowed. Allowed: {", ".join(allowed_ext)}' + ) + + # Target directory + target_dir = VAULT_PATH / safe_folder + target_dir.mkdir(parents=True, exist_ok=True) + + target_path = target_dir / safe_filename + + # Handle duplicates by adding timestamp + if target_path.exists(): + stem = Path(safe_filename).stem + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + safe_filename = f'{stem}_{timestamp}{ext}' + target_path = target_dir / safe_filename + + try: + content = await file.read() + + # If markdown and tags provided, prepend frontmatter + if ext == '.md' and tags: + tag_list = [t.strip() for t in tags.split(',') if t.strip()] + if tag_list: + frontmatter = '---\n' + frontmatter += f'tags: [{", ".join(tag_list)}]\n' + frontmatter += f'uploaded: {datetime.now(timezone.utc).isoformat()}\n' + frontmatter += '---\n\n' + content = frontmatter.encode('utf-8') + content + + with open(target_path, 'wb') as f: + f.write(content) + + logger.info('File uploaded: %s (%d bytes)', target_path, len(content)) + + return UploadResponse( + success=True, + file_path=str(target_path.relative_to(VAULT_PATH)), + filename=safe_filename, + size_bytes=len(content), + message=f'File uploaded to {safe_folder}/' + ) + except Exception as e: + logger.error('Upload failed: %s', e) + raise HTTPException(status_code=500, detail=f'Upload failed: {e}') + + +@router.get('/capture/logs') +async def list_logs(limit: int = 10): + """List recent log files.""" + logs_dir = VAULT_PATH / 'logs' + + if not logs_dir.exists(): + return {'logs': [], 'total': 0} + + log_files = sorted(logs_dir.glob('*.md'), reverse=True)[:limit] + + logs = [] + for lf in log_files: + stat = lf.stat() + logs.append({ + 'date': lf.stem, + 'file_path': str(lf.relative_to(VAULT_PATH)), + 'size_bytes': stat.st_size, + 'modified': datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() + }) + + return {'logs': logs, 'total': len(logs)} + + +@router.get('/capture/log/{date}') +async def get_log(date: str): + """Get contents of a specific log file by date (YYYY-MM-DD).""" + # Validate date format + if not re.match(r'^\d{4}-\d{2}-\d{2}$', date): + raise HTTPException(status_code=400, detail='Invalid date format. Use YYYY-MM-DD') + + log_file = VAULT_PATH / 'logs' / f'{date}.md' + + if not log_file.exists(): + raise HTTPException(status_code=404, detail=f'No log found for {date}') + + content = log_file.read_text(encoding='utf-8') + + return { + 'date': date, + 'file_path': str(log_file.relative_to(VAULT_PATH)), + 'content': content + } diff --git a/services/web-ui/app/capture/page.tsx b/services/web-ui/app/capture/page.tsx new file mode 100644 index 0000000..4816acb --- /dev/null +++ b/services/web-ui/app/capture/page.tsx @@ -0,0 +1,281 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { Send, Upload, Check, AlertCircle, FileText, Plus } from 'lucide-react'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + +interface LogResponse { + success: boolean; + file_path: string; + timestamp: string; + message: string; +} + +interface UploadResponse { + success: boolean; + file_path: string; + filename: string; + size_bytes: number; + message: string; +} + +export default function CapturePage() { + // Log state + const [logContent, setLogContent] = useState(''); + const [logTitle, setLogTitle] = useState(''); + const [logTags, setLogTags] = useState(''); + const [logStatus, setLogStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle'); + const [logMessage, setLogMessage] = useState(''); + + // Upload state + const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle'); + const [uploadMessage, setUploadMessage] = useState(''); + const [uploadFolder, setUploadFolder] = useState('documents'); + const [uploadTags, setUploadTags] = useState(''); + const fileInputRef = useRef(null); + + const handleLogSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!logContent.trim()) return; + + setLogStatus('sending'); + setLogMessage(''); + + try { + const tags = logTags.split(',').map(t => t.trim()).filter(Boolean); + + const res = await fetch(`${API_BASE}/api/v1/capture/log`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: logContent, + title: logTitle || undefined, + tags: tags.length > 0 ? tags : undefined, + }), + }); + + if (!res.ok) throw new Error(`Failed: ${res.status}`); + + const data: LogResponse = await res.json(); + setLogStatus('success'); + setLogMessage(data.message); + setLogContent(''); + setLogTitle(''); + setLogTags(''); + + // Reset status after 3s + setTimeout(() => setLogStatus('idle'), 3000); + } catch (err) { + setLogStatus('error'); + setLogMessage(err instanceof Error ? err.message : 'Failed to save log'); + } + }; + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploadStatus('uploading'); + setUploadMessage(''); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('folder', uploadFolder); + if (uploadTags) { + formData.append('tags', uploadTags); + } + + const res = await fetch(`${API_BASE}/api/v1/capture/upload`, { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.detail || `Upload failed: ${res.status}`); + } + + const data: UploadResponse = await res.json(); + setUploadStatus('success'); + setUploadMessage(`${data.filename} uploaded (${(data.size_bytes / 1024).toFixed(1)} KB)`); + + // Reset + if (fileInputRef.current) fileInputRef.current.value = ''; + setTimeout(() => setUploadStatus('idle'), 3000); + } catch (err) { + setUploadStatus('error'); + setUploadMessage(err instanceof Error ? err.message : 'Upload failed'); + } + }; + + return ( +
+

Quick Capture

+

+ Add notes to your Second Brain or upload documents for indexing. +

+ + {/* Quick Log Section */} +
+
+ +

Quick Log

+
+ +
+
+ setLogTitle(e.target.value)} + className="flex-1 bg-slate-700 border border-slate-600 rounded-lg px-4 py-2 text-white placeholder-slate-400 focus:outline-none focus:border-brain-500" + /> + setLogTags(e.target.value)} + className="w-64 bg-slate-700 border border-slate-600 rounded-lg px-4 py-2 text-white placeholder-slate-400 focus:outline-none focus:border-brain-500" + /> +
+ +