diff --git a/navi/content_store.py b/navi/content_store.py index bc30ca4..9f0c2aa 100644 --- a/navi/content_store.py +++ b/navi/content_store.py @@ -1,11 +1,11 @@ -"""Content store — manages published content files for inline viewing. +"""Content store — tracks published files for inline viewing. -Files are stored in navi/content// and served via /content//filename. -Metadata is tracked in the database for lifecycle management. +Files live in the session directory (uploads/sessions//). +Publishing only registers metadata in the DB; the file itself is NOT copied. +The URL points to the existing /sessions/{id}/files/{filename} endpoint. """ import asyncio -import shutil import uuid from datetime import datetime, timedelta, timezone from pathlib import Path @@ -14,16 +14,13 @@ import structlog from navi.config import settings +from navi.session_files import ensure_session_dir, session_dir if TYPE_CHECKING: import asyncpg log = structlog.get_logger() -_CONTENT_DIR = Path(__file__).parent / "content" -_CONTENT_DIR.mkdir(exist_ok=True) - -# Auto-detect content type from file extension _EXT_TO_TYPE = { ".stl": "stl", ".html": "html", @@ -74,92 +71,87 @@ await conn.execute( "CREATE INDEX IF NOT EXISTS idx_session_content_session ON session_content (session_id)" ) + await conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_session_content_file " + "ON session_content (session_id, filename)" + ) async def publish( session_id: str, - src_path: Path, - filename: str | None = None, + filename: str, title: str | None = None, content_type: str | None = None, ) -> dict: - """Copy a file into the content store and record metadata. + """Register a file from the session directory for inline viewing. - Returns {"id": "uuid", "url": "...", "filename": "...", "content_type": "..."} + The file must already exist in uploads/sessions/{session_id}/. + Returns {"id": "...", "url": "...", "filename": "...", "content_type": "..."} """ - src = Path(src_path).expanduser().resolve() + src = ensure_session_dir(session_id) / filename if not src.exists(): - raise FileNotFoundError(f"Source file not found: {src}") + raise FileNotFoundError( + f"File '{filename}' not found in session directory: {session_dir(session_id)}" + ) + if not src.is_file(): + raise IsADirectoryError(f"Path is a directory, not a file: {src}") - content_id = str(uuid.uuid4())[:8] - dest_dir = _CONTENT_DIR / content_id - dest_dir.mkdir(parents=True, exist_ok=True) + detected_type = content_type or _detect_content_type(filename) + safe_filename = filename.replace("/", "_").replace("\\", "_") + content_id = f"{session_id}_{safe_filename}" - dest_name = filename or src.name - dest = dest_dir / dest_name - - # Avoid clobbering - if dest.exists(): - stem = Path(dest_name).stem - suffix = Path(dest_name).suffix - i = 1 - while dest.exists(): - dest = dest_dir / f"{stem}_{i}{suffix}" - i += 1 - - await asyncio.to_thread(shutil.copy2, str(src), str(dest)) - - detected_type = content_type or _detect_content_type(dest.name) - - # Record in DB try: pool = await _get_db_pool() async with pool.acquire() as conn: await conn.execute( """INSERT INTO session_content (id, session_id, filename, content_type, title, created_at) - VALUES ($1, $2, $3, $4, $5, $6)""", + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (session_id, filename) DO UPDATE + SET content_type = EXCLUDED.content_type, + title = EXCLUDED.title, + created_at = EXCLUDED.created_at""", content_id, session_id, - dest.name, + filename, detected_type, - title or dest.name, + title or filename, datetime.now(timezone.utc), ) except Exception: - log.warning("content_store.db_insert_failed", content_id=content_id, exc_info=True) - # Clean up orphaned file so we don't leak untracked content on disk - try: - if dest.exists(): - dest.unlink() - if dest_dir.exists(): - dest_dir.rmdir() - except Exception: - log.warning("content_store.cleanup_failed", content_id=content_id, exc_info=True) + log.warning("content_store.db_upsert_failed", content_id=content_id, exc_info=True) raise base_url = settings.public_url.rstrip("/") - url = f"{base_url}/content/{content_id}/{dest.name}" + url = f"{base_url}/sessions/{session_id}/files/{filename}" - log.info("content_store.published", content_id=content_id, type=detected_type, url=url) + log.info( + "content_store.published", + content_id=content_id, + session_id=session_id, + filename=filename, + type=detected_type, + url=url, + ) return { "id": content_id, "url": url, - "filename": dest.name, + "filename": filename, "content_type": detected_type, - "title": title or dest.name, + "title": title or filename, } async def list_for_session(session_id: str) -> list[dict]: - """Return all content items for a session.""" + """Return all published content items for a session.""" try: pool = await _get_db_pool() except Exception: return [] async with pool.acquire() as conn: rows = await conn.fetch( - "SELECT id, filename, content_type, title, created_at FROM session_content WHERE session_id=$1 ORDER BY created_at DESC", + "SELECT id, filename, content_type, title, created_at " + "FROM session_content WHERE session_id=$1 ORDER BY created_at DESC", session_id, ) return [ @@ -175,14 +167,7 @@ async def delete_content(content_id: str) -> bool: - """Remove a content item from disk and DB.""" - dest_dir = _CONTENT_DIR / content_id - try: - if dest_dir.exists(): - await asyncio.to_thread(shutil.rmtree, str(dest_dir)) - except Exception: - log.warning("content_store.rmtree_failed", content_id=content_id, exc_info=True) - + """Remove a content item from DB (file on disk is NOT deleted — it lives in session_dir).""" try: pool = await _get_db_pool() async with pool.acquire() as conn: @@ -194,18 +179,16 @@ async def cleanup_old(days: int = 30) -> int: - """Delete content older than N days. Returns count deleted.""" + """Delete DB records older than N days (files on disk are managed by session lifecycle).""" cutoff = datetime.now(timezone.utc) - timedelta(days=days) try: pool = await _get_db_pool() except Exception: return 0 async with pool.acquire() as conn: - rows = await conn.fetch( - "SELECT id FROM session_content WHERE created_at < $1", cutoff + result = await conn.execute( + "DELETE FROM session_content WHERE created_at < $1", cutoff ) - deleted = 0 - for r in rows: - if await delete_content(r["id"]): - deleted += 1 + deleted = int(result.split()[1]) + log.info("content_store.cleanup", deleted=deleted, cutoff=cutoff) return deleted diff --git a/navi/tools/content_publish.py b/navi/tools/content_publish.py index 955455a..927e3c5 100644 --- a/navi/tools/content_publish.py +++ b/navi/tools/content_publish.py @@ -1,12 +1,15 @@ """content_publish tool — publish a file for inline viewing in the chat client. -Copies the file into the content store (navi/content//) and returns a URL -served via /content//filename. The client renders it as an interactive card. +The file must already exist in the session's file directory. +Publishing only registers metadata; the file itself is NOT copied. +The user sees the file via the existing /sessions/{id}/files/{name} endpoint, +so edits made by the agent are immediately visible. """ from pathlib import Path from navi.content_store import publish +from navi.session_files import ensure_session_dir, session_dir from .base import Tool, ToolResult, current_session_id @@ -16,27 +19,27 @@ description = ( "Publish a file for inline viewing in the chat client. " "Use this when you generate or produce content the user will want to see interactively " - "(3D models, HTML pages, SVG graphics, etc.). " - "The file will be copied to a public URL and displayed as an embeddable card. " - "IMPORTANT — path must be an ABSOLUTE path (e.g. /home/user/model.stl)." + "(3D models, HTML pages, SVG graphics, images, videos, PDFs, etc.).\n\n" + "IMPORTANT — the file MUST already be inside the session directory. " + "Before publishing, write or move the file into the session folder. " + "To find the session directory path, use `filesystem info ` or write directly to it. " + "If a file with the same name already exists in the session directory, choose a different name " + "or check the directory contents first with `filesystem list `." ) parameters = { "type": "object", "properties": { - "path": { - "type": "string", - "description": "Absolute path to the file to publish", - }, "filename": { "type": "string", "description": ( - "Published filename shown to the user. " - "Defaults to the original filename." + "Name of the file to publish. " + "The file must already exist in the session directory. " + "Example: 'chart.svg' or 'report.html'." ), }, "title": { "type": "string", - "description": "Human-readable title for the content card", + "description": "Human-readable title shown on the content card", }, "content_type": { "type": "string", @@ -44,28 +47,50 @@ "description": "Content type for viewer selection. Auto-detected from extension if omitted.", }, }, - "required": ["path"], + "required": ["filename"], } async def execute(self, params: dict) -> ToolResult: session_id = current_session_id.get() if not session_id: - return ToolResult(success=False, output="No active session context.", error="no_session") + return ToolResult( + success=False, + output="No active session context.", + error="no_session", + ) - src = Path(params["path"]).expanduser().resolve() + filename = Path(params["filename"]).name # strip any path components + sess_dir = session_dir(session_id) + src = sess_dir / filename + if not src.exists(): - return ToolResult(success=False, output=f"File not found: {src}", error="not_found") + return ToolResult( + success=False, + output=( + f"File '{filename}' not found in the session directory: {sess_dir}\n" + f"Make sure the file was written or moved there before publishing. " + f"You can check the directory contents with `filesystem list {sess_dir}`." + ), + error="not_found", + ) if not src.is_file(): - return ToolResult(success=False, output=f"Path is not a file: {src}", error="not_a_file") + return ToolResult( + success=False, + output=f"Path is not a file: {src}", + error="not_a_file", + ) try: info = await publish( session_id=session_id, - src_path=src, - filename=params.get("filename"), + filename=filename, title=params.get("title"), content_type=params.get("content_type"), ) + except FileNotFoundError as e: + return ToolResult(success=False, output=str(e), error="not_found") + except IsADirectoryError as e: + return ToolResult(success=False, output=str(e), error="is_directory") except Exception as e: return ToolResult(success=False, output=f"Publish failed: {e}", error="publish_failed") @@ -74,7 +99,8 @@ output=( f"Published: {info['title']} ({info['content_type']})\n" f"URL: {info['url']}\n" - f"ID: {info['id']}" + f"ID: {info['id']}\n" + f"If you need to edit this file later, edit it at: {src}" ), metadata=info, )