diff --git a/.gitignore b/.gitignore index 8af9e0a..c6fe5cf 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ Thumbs.db # MCP/Claude .claude/ +.nimblebrain/ # Package managers uv.lock diff --git a/CLAUDE.md b/CLAUDE.md index fc33e49..34e4646 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,17 @@ Fat MCP server for document generation. Documents are the primary entity. The agent writes Typst; the server compiles it. +## Breaking changes + +- **v0.4+ `export_pdf` no longer accepts `include_data: bool`.** Bytes always + move out-of-band via the `collateral://exports/{id}.pdf` resource template — + inlining base64 PDFs in tool results blew through the host's 1 MB cap. + Direct MCP callers passing `include_data` will now fail input validation; + LLM-facing behavior is unchanged. +- **v0.4+ rendering unified on PDF output.** `preview`, `preview_template`, + `export_pdf`, and `compile_typst` all return a single `resource_link` block + pointing at a PDF. The previous per-page PNG output path is gone. + ## Commands ```bash diff --git a/README.md b/README.md index 17331b7..3d9e211 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,26 @@ Typst-powered document generation with brand-aware templates, live preview, and ## Tools -- **Workspace**: `get_workspace`, `reset_workspace` -- **Templates**: `list_templates`, `get_template_schema`, `set_template` -- **Brand**: `get_brand`, `set_brand`, `update_brand_colors`, `set_logo`, `list_brand_presets`, `load_brand_preset` -- **Content**: `set_content`, `update_section`, `get_content`, `list_sections` -- **Rendering**: `preview`, `preview_page`, `export_pdf`, `compile_typst` +- **Theme**: `get_theme`, `set_theme` +- **Templates**: `list_templates`, `get_template`, `create_template`, `duplicate_template`, `delete_template` +- **Documents**: `create_document`, `list_documents`, `open_document`, `save_document`, `save_as_template`, `delete_document` +- **Workspace & Editing**: `get_workspace`, `get_source`, `patch_source`, `set_source`, `import_content` +- **Assets**: `upload_asset`, `list_assets`, `delete_asset` +- **Voice & Components**: `get_voice`, `set_voice`, `get_components`, `set_components` +- **Fonts**: `list_fonts`, `install_font` +- **Rendering**: `preview`, `preview_template`, `export_pdf`, `compile_typst` + +Rendering tools return MCP `resource_link` content blocks pointing at the export resource template — not inline bytes. Clients fetch PDFs via `resources/read`. + +## Resources + +- `ui://collateral/main` — Studio UI rendered in the platform sidebar +- `ui://collateral/settings` — brand / voice / assets configuration panel +- `ui://collateral/preview.pdf` — current document compiled to PDF +- `collateral://exports/{export_id}.{ext}` — rendered export bytes, MIME per extension +- `collateral://assets/{filename}` — uploaded asset bytes +- `skill://collateral/usage` — agent-facing usage guide +- `skill://collateral/reference` — tool catalog, error recovery, anti-patterns ## Built-in Templates diff --git a/src/mcp_collateral/compiler.py b/src/mcp_collateral/compiler.py index a669649..994e289 100644 --- a/src/mcp_collateral/compiler.py +++ b/src/mcp_collateral/compiler.py @@ -1,7 +1,7 @@ """Typst compilation pipeline. -Compiles Typst source to PDF or PNG. Knows nothing about templates, -starters, or workspaces -- just takes source and produces bytes. +Compiles Typst source to PDF. Knows nothing about templates, starters, +or workspaces -- just takes source and produces bytes. The compiler uses --root pointing to BASE_DIR so that absolute Typst paths like /assets/ resolve correctly. If components.typ exists in @@ -43,53 +43,38 @@ def _clean_compile_dir() -> None: def compile_source( source: str, logo_data: dict[str, bytes] | None = None, - output_format: str = "pdf", page: int | None = None, -) -> list[bytes]: - """Compile Typst source to PDF or PNG pages. +) -> bytes: + """Compile Typst source to a PDF. - Returns a list of bytes objects (one for PDF, one per page for PNG). - - Writes source to COMPILE_DIR (~/.collateral/_compile/), copies - components.typ if it exists, compiles with --root pointing to - BASE_DIR, and cleans up after. + When ``page`` is provided, the output is a single-page PDF containing + only that page (1-based). Otherwise the full document is rendered. """ typst_bin = _find_typst() try: _clean_compile_dir() - # Copy components.typ into compile dir if it exists components_path = BASE_DIR / "components.typ" if components_path.exists(): shutil.copy2(components_path, COMPILE_DIR / "components.typ") - # Write brand assets to _compile/brand/ brand_dir = COMPILE_DIR / "brand" brand_dir.mkdir(parents=True, exist_ok=True) if logo_data: for filename, data in logo_data.items(): (brand_dir / filename).write_bytes(data) - # Write source (COMPILE_DIR / "document.typ").write_text(source) - if output_format == "pdf": - out_path = COMPILE_DIR / "output.pdf" - _run_typst(typst_bin, "_compile/document.typ", str(out_path)) - return [out_path.read_bytes()] - else: - out_pattern = str(COMPILE_DIR / "output-{n}.png") - pages_arg = str(page) if page is not None else None - _run_typst( - typst_bin, - "_compile/document.typ", - out_pattern, - fmt="png", - pages=pages_arg, - ) - png_files = sorted(COMPILE_DIR.glob("output-*.png")) - return [p.read_bytes() for p in png_files] + out_path = COMPILE_DIR / "output.pdf" + _run_typst( + typst_bin, + "_compile/document.typ", + str(out_path), + pages=str(page) if page is not None else None, + ) + return out_path.read_bytes() finally: _clean_compile_dir() @@ -98,18 +83,8 @@ def _run_typst( typst_bin: str, input_file: str, output: str, - fmt: str = "pdf", pages: str | None = None, ) -> None: - """Run the typst compiler. - - Args: - typst_bin: Path to the typst binary. - input_file: Input file path relative to BASE_DIR (e.g. "_compile/document.typ"). - output: Absolute path for the output file. - fmt: Output format ("pdf" or "png"). - pages: Optional page range for PNG output. - """ cmd = [ typst_bin, "compile", @@ -118,8 +93,6 @@ def _run_typst( "--font-path", str(FONTS_DIR), ] - if fmt == "png": - cmd.extend(["--format", "png"]) if pages: cmd.extend(["--pages", pages]) cmd.extend([str(BASE_DIR / input_file), output]) diff --git a/src/mcp_collateral/models.py b/src/mcp_collateral/models.py index cd0ba30..571a7a6 100644 --- a/src/mcp_collateral/models.py +++ b/src/mcp_collateral/models.py @@ -73,25 +73,3 @@ class WorkspaceState(BaseModel): document_name: str | None = None template_id: str | None = None theme: ThemeData = Field(default_factory=ThemeData) - - -# --- Rendering --- - - -class PagePreview(BaseModel): - page_number: int - image_base64: str | None = None - - -class PreviewResult(BaseModel): - pages: list[PagePreview] = Field(default_factory=list) - page_count: int = 0 - message: str = "" - - -class ExportResult(BaseModel): - pdf_base64: str | None = None - filename: str = "document.pdf" - page_count: int = 0 - size_bytes: int = 0 - message: str = "" diff --git a/src/mcp_collateral/server.py b/src/mcp_collateral/server.py index ee1f7ed..95148ca 100644 --- a/src/mcp_collateral/server.py +++ b/src/mcp_collateral/server.py @@ -14,17 +14,38 @@ from typing import Any from fastmcp import FastMCP -from mcp.types import Annotations, ImageContent, TextContent +from fastmcp.resources import ResourceContent, ResourceResult +from fastmcp.tools import ToolResult +from mcp.types import Annotations, ResourceLink, TextContent +from pydantic import AnyUrl +from . import store from . import templates as template_mod from .models import ( DocumentInfo, - ExportResult, - PreviewResult, TemplateInfo, WorkspaceState, ) -from .workspace import Workspace +from .workspace import Workspace, load_export, store_export + +_EXT_MIME: dict[str, str] = { + "pdf": "application/pdf", + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", + "svg": "image/svg+xml", + "txt": "text/plain", + "md": "text/markdown", + "json": "application/json", +} + + +def _mime_for(filename: str) -> str: + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + return _EXT_MIME.get(ext, "application/octet-stream") + _USER_ONLY = Annotations(audience=["user"]) @@ -113,6 +134,35 @@ def collateral_preview() -> bytes: return pdf_path.read_bytes() +@mcp.resource("collateral://exports/{export_id}.{ext}") +def collateral_export(export_id: str, ext: str) -> ResourceResult: + """Rendered export (PDF or PNG) addressable by id. MIME is set per extension.""" + data = load_export(export_id, ext) or b"" + mime_type = _EXT_MIME.get(ext.lower(), "application/octet-stream") + return ResourceResult([ResourceContent(data, mime_type=mime_type)]) + + +@mcp.resource("collateral://assets/{filename}") +def collateral_asset(filename: str) -> ResourceResult: + """Uploaded asset bytes, addressable by filename under ~/.collateral/assets/. + + Uploads pass through _validate_filename in store.py, but this handler is + a separate trust boundary (resources can be read by anyone with the URI, + not just whoever uploaded) so it re-enforces containment by resolving + the path and rejecting anything outside ASSETS_DIR. + """ + empty = ResourceResult([ResourceContent(b"", mime_type="application/octet-stream")]) + assets_root = store.ASSETS_DIR.resolve() + try: + candidate = (store.ASSETS_DIR / filename).resolve() + candidate.relative_to(assets_root) + except (ValueError, OSError): + return empty + if not candidate.is_file(): + return empty + return ResourceResult([ResourceContent(candidate.read_bytes(), mime_type=_mime_for(filename))]) + + # --- 1-2. Theme --- @@ -506,97 +556,101 @@ async def install_font( # --- Rendering helpers --- -def _preview_result_to_content_blocks( - result: PreviewResult, -) -> list[TextContent | ImageContent]: - """Convert a PreviewResult to MCP content blocks with audience annotations. - - The text summary goes to both model and user. Each page image is - annotated as user-only so base64 data stays out of the LLM context. - """ - blocks: list[TextContent | ImageContent] = [ - TextContent(type="text", text=f"Preview rendered ({result.page_count} pages)"), - ] - for page in result.pages: - if page.image_base64: - blocks.append( - ImageContent( - type="image", - data=page.image_base64, - mimeType="image/png", - annotations=_USER_ONLY, - ), - ) - return blocks +def _render_pdf(pdf_bytes: bytes, summary_name: str) -> ToolResult: + size = len(pdf_bytes) + # Typst emits one /Page object per page; /Pages is the catalog node. + page_count = pdf_bytes.count(b"/Type /Page") - pdf_bytes.count(b"/Type /Pages") + if page_count < 1: + page_count = 1 + + export_id, _ = store_export(pdf_bytes, "pdf") + link = ResourceLink( + type="resource_link", + uri=AnyUrl(f"collateral://exports/{export_id}.pdf"), + name=summary_name, + mimeType="application/pdf", + description=f"{summary_name} ({page_count} pages, {size // 1024 or 1}KB)", + annotations=_USER_ONLY, + ) + + summary = f"{summary_name}: {page_count} page{'s' if page_count != 1 else ''}, {size} bytes" + structured = { + "export_id": export_id, + "page_count": page_count, + "size_bytes": size, + "mime_type": "application/pdf", + } + return ToolResult( + content=[TextContent(type="text", text=summary), link], + structured_content=structured, + ) # --- 25-27. Rendering --- @mcp.tool() -async def preview(page: int | None = None) -> list[TextContent | ImageContent]: - """Render the current document to PNG preview images. +async def preview(page: int | None = None) -> ToolResult: + """Render the current document to a PDF preview. - Returns a text summary (for the model) and image blocks (for the user). + Returns a text summary and a resource_link to the PDF at + ``collateral://exports/.pdf``. Clients read via ``resources/read`` + and render with a native PDF viewer. Args: - page: Optional page number (1-based) for single-page render. + page: Optional page number (1-based) for a single-page preview. """ - result = _ws.preview(page=page, include_images=True) - return _preview_result_to_content_blocks(result) + from . import compiler + + if page is not None: + pdf_bytes = compiler.compile_source(_ws.source, _ws.logo_data, page=page) + return _render_pdf(pdf_bytes, f"Preview of {_ws.document_name} (page {page})") + + if _ws._cached_pdf is None: + _ws._cached_pdf = compiler.compile_source(_ws.source, _ws.logo_data) + return _render_pdf(_ws._cached_pdf, f"Preview of {_ws.document_name}") @mcp.tool() -async def preview_template(template_id: str) -> list[TextContent | ImageContent]: +async def preview_template(template_id: str) -> ToolResult: """Preview a template without creating a document. - Compiles and renders the template source directly. Does not modify - the workspace or create any files on disk. Returns a text summary - (for the model) and image blocks (for the user). + Compiles the template source to PDF. Does not modify workspace state. Args: template_id: Template identifier (e.g., "proposal", "lead-magnet"). """ - result = _ws.preview_template(template_id, include_images=True) - return _preview_result_to_content_blocks(result) + from . import compiler + source = template_mod.get_source(template_id) + pdf_bytes = compiler.compile_source(source, {}) + return _render_pdf(pdf_bytes, f"Template preview: {template_id}") -@mcp.tool() -async def export_pdf(include_data: bool = False) -> list[TextContent | ImageContent]: - """Export the current document as a final PDF. Cached if unchanged. - Returns a text summary (for the model). When include_data is true, - also returns the base64 PDF as user-only content. +@mcp.tool() +async def export_pdf() -> ToolResult: + """Export the current document as a PDF. - Args: - include_data: Include base64 PDF data in the response (for download). + Returns a text summary and a resource_link to ``collateral://exports/.pdf``. """ - result = _ws.export_pdf(include_data=include_data) - blocks: list[TextContent | ImageContent] = [ - TextContent( - type="text", - text=f"Exported {result.filename} ({result.page_count} pages, {result.size_bytes} bytes)", - ), - ] - if result.pdf_base64: - blocks.append( - TextContent( - type="text", - text=result.pdf_base64, - annotations=_USER_ONLY, - ), - ) - return blocks + if _ws._cached_pdf is None: + from . import compiler + + _ws._cached_pdf = compiler.compile_source(_ws.source, _ws.logo_data) + return _render_pdf(_ws._cached_pdf, f"Export of {_ws.document_name}") @mcp.tool() -async def compile_typst(source: str) -> ExportResult: +async def compile_typst(source: str) -> ToolResult: """Compile raw Typst source to PDF. Bypasses workspace entirely. Args: source: Raw Typst source code. """ - return _ws.compile_typst(source) + from . import compiler + + pdf_bytes = compiler.compile_source(source) + return _render_pdf(pdf_bytes, "Compiled Typst document") # --------------------------------------------------------------------------- diff --git a/src/mcp_collateral/workspace.py b/src/mcp_collateral/workspace.py index 7f66b1e..06d836f 100644 --- a/src/mcp_collateral/workspace.py +++ b/src/mcp_collateral/workspace.py @@ -10,7 +10,9 @@ import base64 import io import re +import secrets import subprocess +import time import zipfile from pathlib import Path from typing import Any @@ -21,14 +23,62 @@ from . import theme as theme_mod from .models import ( DocumentInfo, - ExportResult, - PagePreview, - PreviewResult, TemplateInfo, ThemeData, WorkspaceState, ) +# Rendered artifacts are written here so tools can return resource_link +# references instead of inlining base64 bytes in tool results. +_EXPORT_TTL_SECONDS = 24 * 60 * 60 + + +def _exports_dir() -> Path: + # Resolve lazily so tests that monkeypatch store.BASE_DIR work. + return store.BASE_DIR / "exports" + + +def _cleanup_stale_exports() -> None: + try: + d = _exports_dir() + if not d.exists(): + return + cutoff = time.time() - _EXPORT_TTL_SECONDS + for p in d.iterdir(): + try: + if p.is_file() and p.stat().st_mtime < cutoff: + p.unlink() + except OSError: + pass + except OSError: + pass + + +def store_export(data: bytes, ext: str) -> tuple[str, Path]: + """Persist rendered bytes under a short-lived export id. Returns (id, path).""" + d = _exports_dir() + d.mkdir(parents=True, exist_ok=True) + _cleanup_stale_exports() + export_id = "exp_" + secrets.token_hex(8) + path = d / f"{export_id}.{ext}" + path.write_bytes(data) + return export_id, path + + +def load_export(export_id: str, ext: str) -> bytes | None: + """Load previously stored export bytes. Returns None if missing.""" + path = _exports_dir() / f"{export_id}.{ext}" + if not path.exists(): + return None + try: + return path.read_bytes() + except OSError: + return None + + +# Module-level EXPORTS_DIR kept for backwards compat and test introspection. +EXPORTS_DIR = store.BASE_DIR / "exports" + # Default blank document source BLANK_SOURCE = """\ #set document(title: "Untitled") @@ -52,7 +102,6 @@ def __init__(self) -> None: self.logo_data: dict[str, bytes] = {} self.created: str | None = None self._cached_pdf: bytes | None = None - self._cached_pngs: list[bytes] | None = None # --- Introspection --- @@ -342,51 +391,6 @@ def set_components(self, source: str) -> dict[str, str]: path = store.write_components(source) return {"status": "saved", "path": str(path)} - def preview_template(self, template_id: str, include_images: bool = False) -> PreviewResult: - """Preview a template without creating a document. Does not modify workspace state.""" - source = template_mod.get_source(template_id) - pngs = compiler.compile_source(source, {}, output_format="png") - return self._build_preview(pngs, include_images=include_images) - - # --- Rendering --- - - def preview(self, page: int | None = None, include_images: bool = False) -> PreviewResult: - if self._cached_pngs is not None and page is None: - return self._build_preview(self._cached_pngs, include_images=include_images) - - if page is not None: - pngs = compiler.compile_source( - self.source, self.logo_data, output_format="png", page=page - ) - previews = [ - PagePreview( - page_number=page, - image_base64=base64.b64encode(d).decode() if include_images else None, - ) - for d in pngs - ] - return PreviewResult( - pages=previews, - page_count=len(previews), - message=f"Preview rendered ({len(previews)} page{'s' if len(previews) != 1 else ''})", - ) - - pngs = compiler.compile_source(self.source, self.logo_data, output_format="png") - self._cached_pngs = pngs - return self._build_preview(pngs, include_images=include_images) - - def export_pdf(self, include_data: bool = False) -> ExportResult: - if self._cached_pdf is not None: - return self._build_export(self._cached_pdf, include_data=include_data) - - pdf_pages = compiler.compile_source(self.source, self.logo_data, output_format="pdf") - self._cached_pdf = pdf_pages[0] - return self._build_export(self._cached_pdf, include_data=include_data) - - def compile_typst(self, source: str) -> ExportResult: - pdf_pages = compiler.compile_source(source, output_format="pdf") - return self._build_export(pdf_pages[0], filename="compiled.pdf", include_data=True) - # --- Fonts --- def list_fonts(self) -> list[str]: @@ -448,17 +452,10 @@ def install_font( def _invalidate(self) -> None: self._cached_pdf = None - self._cached_pngs = None def _verify_compile(self) -> None: - """Compile to PDF + PNG, cache both, and write PDF to disk. Raises RuntimeError on failure.""" - pdf_pages = compiler.compile_source(self.source, self.logo_data, output_format="pdf") - self._cached_pdf = pdf_pages[0] - # Also compile PNGs so preview() never needs a second compile - self._cached_pngs = compiler.compile_source( - self.source, self.logo_data, output_format="png" - ) - # Write the compiled PDF to disk for the preview resource + """Compile to PDF, cache it, and write to disk. Raises RuntimeError on failure.""" + self._cached_pdf = compiler.compile_source(self.source, self.logo_data) if self.document_id: doc_dir = store.DOCUMENTS_DIR / self.document_id doc_dir.mkdir(parents=True, exist_ok=True) @@ -475,43 +472,6 @@ def _auto_save(self) -> None: created=self.created, ) - @staticmethod - def _build_preview(pngs: list[bytes], *, include_images: bool = False) -> PreviewResult: - previews = [ - PagePreview( - page_number=i + 1, - image_base64=base64.b64encode(d).decode() if include_images else None, - ) - for i, d in enumerate(pngs) - ] - count = len(previews) - return PreviewResult( - pages=previews, - page_count=count, - message=f"Preview rendered ({count} page{'s' if count != 1 else ''})", - ) - - @staticmethod - def _build_export( - pdf_bytes: bytes, - filename: str = "document.pdf", - *, - include_data: bool = False, - ) -> ExportResult: - size = len(pdf_bytes) - # Count pages by scanning PDF cross-reference for /Type /Page entries - page_count = pdf_bytes.count(b"/Type /Page") - pdf_bytes.count(b"/Type /Pages") - if page_count < 1: - page_count = 1 - size_kb = size // 1024 or 1 - return ExportResult( - pdf_base64=base64.b64encode(pdf_bytes).decode() if include_data else None, - filename=filename, - page_count=page_count, - size_bytes=size, - message=f"PDF exported ({page_count} page{'s' if page_count != 1 else ''}, {size_kb}KB)", - ) - def _slugify(name: str) -> str: """Convert a name to a filesystem-safe slug.""" diff --git a/tests/test_patch_source.py b/tests/test_patch_source.py index 3b52175..07a1952 100644 --- a/tests/test_patch_source.py +++ b/tests/test_patch_source.py @@ -75,7 +75,6 @@ def test_patch_not_found_raises(self, workspace: Workspace) -> None: with pytest.raises(ValueError, match="not found"): workspace.patch_source("MISSING TEXT", "replacement") - def test_patch_not_found_includes_source_context(self, workspace: Workspace) -> None: workspace.create_document("Test") workspace.set_source("= Hello World\nSome text here.") diff --git a/tests/test_tool_contracts.py b/tests/test_tool_contracts.py index 79e7551..1293463 100644 --- a/tests/test_tool_contracts.py +++ b/tests/test_tool_contracts.py @@ -13,11 +13,10 @@ from pathlib import Path import pytest +import pytest_asyncio from mcp_collateral.models import ( DocumentInfo, - ExportResult, - PreviewResult, TemplateInfo, WorkspaceState, ) @@ -299,37 +298,230 @@ def test_install_font_returns_dict(self, workspace: Workspace) -> None: # --------------------------------------------------------------------------- -# Rendering contracts +# Rendering contracts — MCP-spec resource_link tool returns # --------------------------------------------------------------------------- -class TestRenderingContracts: - """preview -> PreviewResult, export_pdf -> ExportResult.""" +@pytest_asyncio.fixture() +async def mcp_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Spin up the FastMCP server in-process with isolated storage.""" + monkeypatch.setattr("mcp_collateral.store.BASE_DIR", tmp_path) + monkeypatch.setattr("mcp_collateral.store.ASSETS_DIR", tmp_path / "assets") + monkeypatch.setattr("mcp_collateral.store.FONTS_DIR", tmp_path / "fonts") + monkeypatch.setattr("mcp_collateral.store.TEMPLATES_DIR", tmp_path / "templates") + monkeypatch.setattr("mcp_collateral.store.DOCUMENTS_DIR", tmp_path / "documents") + monkeypatch.setattr("mcp_collateral.store.COMPILE_DIR", tmp_path / "_compile") - def test_preview_returns_preview_result(self, workspace: Workspace) -> None: - workspace.create_document("Test") - result = workspace.preview() - assert isinstance(result, PreviewResult) - assert isinstance(result.pages, list) - assert isinstance(result.page_count, int) + import mcp_collateral.templates as tmod - def test_preview_with_images_has_base64(self, workspace: Workspace) -> None: - workspace.create_document("Test") - result = workspace.preview(include_images=True) - assert result.pages[0].image_base64 is not None - assert isinstance(result.pages[0].image_base64, str) + monkeypatch.setattr(tmod, "_seeded", False) - def test_export_pdf_returns_export_result(self, workspace: Workspace) -> None: - workspace.create_document("Test") - result = workspace.export_pdf() - assert isinstance(result, ExportResult) - assert result.size_bytes > 0 + from mcp_collateral import server as server_mod + from mcp_collateral import store - def test_export_pdf_with_data_has_base64(self, workspace: Workspace) -> None: - workspace.create_document("Test") - result = workspace.export_pdf(include_data=True) - assert result.pdf_base64 is not None - assert isinstance(result.pdf_base64, str) + store._ensure_dirs() + store.seed_templates() + + # Replace the module-level workspace so tools use isolated storage. + monkeypatch.setattr(server_mod, "_ws", Workspace()) + + from fastmcp import Client + + async with Client(server_mod.mcp) as client: + yield client + + +def _result_byte_size(result) -> int: + """Estimate on-the-wire size of a CallToolResult's content.""" + total = 0 + for block in result.content: + # block is a pydantic model; use json dump length as a proxy + total += len(block.model_dump_json()) + return total + + +@pytest.mark.asyncio +class TestRenderingContracts: + """Preview/export tools return small results with resource_link blocks.""" + + async def test_preview_returns_pdf_resource_link(self, mcp_client) -> None: + await mcp_client.call_tool("create_document", {"name": "Preview Doc"}) + result = await mcp_client.call_tool("preview", {}) + + assert _result_byte_size(result) < 10_000 + + links = [b for b in result.content if getattr(b, "type", None) == "resource_link"] + assert len(links) == 1 + link = links[0] + assert str(link.uri).startswith("collateral://exports/") + assert str(link.uri).endswith(".pdf") + assert link.mimeType == "application/pdf" + + for b in result.content: + dumped = b.model_dump_json() + assert len(dumped) < 2_000, "no block should inline large bytes" + + data = result.structured_content + assert data is not None + assert data["mime_type"] == "application/pdf" + assert data["size_bytes"] > 0 + assert data["export_id"].startswith("exp_") + + async def test_preview_resource_link_fetches_pdf(self, mcp_client) -> None: + await mcp_client.call_tool("create_document", {"name": "Fetch Doc"}) + result = await mcp_client.call_tool("preview", {}) + link = next(b for b in result.content if getattr(b, "type", None) == "resource_link") + + contents = await mcp_client.read_resource(str(link.uri)) + assert contents, "resources/read must return content" + import base64 as _b64 + + first = contents[0] + raw = _b64.b64decode(first.blob) if hasattr(first, "blob") else first.text.encode() + assert raw.startswith(b"%PDF") + + async def test_preview_template_returns_pdf_resource_link(self, mcp_client) -> None: + templates_result = await mcp_client.call_tool("list_templates", {}) + templates = templates_result.structured_content + tlist = ( + templates["result"] + if isinstance(templates, dict) and "result" in templates + else templates + ) + if not tlist: + pytest.skip("No seed templates available") + tid = tlist[0]["id"] + + result = await mcp_client.call_tool("preview_template", {"template_id": tid}) + assert _result_byte_size(result) < 10_000 + links = [b for b in result.content if getattr(b, "type", None) == "resource_link"] + assert len(links) == 1 + link = links[0] + assert str(link.uri).startswith("collateral://exports/") + assert link.mimeType == "application/pdf" + + async def test_export_pdf_returns_resource_link(self, mcp_client) -> None: + await mcp_client.call_tool("create_document", {"name": "Export Doc"}) + result = await mcp_client.call_tool("export_pdf", {}) + + assert _result_byte_size(result) < 10_000 + + links = [b for b in result.content if getattr(b, "type", None) == "resource_link"] + assert len(links) == 1 + link = links[0] + assert str(link.uri).startswith("collateral://exports/") + assert str(link.uri).endswith(".pdf") + assert link.mimeType == "application/pdf" + + data = result.structured_content + assert data is not None + assert data["mime_type"] == "application/pdf" + assert data["size_bytes"] > 0 + assert data["export_id"].startswith("exp_") + + async def test_export_pdf_resource_link_fetches_pdf(self, mcp_client) -> None: + await mcp_client.call_tool("create_document", {"name": "Fetch PDF"}) + result = await mcp_client.call_tool("export_pdf", {}) + link = next(b for b in result.content if getattr(b, "type", None) == "resource_link") + + contents = await mcp_client.read_resource(str(link.uri)) + assert contents + import base64 as _b64 + + first = contents[0] + raw = _b64.b64decode(first.blob) if hasattr(first, "blob") else first.text.encode() + assert raw.startswith(b"%PDF") + + async def test_compile_typst_returns_resource_link(self, mcp_client) -> None: + source = '#set page(paper: "us-letter")\n= Hello\nWorld.' + result = await mcp_client.call_tool("compile_typst", {"source": source}) + assert _result_byte_size(result) < 10_000 + links = [b for b in result.content if getattr(b, "type", None) == "resource_link"] + assert len(links) == 1 + assert links[0].mimeType == "application/pdf" + + +class TestExportResourceTemplate: + """The collateral://exports/{export_id}.{ext} resource template works.""" + + def test_store_and_load_export_roundtrip(self, workspace: Workspace, tmp_path: Path) -> None: + # Unused workspace fixture just isolates store.BASE_DIR. + del workspace + + from mcp_collateral.workspace import load_export, store_export + + export_id, path = store_export(b"hello bytes", "pdf") + assert path.exists() + assert export_id.startswith("exp_") + assert load_export(export_id, "pdf") == b"hello bytes" + + def test_load_export_missing_returns_none(self, workspace: Workspace) -> None: + del workspace + from mcp_collateral.workspace import load_export + + assert load_export("exp_missing", "pdf") is None + + +class TestAssetResourceTemplate: + """The collateral://assets/{filename} resource template works.""" + + def test_asset_resource_returns_bytes_and_mime(self, workspace: Workspace) -> None: + del workspace + from mcp_collateral import server, store + + store.ASSETS_DIR.mkdir(parents=True, exist_ok=True) + (store.ASSETS_DIR / "headshot.png").write_bytes(b"\x89PNG\r\n\x1a\nfakebytes") + + result = server.collateral_asset("headshot.png") + assert len(result.contents) == 1 + content = result.contents[0] + assert content.mime_type == "image/png" + assert content.content.startswith(b"\x89PNG") + + def test_asset_resource_mime_per_extension(self, workspace: Workspace) -> None: + del workspace + from mcp_collateral import server, store + + store.ASSETS_DIR.mkdir(parents=True, exist_ok=True) + cases = { + "brand.jpg": "image/jpeg", + "logo.svg": "image/svg+xml", + "notes.md": "text/markdown", + "random.bin": "application/octet-stream", + } + for filename, expected_mime in cases.items(): + (store.ASSETS_DIR / filename).write_bytes(b"payload") + result = server.collateral_asset(filename) + assert result.contents[0].mime_type == expected_mime, filename + + def test_asset_resource_missing_returns_empty(self, workspace: Workspace) -> None: + del workspace + from mcp_collateral import server + + result = server.collateral_asset("does-not-exist.png") + assert len(result.contents) == 1 + assert result.contents[0].content == b"" + + def test_asset_resource_refuses_directory(self, workspace: Workspace) -> None: + del workspace + from mcp_collateral import server, store + + store.ASSETS_DIR.mkdir(parents=True, exist_ok=True) + (store.ASSETS_DIR / "a-folder").mkdir(exist_ok=True) + + result = server.collateral_asset("a-folder") + assert result.contents[0].content == b"" + + def test_asset_resource_rejects_path_traversal(self, workspace: Workspace) -> None: + del workspace + from mcp_collateral import server + + # Classic dot-dot escape + assert server.collateral_asset("../../../etc/passwd").contents[0].content == b"" + # Nested dot-dot mid-path + assert server.collateral_asset("sub/../../etc/passwd").contents[0].content == b"" + # Absolute path + assert server.collateral_asset("/etc/passwd").contents[0].content == b"" # --------------------------------------------------------------------------- diff --git a/ui/package-lock.json b/ui/package-lock.json index b6741ea..cefb017 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -6,9 +6,10 @@ "": { "name": "collateral-ui", "dependencies": { - "@nimblebrain/synapse": "^0.4.2", + "@nimblebrain/synapse": "^0.4.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "unpdf": "^1.6.0" }, "devDependencies": { "@types/react": "^19.0.0", @@ -875,9 +876,9 @@ } }, "node_modules/@nimblebrain/synapse": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@nimblebrain/synapse/-/synapse-0.4.2.tgz", - "integrity": "sha512-Y6q97I2nQqYLL0yNhEl5BoBsOq5uWzH8/1kou/dCw+5E3+g2ki+jZlSOgdMmiYqbrWOWY33qudXRCVtmFEXIhQ==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@nimblebrain/synapse/-/synapse-0.4.3.tgz", + "integrity": "sha512-tIcbjqPcXt4gHxquiaXlNv2rxXXvlFgVcT/lvVQIUAwcC4mJjlZt7Yo5UpG/5ap8rKgxTMqCLnMWghAX6h6i1g==", "license": "MIT", "bin": { "synapse": "dist/codegen/cli.js" @@ -2948,6 +2949,20 @@ "node": ">=14.17" } }, + "node_modules/unpdf": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.6.0.tgz", + "integrity": "sha512-DsjbuDe6PDbZzGvAP40QQp0xskrXP3Tm3fd/FLkGObL00Icr7cc28QgrPHYg+6B1lMWydgXwDXauIv5CGyXudA==", + "license": "MIT", + "peerDependencies": { + "@napi-rs/canvas": "^0.1.69" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 35baab4..34727d5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,9 +8,10 @@ "preview": "vite preview" }, "dependencies": { - "@nimblebrain/synapse": "^0.4.2", + "@nimblebrain/synapse": "^0.4.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "unpdf": "^1.6.0" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d38483d..843bb9e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,1164 +1,516 @@ -import { useState, useEffect, useCallback } from "react"; -import { - SynapseProvider, - useCallTool, - useDataSync, - useSynapse, - useTheme, -} from "@nimblebrain/synapse/react"; +import { useCallback, useEffect, useState } from "react"; +import { useDataSync } from "@nimblebrain/synapse/react"; +import { s, tokens } from "./styles"; +import { useInjectThemeTokens } from "./theme-utils"; +import { injectResponsiveStyles } from "./styles/responsive"; +import { TopNav } from "./components/TopNav"; +import type { Tab } from "./components/TopNav"; +import { Dialog } from "./components/Dialog"; +import { SettingsPanel } from "./components/SettingsPanel"; +import { DocumentsView } from "./views/DocumentsView"; +import { TemplatesView } from "./views/TemplatesView"; +import { AssetsView } from "./views/AssetsView"; +import { useDocuments } from "./hooks/useDocuments"; +import type { TemplateInfo } from "./hooks/useTemplates"; +import { useTemplates } from "./hooks/useTemplates"; +import { useAssets } from "./hooks/useAssets"; +import { usePreview } from "./hooks/usePreview"; + +type DialogType = + | "newDoc" + | "newTemplate" + | "saveAsTemplate" + | "deleteTemplate" + | "renameDoc" + | "deleteDoc" + | null; -// --- Types matching server contracts --- - -interface TemplateInfo { - id: string; - name: string; - description: string; - page_count: number; - variables: unknown[]; - created: string; - modified: string; -} - -interface DocumentInfo { - id: string; - name: string; - template_id: string | null; - created: string; - modified: string; -} - -interface WorkspaceState { - document_id: string | null; - document_name: string | null; - template_id: string | null; - theme: ThemeData; - source: string; - sections: unknown[]; - has_cache: boolean; -} - -interface ThemeData { - colors: Record; - fonts: Record; - spacing: Record; -} - -interface PreviewResult { - pages: { page_number: number; image_base64: string | null }[]; - page_count: number; - message: string; -} - -interface ExportResult { - filename: string; - pdf_base64: string | null; - page_count: number; - size_bytes: number; -} - -/** - * Extract base64 image data from MCP content blocks returned by preview tools. - * The server returns ImageContent blocks with audience:["user"] for the UI. - */ -function extractImagesFromContent(blocks: unknown[]): string[] { - return blocks - .filter( - (block): block is { type: "image"; data: string } => - block != null && - typeof block === "object" && - (block as Record).type === "image" && - typeof (block as Record).data === "string", - ) - .map((block) => block.data); -} - -// --- Tab type --- -type Tab = "documents" | "templates"; - -// --- Settings sections --- -type SettingsSection = "voice" | "components" | "assets" | null; - -function CollateralStudioUI() { - const theme = useTheme(); - const synapse = useSynapse(); +export function App() { + useInjectThemeTokens(); const [tab, setTab] = useState("documents"); - // Tool hooks - const listTemplates = useCallTool("list_templates"); - const createTemplateTool = useCallTool("create_template"); - const duplicateTemplateTool = useCallTool("duplicate_template"); - const deleteTemplateTool = useCallTool("delete_template"); - const deleteDocumentTool = useCallTool("delete_document"); - const createDocument = useCallTool("create_document"); - const listDocuments = useCallTool("list_documents"); - const openDocument = useCallTool("open_document"); - const saveDocument = useCallTool("save_document"); - const saveAsTemplate = useCallTool("save_as_template"); - const previewTool = useCallTool("preview"); - const previewTemplateTool = useCallTool("preview_template"); - const exportPdf = useCallTool("export_pdf"); - const uploadAsset = useCallTool<{ filename: string }>("upload_asset"); - const listAssetsTool = useCallTool("list_assets"); - const deleteAssetTool = useCallTool<{ status: string }>("delete_asset"); - const setVoiceTool = useCallTool<{ status: string }>("set_voice"); - const getVoiceTool = useCallTool("get_voice"); - const setComponentsTool = useCallTool<{ status: string }>("set_components"); - const getComponentsTool = useCallTool("get_components"); - - // List state - const [docs, setDocs] = useState([]); - const [templates, setTemplates] = useState([]); + const { + documents, + refresh: refreshDocs, + create: createDoc, + open: openDoc, + save: saveDoc, + remove: removeDoc, + saveAsTemplate: saveDocAsTemplate, + } = useDocuments(); + const { + templates, + refresh: refreshTemplates, + create: createTemplate, + duplicate: duplicateTemplate, + remove: removeTemplate, + } = useTemplates(); + const { + assets, + refresh: refreshAssets, + upload: uploadAsset, + remove: removeAsset, + } = useAssets(); + const { + blob: previewBlob, + loading: previewLoading, + error: previewError, + previewDocument, + previewTemplate, + clear: clearPreview, + setError: setPreviewError, + } = usePreview(); - // Selection state - const [selectedTemplate, setSelectedTemplate] = useState(null); const [selectedDocument, setSelectedDocument] = useState(null); - - // Preview state - const [pages, setPages] = useState([]); - const [pageIndex, setPageIndex] = useState(0); - const [previewError, setPreviewError] = useState(""); - const [previewLoading, setPreviewLoading] = useState(false); - - // Settings state + const [selectedTemplate, setSelectedTemplate] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsSection, setSettingsSection] = useState(null); - const [voice, setVoiceText] = useState(""); - const [components, setComponentsText] = useState(""); - const [assets, setAssets] = useState([]); - // Dialog state - const [dialogType, setDialogType] = useState<"newDoc" | "newTemplate" | "saveAsTemplate" | "deleteTemplate" | "renameDoc" | "deleteDoc" | null>(null); + const [dialogType, setDialogType] = useState(null); const [dialogName, setDialogName] = useState(""); const [dialogDesc, setDialogDesc] = useState(""); const [dialogTemplate, setDialogTemplate] = useState(""); const [deleteConfirmId, setDeleteConfirmId] = useState(null); - const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); - // Synapse design token map - // Keyboard navigation for preview pages (left/right arrows) useEffect(() => { - function onKey(e: KeyboardEvent) { - if (pages.length <= 1) return; - if (e.key === "ArrowLeft") setPageIndex((i) => Math.max(0, i - 1)); - if (e.key === "ArrowRight") setPageIndex((i) => Math.min(pages.length - 1, i + 1)); - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [pages.length]); - - const TOKEN_MAP: Record = { - background: "--color-background-primary", - foreground: "--color-text-primary", - card: "--color-background-secondary", - primary: "--color-text-accent", - border: "--color-border-primary", - muted: "--color-text-secondary", - secondary: "--color-background-secondary", - destructive: "--nb-color-danger", - }; - - const t = (token: string, fallback: string) => - theme.tokens[TOKEN_MAP[token] ?? token] || fallback; + injectResponsiveStyles(); + }, []); - // Whether we have an active document or template loaded for preview - const hasSelection = tab === "documents" ? !!selectedDocument : !!selectedTemplate; - - // --- Data loading --- - - const loadTemplates = useCallback(async () => { - try { - const result = await listTemplates.call({}); - setTemplates((result.data as TemplateInfo[]) || []); - } catch { /* non-critical */ } - }, [listTemplates]); - - const loadDocs = useCallback(async () => { - try { - const result = await listDocuments.call({}); - setDocs((result.data as DocumentInfo[]) || []); - } catch { /* non-critical */ } - }, [listDocuments]); - - const loadSettings = useCallback(async () => { - try { - const [voiceResult, componentsResult, assetsResult] = await Promise.all([ - getVoiceTool.call({}), - getComponentsTool.call({}), - listAssetsTool.call({}), - ]); - setVoiceText((voiceResult.data as string) || ""); - setComponentsText((componentsResult.data as string) || ""); - setAssets((assetsResult.data as string[]) || []); - } catch { /* non-critical */ } - }, [getVoiceTool, getComponentsTool, listAssetsTool]); - - // Refresh the current document's preview (workspace-based). - // Only used after openDocument — template previews use preview_template directly. - const refreshPreview = useCallback(async () => { - setPreviewLoading(true); - setPreviewError(""); - try { - const result = await previewTool.call({}); - const images = extractImagesFromContent(result.content ?? []); - setPages(images); - setPageIndex(0); - } catch (e) { - setPreviewError(e instanceof Error ? e.message : "Preview failed"); - } - setPreviewLoading(false); - }, [previewTool]); - - // --- Open a document for preview --- - const handleSelectDocument = useCallback(async (id: string) => { - setSelectedDocument(id); - setSelectedTemplate(null); - try { - await openDocument.call({ document_id: id }); - await refreshPreview(); - } catch (e) { - setPreviewError(e instanceof Error ? e.message : "Failed to open"); - } - }, [openDocument, refreshPreview]); - - // --- Open a template for preview --- - const handleSelectTemplate = useCallback(async (id: string) => { - setSelectedTemplate(id); - setSelectedDocument(null); - setPreviewLoading(true); - setPreviewError(""); - try { - const result = await previewTemplateTool.call({ template_id: id }); - const images = extractImagesFromContent(result.content ?? []); - setPages(images); - setPageIndex(0); - } catch (e) { - setPreviewError(e instanceof Error ? e.message : "Failed to preview template"); - } - setPreviewLoading(false); - }, [previewTemplateTool]); + useEffect(() => { + if (tab === "templates") refreshTemplates(); + else if (tab === "assets") refreshAssets(); + else refreshDocs(); + }, [tab, refreshDocs, refreshTemplates, refreshAssets]); - // Auto-refresh when the agent calls tools (data-changed from host). - // Only refresh the document preview — template previews are static snapshots - // that don't change in response to agent activity. useDataSync(() => { - if (tab === "templates") loadTemplates(); - if (tab === "documents") { - loadDocs(); - if (selectedDocument) refreshPreview(); + if (tab === "templates") refreshTemplates(); + else if (tab === "assets") refreshAssets(); + else if (tab === "documents") { + refreshDocs(); + if (selectedDocument) previewDocument(); } }); - // Load data when tab changes - useEffect(() => { - if (tab === "templates") loadTemplates(); - if (tab === "documents") loadDocs(); - }, [tab]); + const hasSelection = tab === "documents" ? !!selectedDocument : !!selectedTemplate; - // --- Actions --- + const handleSelectDocument = useCallback( + async (id: string) => { + setSelectedDocument(id); + setSelectedTemplate(null); + try { + await openDoc(id); + await previewDocument(); + } catch (e) { + setPreviewError(e instanceof Error ? e.message : "Failed to open"); + } + }, + [openDoc, previewDocument, setPreviewError], + ); - async function handleCreateDocument() { + const handleSelectTemplate = useCallback( + async (id: string) => { + setSelectedTemplate(id); + setSelectedDocument(null); + await previewTemplate(id); + }, + [previewTemplate], + ); + + const openDialog = (type: "newDoc" | "newTemplate" | "saveAsTemplate") => { + setDialogName(""); + setDialogDesc(""); + setDialogType(type); + }; + + const handleCreateDocument = async () => { const name = dialogName.trim(); if (!name) return; try { - const args: Record = { name }; + const args: { name: string; template_id?: string } = { name }; if (dialogTemplate) args.template_id = dialogTemplate; - const result = await createDocument.call(args); - const ws = result.data as WorkspaceState; + const ws = await createDoc(args); setDialogType(null); setSelectedDocument(ws.document_id); setSelectedTemplate(null); setDialogTemplate(""); setTab("documents"); - await Promise.all([loadDocs(), refreshPreview()]); + await Promise.all([refreshDocs(), previewDocument()]); } catch (e) { - // Keep dialog open and show error setPreviewError(e instanceof Error ? e.message : "Failed to create document"); } - } + }; - async function handleCreateTemplate() { + const handleCreateTemplate = async () => { const name = dialogName.trim(); if (!name) return; setDialogType(null); try { - const tid = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); - await createTemplateTool.call({ + const tid = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + await createTemplate({ template_id: tid, name, description: dialogDesc.trim(), source: "", }); - await loadTemplates(); - } catch { /* non-critical */ } - } + await refreshTemplates(); + } catch { + /* non-critical */ + } + }; - async function handleDuplicateTemplate(tpl: TemplateInfo) { + const handleDuplicateTemplate = async (tpl: TemplateInfo) => { try { - await duplicateTemplateTool.call({ + await duplicateTemplate({ template_id: tpl.id, new_id: tpl.id + "-copy", new_name: tpl.name + " (Copy)", }); - await loadTemplates(); - } catch { /* non-critical */ } - } + await refreshTemplates(); + } catch { + /* non-critical */ + } + }; - async function handleDeleteTemplate(id: string) { + const handleDeleteTemplate = async (id: string) => { try { - await deleteTemplateTool.call({ template_id: id }); + await removeTemplate(id); setDeleteConfirmId(null); setDialogType(null); if (selectedTemplate === id) { setSelectedTemplate(null); - setPages([]); + clearPreview(); } - await loadTemplates(); - } catch { /* non-critical */ } - } - - async function handleSaveDocument() { - setSaveStatus("saving"); - try { - await saveDocument.call({}); - setSaveStatus("saved"); - setTimeout(() => setSaveStatus("idle"), 1500); - } catch (e) { - setPreviewError(e instanceof Error ? e.message : "Save failed"); - setSaveStatus("idle"); + await refreshTemplates(); + } catch { + /* non-critical */ } - } + }; - async function handleSaveAsTemplate() { + const handleSaveAsTemplate = async () => { const name = dialogName.trim(); if (!name) return; setDialogType(null); try { - await saveAsTemplate.call({ name, description: dialogDesc.trim() }); - await loadTemplates(); + await saveDocAsTemplate({ name, description: dialogDesc.trim() }); + await refreshTemplates(); } catch (e) { setPreviewError(e instanceof Error ? e.message : "Failed to save as template"); } - } - - async function handleExport() { - try { - const result = await exportPdf.call({ include_data: true }); - const data = result.data as ExportResult; - if (!data.pdf_base64) return; - const raw = atob(data.pdf_base64); - synapse.downloadFile(data.filename || "document.pdf", raw, "application/pdf"); - } catch (e) { - setPreviewError(e instanceof Error ? e.message : "Export failed"); - } - } + }; - async function handleDeleteDocument(id: string) { + const handleDeleteDocument = async (id: string) => { setDialogType(null); setDeleteConfirmId(null); try { - await deleteDocumentTool.call({ document_id: id }); + await removeDoc(id); if (selectedDocument === id) { setSelectedDocument(null); + clearPreview(); } - await loadDocs(); + await refreshDocs(); } catch (e) { setPreviewError(e instanceof Error ? e.message : "Delete failed"); } - } + }; - async function handleRenameDocument() { + const handleRenameDocument = async () => { const name = dialogName.trim(); if (!name || !selectedDocument) return; setDialogType(null); try { - await saveDocument.call({ name }); - await loadDocs(); + await saveDoc({ name }); + await refreshDocs(); } catch (e) { setPreviewError(e instanceof Error ? e.message : "Rename failed"); } - } + }; - function openDialog(type: "newDoc" | "newTemplate" | "saveAsTemplate") { - setDialogName(""); - setDialogDesc(""); - setDialogType(type); - } + return ( +
+ openDialog("saveAsTemplate")} + onRename={() => { + setDialogName(""); + setDialogType("renameDoc"); + }} + settingsOpen={settingsOpen} + onToggleSettings={() => setSettingsOpen((prev) => !prev)} + /> + + {tab === "documents" && ( + openDialog("newDoc")} + onDelete={(id) => { + setDeleteConfirmId(id); + setDialogType("deleteDoc"); + }} + previewBlob={previewBlob} + previewLoading={previewLoading} + previewError={hasSelection ? previewError : ""} + /> + )} + {tab === "templates" && ( + openDialog("newTemplate")} + onDuplicate={handleDuplicateTemplate} + onDelete={(id) => { + setDeleteConfirmId(id); + setDialogType("deleteTemplate"); + }} + previewBlob={previewBlob} + previewLoading={previewLoading} + previewError={hasSelection ? previewError : ""} + /> + )} + {tab === "assets" && ( + + )} - // --- Render --- + setSettingsOpen(false)} /> - return ( -
- {/* Top bar */} - - - {/* Main content */} -
- {/* Left panel: list */} -
- {/* Action buttons */} -
- {tab === "templates" && ( - - )} - {tab === "documents" && ( - - )} -
- - {/* List items */} -
- {tab === "templates" && templates.map((tpl) => ( -
handleSelectTemplate(tpl.id)} - > -
{tpl.name}
- {tpl.description && ( -
- {tpl.description} -
- )} -
- {tpl.page_count} page{tpl.page_count !== 1 ? "s" : ""} -
-
e.stopPropagation()}> - - -
-
- ))} - - {tab === "documents" && docs.map((d) => ( -
handleSelectDocument(d.id)} - > -
-
{d.name}
- -
-
- {d.template_id || "custom"} ·{" "} - {d.modified ? new Date(d.modified).toLocaleDateString() : ""} -
-
- ))} - - {tab === "templates" && templates.length === 0 && ( -
- No templates yet. -
- )} - {tab === "documents" && docs.length === 0 && ( -
- No documents yet. -
- )} -
-
- - {/* Right panel: preview */} -
- {!hasSelection && ( -
- Select a {tab === "templates" ? "template" : "document"} to preview. -
- )} - {previewLoading && ( -
Rendering...
- )} - {previewError && ( -
{previewError}
- )} - {pages.length > 0 && ( - <> - {`Page - {pages.length > 1 && ( -
- - {pageIndex + 1} / {pages.length} - -
- )} - - )} -
-
- - {/* Settings panel (slide-over) */} - {settingsOpen && ( -
e.target === e.currentTarget && setSettingsOpen(false)}> -
-
-

Settings

- -
- - {/* Settings nav */} -
- {(["voice", "components", "assets"] as SettingsSection[]).map((sec) => ( - - ))} -
- - {/* Voice */} - {settingsSection === "voice" && ( -
-

Voice

-

- Brand voice, tone, and style guidance for the agent. -

-