diff --git a/.gitignore b/.gitignore index cdbc9a9..81bf7fc 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ web-ui/public/aws-exports.json !.kiro/steering/ .kiro/steering/* !.kiro/steering/principles.md +!.kiro/steering/tech-public.md !.kiro/steering/versioning.md !.kiro/steering/steering-policy.md diff --git a/.kiro/steering/principles.md b/.kiro/steering/principles.md index 00f3e18..210123d 100644 --- a/.kiro/steering/principles.md +++ b/.kiro/steering/principles.md @@ -65,3 +65,11 @@ Do not share: 1. Does the logic exist in the Engine? → Use it 2. Is it infrastructure-dependent? → S3/DynamoDB dependencies allow MCP Remote independent implementation 3. Is the difference only in presentation/output? → Engine API returns data, each layer controls output + +## PR前ローカルチェック + +PR作成前に以下をローカルで実行し、CI待ちを減らす: + +```bash +ash scan --mode local --fail-on-findings +``` diff --git a/.kiro/steering/tech-public.md b/.kiro/steering/tech-public.md new file mode 100644 index 0000000..dc14995 --- /dev/null +++ b/.kiro/steering/tech-public.md @@ -0,0 +1,14 @@ + + +# Tech (Public) + +## Deployment +- WebUI: `AWS_DEFAULT_REGION= bash scripts/deploy_webui.sh` +- CDK stacks: SdpmWebUi, SdpmAgent, SdpmRuntime, SdpmPngWorker, SdpmData, SdpmAuth + +## Security Scanning +- ASH (Automated Security Helper) v3 +- Local: `ash scan --mode local --fail-on-findings` +- Install: `alias ash="uvx git+https://github.com/awslabs/automated-security-helper.git@v3"` +- CI: GitHub Actions `.github/workflows/` で `--fail-on-findings` 付きで実行 +- md5等の非セキュリティ用途ハッシュには `usedforsecurity=False` を付与(bandit B303) diff --git a/api/index.py b/api/index.py index c5dcbf4..26c9649 100644 --- a/api/index.py +++ b/api/index.py @@ -424,15 +424,43 @@ def get_deck(deck_id: str) -> Dict[str, Any]: resp = s3_client.get_object(Bucket=BUCKET_NAME, Key=pres_key) presentation = json.loads(resp["Body"].read()) preview_keys = _list_preview_keys(deck_id) + # Check compose data existence (defs + per-slide, epoch-keyed like previews) + compose_keys = set() + try: + for obj in s3_client.list_objects_v2(Bucket=BUCKET_NAME, Prefix=f"decks/{deck_id}/compose/").get("Contents", []): + compose_keys.add(obj["Key"]) + except Exception: + pass + + import re as _re + def _latest_compose_key(prefix: str, keys: set) -> Optional[str]: + """Pick the key with the highest epoch from epoch-keyed compose files.""" + best_epoch, best_key = -1, None + for k in keys: + if not k.startswith(prefix): + continue + m = _re.search(r"_(\d+)\.json$", k) + epoch = int(m.group(1)) if m else 0 + if epoch > best_epoch: + best_epoch, best_key = epoch, k + return best_key + + defs_key = _latest_compose_key(f"decks/{deck_id}/compose/defs_", compose_keys) + has_defs = defs_key is not None + for i, s in enumerate(presentation.get("slides", [])): sid = f"slide_{i + 1:02d}" slide_preview = _resolve_preview_url(deck_id, sid, preview_keys) slide_entry: Dict[str, Any] = {"slideId": sid, "previewUrl": slide_preview} + # Compose URL for animation (epoch-keyed) + compose_key = _latest_compose_key(f"decks/{deck_id}/compose/slide_{i + 1}_", compose_keys) + if compose_key: + slide_entry["composeUrl"] = preview_url(compose_key) if include_json: slide_entry["slideJson"] = json.dumps(s) slides.append(slide_entry) except Exception: - pass + has_defs = False # Read spec files from S3 (brief.md, outline.md, art-direction.html/.md) specs: Dict[str, Any] = {} @@ -467,6 +495,7 @@ def get_deck(deck_id: str) -> Dict[str, Any]: "slideCount": len(slides), "slides": slides, "specs": specs, + "defsUrl": preview_url(defs_key) if has_defs else None, "pptxUrl": (_cf_signed_url(pptx_key) or presigned_url(s3_client, BUCKET_NAME, pptx_key)) if pptx_key else None, "updatedAt": deck.get("updatedAt", ""), "chatSessionId": deck.get("chatSessionId"), diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile index 825af8e..bf5e48f 100644 --- a/mcp-server/Dockerfile +++ b/mcp-server/Dockerfile @@ -2,6 +2,7 @@ # Platform: ARM64 (required by AgentCore Runtime) # Listens on 0.0.0.0:8000/mcp (stateless streamable-http) # Includes LibreOffice + poppler for synchronous PNG/SVG generation +# cache-bust: 20260413-1457 FROM --platform=linux/arm64 public.ecr.aws/docker/library/fedora:41 diff --git a/mcp-server/server.py b/mcp-server/server.py index c0e0e38..3bd55d5 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -461,19 +461,23 @@ def _build_pptx(tmpdir: Path, slides: list[dict], build_kwargs: dict) -> Path: return pptx_path -def _run_measure(tmpdir: Path, pptx_path: Path, slide_numbers: list[int]) -> str: - """PPTX → SVG → bbox measurement → report string.""" +def _export_svg(tmpdir: Path, pptx_path: Path) -> Path: + """PPTX → SVG via LibreOffice. Returns svg_path.""" import subprocess - - from sdpm.preview.measure import measure_from_svg, format_measure_report - env = os.environ.copy() env["HOME"] = str(tmpdir) - subprocess.run( # nosec B603 + subprocess.run( ["soffice", "--headless", "--convert-to", "svg", "--outdir", str(tmpdir), str(pptx_path)], env=env, capture_output=True, text=True, timeout=120, check=True, ) - svg_path = tmpdir / "measure.svg" + return tmpdir / "measure.svg" + + +def _run_measure(tmpdir: Path, pptx_path: Path, slide_numbers: list[int]) -> str: + """PPTX → SVG → bbox measurement → report string.""" + from sdpm.preview.measure import measure_from_svg, format_measure_report + + svg_path = _export_svg(tmpdir, pptx_path) if not svg_path.exists(): return json.dumps({"error": "LibreOffice SVG export failed"}) @@ -744,8 +748,7 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, result: dict = {"output": output} # Post-processing: measure_slides triggers PPTX build → measure/lint/bias - # WebP preview generation runs when measure_slides is present and save=True - if deck_id and measure_slides: + if deck_id and (measure_slides or save): import shutil import traceback @@ -756,35 +759,135 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, tmpdir, slides, build_kwargs = _prepare_workspace(deck_id, user_id, _storage) pptx_path = _build_pptx(tmpdir, slides, build_kwargs) - # Measure - try: - result["measure"] = _run_measure(tmpdir, pptx_path, measure_slides) - except Exception as e: - result["measure"] = json.dumps({"error": str(e)}) + # Measure (runs SVG export internally) + if measure_slides: + try: + result["measure"] = _run_measure(tmpdir, pptx_path, measure_slides) + except Exception as e: + result["measure"] = json.dumps({"error": str(e)}) # Lint (filter to measured slides; lint uses 0-based index) - try: - from sdpm.schema.lint import lint as lint_slides - presentation = json.loads((tmpdir / "presentation.json").read_text(encoding="utf-8")) - slide_set = set(measure_slides) - lint_diag = [d for d in lint_slides(presentation) if d.get("slide") + 1 in slide_set] - if lint_diag: - result["errors"] = {"lintDiagnostics": lint_diag} - except Exception as e: - logger.warning("Lint failed: %s", e) - - # Layout bias (filter to measured slides; bias uses 1-based) - try: - from sdpm.preview import check_layout_imbalance_data - layout_bias = [b for b in check_layout_imbalance_data(pptx_path, slide_defs=slides) if b.get("slide") in slide_set] - if layout_bias: - result["warnings"] = {"layoutBias": layout_bias} - except Exception as e: - logger.warning("Layout bias check failed: %s", e) + if measure_slides: + try: + from sdpm.schema.lint import lint as lint_slides + presentation = json.loads((tmpdir / "presentation.json").read_text(encoding="utf-8")) + slide_set = set(measure_slides) + lint_diag = [d for d in lint_slides(presentation) if d.get("slide") + 1 in slide_set] + if lint_diag: + result["errors"] = {"lintDiagnostics": lint_diag} + except Exception as e: + logger.warning("Lint failed: %s", e) + + # Layout bias (filter to measured slides; bias uses 1-based) + try: + from sdpm.preview import check_layout_imbalance_data + layout_bias = [b for b in check_layout_imbalance_data(pptx_path, slide_defs=slides) if b.get("slide") in slide_set] + if layout_bias: + result["warnings"] = {"layoutBias": layout_bias} + except Exception as e: + logger.warning("Layout bias check failed: %s", e) if save: - from server_utils import schedule_webp_background - schedule_webp_background(deck_id, pptx_path, tmpdir, _storage) + # Compose: SVG → optimized JSON for WebUI animation + # Runs synchronously — measured at ~300ms for 8 slides (≪3s threshold) + try: + from tools.compose import extract_optimized_defs, split_slide_components, count_slides + import hashlib as _hashlib + svg_path = tmpdir / "measure.svg" + if not svg_path.exists(): + _export_svg(tmpdir, pptx_path) + if svg_path.exists(): + import json as _json + import time as _time + _epoch = int(_time.time()) + compose_prefix = f"decks/{deck_id}/compose/" + + # 1. List old compose keys (for cleanup + prev data) + old_keys = _storage.list_files(prefix=compose_prefix, bucket=_storage.pptx_bucket) + + # 2. Load previous slide compose data keyed by sourceHash + slot number + prev_by_hash: dict[str, list[dict]] = {} + prev_by_slot: list[list[dict]] = [] # ordered by slide number + prev_slot_map: dict[int, list[dict]] = {} + import re as _re + for k in old_keys: + if "/slide_" not in k: + continue + try: + raw = _storage.download_file_from_pptx_bucket(k) + prev_data = _json.loads(raw) + comps = prev_data.get("components", []) + h = prev_data.get("sourceHash") + if h: + prev_by_hash[h] = comps + # Extract slot number from key: slide_{N}_{epoch}.json + m = _re.search(r"/slide_(\d+)_", k) + if m: + prev_slot_map[int(m.group(1))] = comps + except Exception: + pass + # Build ordered list + if prev_slot_map: + max_slot = max(prev_slot_map.keys()) + prev_by_slot = [prev_slot_map.get(i, []) for i in range(1, max_slot + 1)] + + # 3. Upload defs + defs_data = extract_optimized_defs(svg_path) + _storage.upload_file( + key=f"{compose_prefix}defs_{_epoch}.json", + data=_json.dumps(defs_data, ensure_ascii=False).encode(), + content_type="application/json", + ) + + # 4. Generate compose for all slides with changed flags + _total = count_slides(svg_path) + for sn in range(1, _total + 1): + try: + comp_data = split_slide_components(svg_path, sn) + # sourceHash from slide JSON + src_hash = "" + if sn <= len(slides): + src_hash = _hashlib.md5(_json.dumps(slides[sn - 1], sort_keys=True, ensure_ascii=False).encode(), usedforsecurity=False).hexdigest() + comp_data["sourceHash"] = src_hash + + # Diff: find prev slide by sourceHash, fallback to same slot number + prev_comps = prev_by_hash.get(src_hash) + if prev_comps is None and sn <= len(prev_by_slot): + prev_comps = prev_by_slot[sn - 1] + if prev_comps is not None: + # Component-level diff + def _mk(c: dict) -> str: + b = c.get("bbox") + return f"{c['class']}|{b['x']},{b['y']},{b['w']},{b['h']}" if b else f"{c['class']}|none" + def _fp(c: dict) -> str: + return f"{c['class']}|{c.get('text', '')}" + prev_map = {_mk(c): _fp(c) for c in prev_comps} + for c in comp_data["components"]: + k = _mk(c) + c["changed"] = k not in prev_map or prev_map[k] != _fp(c) + else: + for c in comp_data["components"]: + c["changed"] = True + + _storage.upload_file( + key=f"{compose_prefix}slide_{sn}_{_epoch}.json", + data=_json.dumps(comp_data, ensure_ascii=False).encode(), + content_type="application/json", + ) + except Exception: + logger.error("compose failed for slide %d", sn, exc_info=True) + + # 5. Cleanup old compose files + for k in old_keys: + try: + _storage._s3.delete_object(Bucket=_storage.pptx_bucket, Key=k) + except Exception: + pass + except Exception: + logger.error("compose failed", exc_info=True) + + # tmpdir cleanup (WebP generation only in generate_pptx) + shutil.rmtree(tmpdir, ignore_errors=True) else: shutil.rmtree(tmpdir, ignore_errors=True) except Exception as e: diff --git a/mcp-server/tools/compose.py b/mcp-server/tools/compose.py new file mode 100644 index 0000000..c5bbf4f --- /dev/null +++ b/mcp-server/tools/compose.py @@ -0,0 +1,160 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""SVG composition: split LibreOffice SVG into optimized per-slide components. + +MCP Remote only — consumed by WebUI for build animation. +Not shared with Engine/CLI/MCP Local (no consumer there). +""" + +import base64 +import io +import logging +import re +from pathlib import Path + +from lxml import etree +from PIL import Image + +logger = logging.getLogger("sdpm.compose") + +SVG_NS = "http://www.w3.org/2000/svg" +OOO_NS = "http://xml.openoffice.org/svg/export" +_PNG_B64_RE = re.compile(r"data:image/png;base64,([A-Za-z0-9+/=]+)") + + +def _png_to_webp_b64(match: re.Match) -> str: + png_data = base64.b64decode(match.group(1)) + img = Image.open(io.BytesIO(png_data)) + buf = io.BytesIO() + img.save(buf, format="WEBP", quality=80) + return f"data:image/webp;base64,{base64.b64encode(buf.getvalue()).decode()}" + + +def _convert_images(svg_str: str) -> str: + return _PNG_B64_RE.sub(_png_to_webp_b64, svg_str) + + +def _strip_fonts(defs_el: etree._Element) -> None: + for font in defs_el.findall(f".//{{{SVG_NS}}}font"): + font.getparent().remove(font) + + +def count_slides(svg_path: Path) -> int: + """Return the number of slides in a LibreOffice SVG.""" + root = etree.parse(str(svg_path)).getroot() + return len(root.findall(f".//{{{SVG_NS}}}g[@class='Slide']")) + + +def extract_optimized_defs(svg_path: Path) -> dict: + """Extract shared defs: strip SVG fonts, convert PNG→WebP. + + Returns {"version": 1, "defs": str}. + """ + tree = etree.parse(str(svg_path)) + root = tree.getroot() + defs_elements = root.findall(f"{{{SVG_NS}}}defs") + for d in defs_elements: + _strip_fonts(d) + defs_svg = "".join(etree.tostring(d, encoding="unicode") for d in defs_elements) + return {"version": 1, "defs": _convert_images(defs_svg)} + + +def split_slide_components(svg_path: Path, slide_num: int) -> dict: + """Split one slide into component fragments with metadata (defs excluded). + + Returns {"version": 1, "viewBox": str, "bgFill": str, + "bgSvg": str|None, "components": [...]}. + """ + tree = etree.parse(str(svg_path)) + root = tree.getroot() + view_box = root.get("viewBox", "0 0 33867 19050") + + slides = root.findall(f".//{{{SVG_NS}}}g[@class='Slide']") + if slide_num >= len(slides): + raise ValueError(f"Slide {slide_num} not found (total: {len(slides) - 1})") + + page_g = slides[slide_num].find(f"{{{SVG_NS}}}g[@class='Page']") + if page_g is None: + raise ValueError("No Page group found") + + # --- Background --- + bg_fill = "#000" + bg_svg = None + + # 1) Slide-specific custom background (Page > defs.SlideBackground) + slide_bg_defs = page_g.find(f"{{{SVG_NS}}}defs[@class='SlideBackground']") + if slide_bg_defs is not None: + parts = [] + for child in slide_bg_defs: + cls = child.get("class", "") + if cls in ("Background", "BackgroundObjects"): + parts.append(etree.tostring(child, encoding="unicode")) + if cls == "Background": + for el in child.iter(): + f = el.get("fill") + if f and f != "none": + bg_fill = f + break + if parts: + bg_svg = _convert_images("\n".join(parts)) + + # 2) Fallback: master page background + if bg_svg is None: + meta_slides = root.find(f".//{{{SVG_NS}}}g[@id='ooo:meta_slides']") + if meta_slides is not None: + meta = meta_slides.find(f".//{{{SVG_NS}}}g[@id='ooo:meta_slide_{slide_num - 1}']") + if meta is not None: + master_id = meta.get(f"{{{OOO_NS}}}master", "") + if master_id: + master_g = root.find(f".//*[@id='{master_id}']") + if master_g is not None: + parts = [] + bg_g = master_g.find(f"{{{SVG_NS}}}g[@class='Background']") + if bg_g is not None: + parts.append(etree.tostring(bg_g, encoding="unicode")) + for el in bg_g.iter(): + f = el.get("fill") + if f and f != "none": + bg_fill = f + break + bo_g = master_g.find(f"{{{SVG_NS}}}g[@class='BackgroundObjects']") + if bo_g is not None: + for child in bo_g: + if child.get("visibility") != "hidden": + parts.append(etree.tostring(child, encoding="unicode")) + if parts: + bg_svg = _convert_images("\n".join(parts)) + + # --- Components --- + components = [] + for shape_g in page_g: + if shape_g.tag != f"{{{SVG_NS}}}g": + continue + cls = shape_g.get("class", "") + bbox_el = shape_g.find(f".//{{{SVG_NS}}}rect[@class='BoundingBox']") + bbox = None + if bbox_el is not None: + bbox = { + "x": float(bbox_el.get("x", 0)), + "y": float(bbox_el.get("y", 0)), + "w": float(bbox_el.get("width", 0)), + "h": float(bbox_el.get("height", 0)), + } + text_el = shape_g.find(f".//{{{SVG_NS}}}text") + text = "" + if text_el is not None: + text = "".join(text_el.itertext()).strip()[:80] + components.append({ + "class": cls, + "bbox": bbox, + "text": text, + "svg": _convert_images(etree.tostring(shape_g, encoding="unicode")), + }) + + return { + "version": 1, + "viewBox": view_box, + "bgFill": bg_fill, + "bgSvg": bg_svg, + "components": components, + } diff --git a/mcp-server/tools/generate.py b/mcp-server/tools/generate.py index a08c6ab..6a97ed1 100644 --- a/mcp-server/tools/generate.py +++ b/mcp-server/tools/generate.py @@ -155,7 +155,7 @@ def _prepare_workspace( template_key = t.get("s3Key", "") break if not template_key: - template_key = deck.get("templateS3Key", "templates/default.pptx") + template_key = deck.get("templateS3Key", "templates/blank-dark.pptx") template_path = tmpdir / "template.pptx" template_path.write_bytes(storage.download_file(key=template_key)) diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index b84e3d1..1968eaa 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -15,9 +15,11 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", + "@types/dompurify": "^3.0.5", "aws-amplify": "^6.15.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.3.3", "lucide-react": "^0.562.0", "next": "^16.2.3", "next-themes": "^0.4.6", @@ -167,6 +169,7 @@ "resolved": "https://registry.npmjs.org/@aws-amplify/core/-/core-6.16.1.tgz", "integrity": "sha512-WHO6yYegmnZ+K3vnYzVwy+wnxYqSkdFakBIlgm4922QXHOQYWdIl/rrTcaagrpJEGT6YlTnqx1ANIoPojNxWmw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/types": "3.973.1", @@ -1637,6 +1640,7 @@ "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", @@ -3463,6 +3467,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -7055,6 +7060,15 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -7113,6 +7127,7 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -7122,6 +7137,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7132,6 +7148,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7143,6 +7160,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -7200,6 +7223,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -7719,6 +7743,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8266,6 +8291,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9081,6 +9107,15 @@ "node": ">=0.3.1" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -9405,6 +9440,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9590,6 +9626,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10015,6 +10052,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -10863,6 +10901,7 @@ "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -11852,7 +11891,6 @@ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -14420,6 +14458,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14429,6 +14468,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16094,7 +16134,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -16414,6 +16455,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17075,6 +17117,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web-ui/package.json b/web-ui/package.json index ee4318c..e4e9297 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -18,9 +18,11 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", + "@types/dompurify": "^3.0.5", "aws-amplify": "^6.15.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.3.3", "lucide-react": "^0.562.0", "next": "^16.2.3", "next-themes": "^0.4.6", diff --git a/web-ui/src/app/(authenticated)/decks/page.tsx b/web-ui/src/app/(authenticated)/decks/page.tsx index 170e1cc..0e54447 100644 --- a/web-ui/src/app/(authenticated)/decks/page.tsx +++ b/web-ui/src/app/(authenticated)/decks/page.tsx @@ -123,6 +123,7 @@ export default function DecksPage() { ) : ( void + onComplete?: () => void + fallback?: React.ReactNode +} + +function assignAgent(comp: ComposeComponent) { + const cls = comp.class || "" + if (cls === "TitleText" || cls === "SubtitleText") return AGENTS[0] + if (comp.text.length > 20) return AGENTS[1] + if (cls === "Graphic" || cls.includes("image")) return AGENTS[2] + if (cls.includes("ConnectorShape") || cls.includes("line")) return AGENTS[3] + return AGENTS[4] +} + +export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, skipAnimation, onAnimate, onComplete, fallback }: AnimatedSlidePreviewProps) { + const containerRef = useRef(null) + const timersRef = useRef[]>([]) + const intervalsRef = useRef([]) + const lastComposeUrlRef = useRef("") + const animatingRef = useRef(false) + const [error, setError] = useState(false) + const reducedMotion = useRef( + typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) + + const cleanup = useCallback(() => { + timersRef.current.forEach(clearTimeout) + timersRef.current = [] + intervalsRef.current.forEach(clearInterval) + intervalsRef.current = [] + }, []) + + useEffect(() => () => cleanup(), [cleanup]) + + // Track latest props in refs so the interval can read them without re-triggering + const composeUrlRef = useRef(composeUrl) + const defsUrlRef = useRef(defsUrl) + const skipRef = useRef(skipAnimation) + composeUrlRef.current = composeUrl + defsUrlRef.current = defsUrl + skipRef.current = skipAnimation + + useEffect(() => { + let cancelled = false + + function check() { + const compUrlBase = composeUrlRef.current.split("?")[0] + if (compUrlBase === lastComposeUrlRef.current) return + if (animatingRef.current) return // defer until animation completes + lastComposeUrlRef.current = compUrlBase + + ;(async () => { + try { + const [defsResp, compResp] = await Promise.all([fetch(defsUrlRef.current), fetch(composeUrlRef.current)]) + if (cancelled || !defsResp.ok || !compResp.ok) { setError(true); return } + + const defsData: DefsData = await defsResp.json() + const data: ComposeData = await compResp.json() + + if (defsData.version !== COMPOSE_VERSION || data.version !== COMPOSE_VERSION) { + setError(true); return + } + + const container = containerRef.current + if (!container || cancelled) return + + cleanup() + + const animTargets = new Set() + if (!skipRef.current) { + data.components.forEach((comp, i) => { + if (comp.changed) animTargets.add(i) + }) + } + + + if (animTargets.size > 0) { + onAnimate?.() + animatingRef.current = true + } + + // --- Build SVG --- + const vb = data.viewBox.split(" ").map(Number) + container.innerHTML = "" + container.parentElement?.querySelectorAll(".asp-overlay").forEach(el => el.remove()) + + const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg") + svgEl.setAttribute("viewBox", data.viewBox) + svgEl.setAttribute("preserveAspectRatio", "xMidYMid") + svgEl.style.width = "100%" + svgEl.style.height = "100%" + + // Background + if (data.bgSvg) { + const g = document.createElementNS("http://www.w3.org/2000/svg", "g") + g.innerHTML = data.bgSvg + svgEl.appendChild(g) + } else { + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect") + rect.setAttribute("width", String(vb[2])) + rect.setAttribute("height", String(vb[3])) + rect.setAttribute("fill", data.bgFill || "#000") + svgEl.appendChild(rect) + } + + // Defs + const defsG = document.createElementNS("http://www.w3.org/2000/svg", "g") + defsG.innerHTML = defsData.defs + while (defsG.firstChild) svgEl.appendChild(defsG.firstChild) + + // Components + data.components.forEach((comp, i) => { + const g = document.createElementNS("http://www.w3.org/2000/svg", "g") + g.innerHTML = comp.svg + g.dataset.index = String(i) + g.style.opacity = (animTargets.has(i) && !reducedMotion.current) ? "0" : "1" + svgEl.appendChild(g) + }) + + container.appendChild(svgEl) + + if (reducedMotion.current || animTargets.size === 0) { + onComplete?.() + return + } + + // --- Animate changed components --- + const overlayContainer = document.createElement("div") + overlayContainer.className = "asp-overlay absolute inset-0 pointer-events-none" + container.parentElement?.appendChild(overlayContainer) + + let staggerIdx = 0 + data.components.forEach((comp, i) => { + if (!animTargets.has(i) || !comp.bbox) return + const si = staggerIdx++ + const agent = assignAgent(comp) + + const pctL = (comp.bbox.x / vb[2]) * 100 + const pctT = (comp.bbox.y / vb[3]) * 100 + const pctW = (comp.bbox.w / vb[2]) * 100 + const pctH = (comp.bbox.h / vb[3]) * 100 + + const t1 = setTimeout(() => { + if (cancelled) return + const cursor = document.createElement("div") + cursor.className = "absolute transition-all duration-300" + cursor.style.cssText = `left:${pctL}%;top:${Math.max(0, pctT - 5)}%;opacity:0;z-index:20;` + cursor.innerHTML = `${agent.name}` + overlayContainer.appendChild(cursor) + requestAnimationFrame(() => { + cursor.style.opacity = "1" + cursor.style.left = `${pctL}%` + cursor.style.top = `${pctT}%` + }) + + const t2 = setTimeout(() => { + if (cancelled) return + const wf = document.createElement("div") + wf.className = "absolute" + wf.style.cssText = `left:${pctL}%;top:${pctT}%;width:${pctW}%;height:${pctH}%;border:1px solid ${agent.color};border-radius:2px;box-shadow:inset 0 0 16px ${agent.glow};opacity:1;clip-path:inset(0 100% 100% 0);animation:asp-wf-drag 0.35s cubic-bezier(0.16,1,0.3,1) forwards;` + overlayContainer.appendChild(wf) + + const endL = ((comp.bbox!.x + comp.bbox!.w) / vb[2]) * 100 + const endT = ((comp.bbox!.y + comp.bbox!.h) / vb[3]) * 100 + cursor.style.left = `${endL}%` + cursor.style.top = `${endT}%` + + const t3 = setTimeout(() => { + if (cancelled) return + const g = svgEl.querySelector(`g[data-index="${i}"]`) as SVGGElement | null + if (g) { + g.style.opacity = "1" + g.style.filter = "brightness(2) saturate(0.5)" + g.style.transition = "filter 0.5s cubic-bezier(0.16,1,0.3,1)" + requestAnimationFrame(() => { g.style.filter = "brightness(1) saturate(1)" }) + typewrite(g) + } + const t4 = setTimeout(() => { + wf.style.transition = "opacity 0.4s ease-out" + wf.style.opacity = "0" + cursor.style.transition = "opacity 0.4s ease-out" + cursor.style.opacity = "0" + }, 500) + timersRef.current.push(t4) + }, WIREFRAME_LEAD_MS - 50) + timersRef.current.push(t3) + }, 250) + timersRef.current.push(t2) + }, si * STAGGER_MS) + timersRef.current.push(t1) + }) + + const totalTime = staggerIdx * STAGGER_MS + WIREFRAME_LEAD_MS + 1000 + const tDone = setTimeout(() => { + animatingRef.current = false + overlayContainer.remove() + onComplete?.() + // Check if a new composeUrl arrived during animation + check() + }, totalTime) + timersRef.current.push(tDone) + } catch { + setError(true) + } + })() + } + + check() + const iv = window.setInterval(check, 1000) + return () => { cancelled = true; clearInterval(iv); cleanup() } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slideId]) + + if (error && fallback) return <>{fallback} + + return ( +
+
+
+ ) +} + +function typewrite(compEl: SVGGElement) { + const tspans = compEl.querySelectorAll("tspan") + const leafSpans: { el: Element; fullText: string; saved: Record }[] = [] + let totalChars = 0 + tspans.forEach(ts => { + if (ts.querySelectorAll("tspan").length === 0 && ts.textContent) { + const saved: Record = {} + for (const attr of ["textLength", "lengthAdjust"]) { + if (ts.hasAttribute(attr)) { + saved[attr] = ts.getAttribute(attr)! + ts.removeAttribute(attr) + } + } + totalChars += ts.textContent.length + leafSpans.push({ el: ts, fullText: ts.textContent, saved }) + ts.textContent = "" + } + }) + if (!leafSpans.length) return + const charMs = Math.max(MIN_CHAR_MS, Math.min(MAX_CHAR_MS, Math.floor(TYPE_DURATION_MS / totalChars))) + let spanIdx = 0, charIdx = 0 + const iv = window.setInterval(() => { + if (spanIdx >= leafSpans.length) { clearInterval(iv); return } + const span = leafSpans[spanIdx] + charIdx++ + span.el.textContent = span.fullText.slice(0, charIdx) + if (charIdx >= span.fullText.length) { + for (const [a, v] of Object.entries(span.saved)) span.el.setAttribute(a, v) + spanIdx++ + charIdx = 0 + } + }, charMs) +} diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 2ea1bbc..973f307 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -9,7 +9,7 @@ "use client" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useCallback } from "react" import { SlidePreview, getDeckWithJson } from "@/services/deckService" import type { SpecFiles } from "@/services/deckService" import { Download, FileJson, Layers, Loader2, LayoutGrid, Rows3 } from "lucide-react" @@ -18,9 +18,11 @@ import { usePreferences } from "@/hooks/usePreferences" import { SpecStepNav, SpecMarkdownPreview } from "@/components/deck/SpecStepNav" import type { SpecTab } from "@/components/deck/SpecStepNav" import { SlideThumbnail } from "@/components/deck/SlideThumbnail" +import { AnimatedSlidePreview } from "@/components/deck/AnimatedSlidePreview" interface SlideCarouselProps { slides: SlidePreview[] + defsUrl?: string | null deckId?: string deckName?: string pptxUrl?: string | null @@ -44,13 +46,54 @@ interface SlideCarouselProps { idToken?: string } -export function SlideCarousel({ slides, deckId, deckName, pptxUrl, isLoading, onSlideClick, scrollToSlide, onScrollComplete, headerActions, ownerAlias, specs, workflowPhase, onStyleSelect, idToken }: SlideCarouselProps) { - const slidesWithPreview = slides.filter((s) => s.previewUrl) +export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLoading, onSlideClick, scrollToSlide, onScrollComplete, headerActions, ownerAlias, specs, workflowPhase, onStyleSelect, idToken }: SlideCarouselProps) { + const slidesWithPreview = slides.filter((s) => s.previewUrl || s.composeUrl) const auth = useAuth() const [jsonLoading, setJsonLoading] = useState(false) const { viewMode, setViewMode } = usePreferences() const containerRef = useRef(null) + /* ── Compose update detection → auto-scroll to changed slide ── */ + const prevComposeKeys = useRef>(new Map()) + const scrollTargetRef = useRef(undefined) + // If deck opened with slides → existing deck → first compose is instant + const hadSlidesOnMount = useRef(slides.length > 0) + const firstComposeSeenRef = useRef(false) + + useEffect(() => { + let anyChanged = false + for (const slide of slides) { + const key = slide.composeUrl?.split("?")[0] || "" + const prev = prevComposeKeys.current.get(slide.slideId) || "" + if (key && prev && key !== prev) anyChanged = true + if (key && !prev && firstComposeSeenRef.current) anyChanged = true + if (key) prevComposeKeys.current.set(slide.slideId, key) + } + // Mark first compose seen (skip animation for existing decks) + if (!firstComposeSeenRef.current && slides.some(s => s.composeUrl)) { + if (hadSlidesOnMount.current) { + // Existing deck: suppress animation for this first batch + anyChanged = false + } + firstComposeSeenRef.current = true + } + if (anyChanged) scrollTargetRef.current = null // arm scroll for next onAnimate + }, [slides]) + + const handleAnimate = useCallback((slideId: string) => { + if (scrollTargetRef.current === null && containerRef.current) { + scrollTargetRef.current = slideId + const el = containerRef.current.querySelector(`[data-slide-id="${slideId}"]`) + if (el) { + const container = containerRef.current + const elRect = el.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const offset = elRect.top - containerRect.top + container.scrollTop - 24 + container.scrollTo({ top: offset, behavior: "smooth" }) + } + } + }, []) + /* ── Slide update detection for glow highlight ── */ const prevUrlKeys = useRef>(new Map()) const [updatedIds, setUpdatedIds] = useState>(new Set()) @@ -294,16 +337,37 @@ export function SlideCarousel({ slides, deckId, deckName, pptxUrl, isLoading, on
) : ( slidesWithPreview.map((slide, i) => ( - onSlideClick?.(i + 1)} - updated={updatedIds.has(slide.slideId)} - className="slide-shadow w-full cursor-pointer hover:ring-2 hover:ring-primary/50 transition-shadow" - /> + slide.composeUrl && defsUrl ? ( + handleAnimate(slide.slideId)} + fallback={ + onSlideClick?.(i + 1)} + className="slide-shadow w-full cursor-pointer hover:ring-2 hover:ring-primary/50 transition-shadow" + /> + } + /> + ) : ( + onSlideClick?.(i + 1)} + updated={updatedIds.has(slide.slideId)} + className="slide-shadow w-full cursor-pointer hover:ring-2 hover:ring-primary/50 transition-shadow" + /> + ) )) )} diff --git a/web-ui/src/hooks/useWorkspace.ts b/web-ui/src/hooks/useWorkspace.ts index 3f89872..6fb559d 100644 --- a/web-ui/src/hooks/useWorkspace.ts +++ b/web-ui/src/hooks/useWorkspace.ts @@ -97,13 +97,16 @@ export function useWorkspace( const INTERVALS = [1000, 2000, 4000, 6000] let step = 0 + let cancelled = false + async function poll() { - if (!idToken || !deckIdToLoad || deckIdToLoad === "__polling__") { - scheduleNext() + if (cancelled || !idToken || !deckIdToLoad || deckIdToLoad === "__polling__") { + if (!cancelled) scheduleNext() return } try { const data = await getDeck(deckIdToLoad, idToken) + if (cancelled) return // Detect slide changes (added/removed/preview updated) const slideKey = data.slides.map((s) => { const base = s.previewUrl?.split("?")[0] || "" @@ -129,12 +132,35 @@ export function useWorkspace( } else { stablePreviewUrls.current.delete(s.slideId) } + // Stabilise composeUrl with same pattern as previewUrl + if (s.composeUrl) { + const cacheKey = `${s.slideId}:compose` + const cached = stablePreviewUrls.current.get(cacheKey) + const base = s.composeUrl.split("?")[0] + const cachedBase = cached?.url.split("?")[0] || "" + if (base !== cachedBase) { + stablePreviewUrls.current.set(cacheKey, { url: s.composeUrl }) + } else if (cached) { + s.composeUrl = cached.url + } + } + } + // Stabilise defsUrl + if (data.defsUrl) { + const cached = stablePreviewUrls.current.get("__defs__") + const base = data.defsUrl.split("?")[0] + const cachedBase = cached?.url.split("?")[0] || "" + if (base !== cachedBase) { + stablePreviewUrls.current.set("__defs__", { url: data.defsUrl }) + } else if (cached) { + data.defsUrl = cached.url + } } setDeck(data) } catch { // Deck may not exist yet } - scheduleNext() + if (!cancelled) scheduleNext() } function scheduleNext() { @@ -150,6 +176,7 @@ export function useWorkspace( } return () => { + cancelled = true if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current) } }, [isAuthenticated, idToken, activeDeckId, createdDeckId]) @@ -182,7 +209,7 @@ export function useWorkspace( const isNew = activeDeckId === "new" const isOwner = deck?.role === "owner" || deck?.role === undefined const canChat = isOwner || isNew - const hasSlides = deck && deck.slides.some((s) => s.previewUrl) + const hasSlides = deck && deck.slides.some((s) => s.previewUrl || s.composeUrl) const waitingForPng = pptxRequested // Reset flag once previews change after generate_pptx diff --git a/web-ui/src/services/deckService.ts b/web-ui/src/services/deckService.ts index 55f176d..1f9e64c 100644 --- a/web-ui/src/services/deckService.ts +++ b/web-ui/src/services/deckService.ts @@ -19,6 +19,7 @@ export interface DeckSummary { export interface SlidePreview { slideId: string previewUrl: string | null + composeUrl?: string | null updatedAt: string slideJson?: string } @@ -34,6 +35,7 @@ export interface DeckDetail { name: string slideOrder: string[] slides: SlidePreview[] + defsUrl?: string | null pptxUrl: string | null specs?: SpecFiles | null updatedAt: string