diff --git a/CLAUDE.md b/CLAUDE.md index 6c3b68a8..c2ea3113 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1317,6 +1317,204 @@ Context about the task. | Standard | 15-25 | 150,000 | 600 | | Complex | 30-50 | 300,000 | 1800 | +### Workflow Pages (Integrated Editor Views) + +Workflow pages define interactive views that open from workflow steps in the web UI. +All media panels follow the **agent-as-editor model**: the UI is a viewer with +lightweight intent-capture controls. Editing requests are dispatched to the Claude +Code agent, which performs the actual file modifications (editing Motion Canvas +`.tsx` scenes, running `ffmpeg` commands, using ImageMagick/Pillow for images). + +Pages are defined in the `pages` array in workflow frontmatter (YAML/TOML) and +displayed as clickable buttons on workflow steps. + +#### Page Types + +| Type | Component | Use Case | +|------|-----------|----------| +| `data-table` | Editable spreadsheet | Seed data, extracted results, configuration | +| `image` | Image viewer + intent toolbar | Generated images, charts, screenshots | +| `motion-canvas` | Rendered MC output viewer + intent toolbar | Animated graphics, data visualizations | +| `video` | Video player with trim/cut + intent toolbar | Individual video files | +| `video-sequence` | Multi-scene composition timeline | Composed videos (MC scenes + clips) | + +#### Architecture: Agent-as-Editor + +``` +User clicks "Add Text" --> UI captures intent (what, where) --> +dispatches to agent --> agent edits .tsx / runs ffmpeg / uses Pillow --> +file changes --> panel polls mtime --> auto-refresh preview +``` + +Panels never modify files directly. The **IntentToolbar** (shared across image, +motion-canvas, video, and video-sequence panels) captures structured intents: + +| Control | Intent Dispatched | +|---------|-------------------| +| Add Text | `{ action: "add_text", text, position, style: { font, size, color } }` | +| Add Shape | `{ action: "add_shape", shape_id, position, size, animated }` | +| Move | `{ action: "move_element", element_id, new_position }` | +| Resize | `{ action: "resize_element", element_id, new_size }` | +| Delete | `{ action: "delete_element", element_id }` | + +Intents are converted to natural language prompts server-side and dispatched to +the agent via `POST /api/workflows/{id}/pages/{page_id}/edit`. + +#### Video Composition with Motion Canvas + +The `video-sequence` page type uses Motion Canvas as the composition layer. +Multiple scenes (MC animations + video clips) compose into a single video: + +```yaml +pages: + - id: final-video + type: video-sequence + title: Final Presentation + step: compose_video + output_path: output/final.mp4 + resolution: [1920, 1080] + scenes: + - id: intro + type: motion-canvas + title: Animated Intro + scene_path: scenes/intro.tsx + rendered_path: output/intro.mp4 + - id: interview + type: clip + title: Interview Footage + source_path: footage/interview.mp4 + trim_start: 5.0 + trim_end: 30.0 +``` + +#### Defining Pages in Markdown + +```yaml +--- +name: topic-research +title: Topic Research Workflow +agent: + model: claude-sonnet-4-20250514 + max_turns: 25 + +pages: + - id: seed-topics + type: data-table + title: Seed Topics + step: research_topics + seed: true + data_path: data/topics.csv + columns: + - name: topic + label: Topic + type: text + required: true + - name: priority + label: Priority + type: select + options: [high, medium, low] + + - id: output-chart + type: image + title: Research Chart + step: generate_chart + image_path: output/research-chart.png + editable: true + assets_dir: assets + + - id: summary-animation + type: motion-canvas + title: Summary Animation + step: create_animation + scene_path: scenes/summary.tsx + output_path: output/summary.mp4 + duration: 10 + fps: 30 + + - id: presentation-video + type: video + title: Presentation + step: render_video + video_path: output/presentation.mp4 + trim: true + max_duration: 120 +--- +``` + +#### Seed Data Tables + +When a page has `seed: true`, it acts as input data for the workflow: + +1. User opens the seed data table from the workflow step +2. Adds/edits rows (e.g., adding new research topics) +3. Clicks **Save** to persist changes to `data_path` +4. UI prompts: "Seed data updated. Run the workflow with new data?" +5. Clicking **Run Workflow** starts a new workflow execution with the updated data + +#### Column Types + +| Type | Input | Description | +|------|-------|-------------| +| `text` | Text input | Default, free-form text | +| `number` | Number input | Numeric values | +| `boolean` | Checkbox | True/false toggle | +| `url` | Text input | URL values | +| `date` | Text input | Date strings | +| `select` | Dropdown | Choose from `options` list | + +#### File Refresh After Agent Edits + +All media panels use the `useFileWatch` hook which polls the page data endpoint +every 2 seconds. When the file's `mtime` changes, the panel auto-refreshes the +preview. Cache-busting is handled via `?v=` query parameters on +file URLs. + +#### API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/workflows/{id}/pages` | GET | List pages for a workflow | +| `/api/workflows/{id}/pages/{page_id}/data` | GET | Get page data (table rows, file metadata with mtime) | +| `/api/workflows/{id}/pages/{page_id}/data` | PUT | Update data-table rows | +| `/api/workflows/{id}/pages/{page_id}/run` | POST | Re-run workflow from seed data | +| `/api/workflows/{id}/pages/{page_id}/edit` | POST | Dispatch editing intents to agent | +| `/api/file/raw?path=` | GET | Serve raw media files (video, image, font, SVG) | +| `/api/assets/shapes` | GET | Get SVG shape library manifest | +| `/api/assets/fonts` | GET | Get custom font library manifest | + +#### Custom SVG Shapes + +Store SVG shapes in `assets/shapes/` with a `manifest.json`: + +```json +{ + "shapes": [ + { + "id": "arrow-expand", + "category": "arrows", + "title": "Expanding Arrow", + "svg_path": "arrows/arrow-expand.svg", + "animated": true + } + ] +} +``` + +#### Custom Fonts + +Store fonts in `assets/fonts/` with a `manifest.json`: + +```json +{ + "fonts": [ + { "id": "montserrat-bold", "family": "Montserrat", "weight": 700, "path": "Montserrat-Bold.woff2" } + ] +} +``` + +Panels load fonts dynamically via the Font Loading API. The agent references +fonts by family name in MC scenes or by file path in ffmpeg/ImageMagick commands. + --- ## Workflow Observability API diff --git a/src/kurt/web/api/intent_dispatch.py b/src/kurt/web/api/intent_dispatch.py new file mode 100644 index 00000000..a651b0b7 --- /dev/null +++ b/src/kurt/web/api/intent_dispatch.py @@ -0,0 +1,183 @@ +"""Intent-to-prompt conversion and dispatch for workflow page editing. + +Converts structured editing intents from the UI into natural language prompts +for the Claude Code agent. Supports two dispatch modes: +- Chat dispatch: inject into existing Claude WebSocket session +- Background dispatch: start a new agent workflow execution +""" + +from __future__ import annotations + +from typing import Any, Optional + + +def build_edit_prompt(page: dict, intents: list[dict]) -> str: + """Convert structured editing intents into a natural language prompt. + + Args: + page: Page config dict (type, scene_path, video_path, image_path, etc.) + intents: List of intent dicts with action, position, text, etc. + + Returns: + Natural language prompt string for the agent. + """ + page_type = page.get("type", "") + target_file = _get_target_file(page) + + lines = [] + lines.append(f"Edit the file at `{target_file}`:") + lines.append("") + + for intent in intents: + line = _format_intent(intent, page_type) + if line: + lines.append(f"- {line}") + + lines.append("") + + # Add type-specific context + if page_type == "motion-canvas": + lines.append("This is a Motion Canvas scene file (.tsx). Use Motion Canvas 2D API.") + lines.append("Import shapes from '@motion-canvas/2d' and use generator functions for animations.") + assets_dir = page.get("assets_dir") + if assets_dir: + lines.append(f"Custom assets (shapes, fonts) are in `{assets_dir}/`.") + elif page_type == "video": + lines.append("Use ffmpeg CLI commands to apply the edits to the video file.") + lines.append("Preserve the original file as a backup before modifying.") + elif page_type == "video-sequence": + lines.append("This is a video sequence project using Motion Canvas as the composition layer.") + lines.append("Each scene is a .tsx file. Video clips are wrapped as MC Video elements.") + elif page_type == "image": + lines.append("Use ImageMagick or Python Pillow to apply the edits to the image file.") + lines.append("Preserve the original file as a backup before modifying.") + assets_dir = page.get("assets_dir") + if assets_dir: + lines.append(f"Custom fonts are in `{assets_dir}/fonts/` and shapes in `{assets_dir}/shapes/`.") + + return "\n".join(lines) + + +def _get_target_file(page: dict) -> str: + """Get the primary target file path for a page type.""" + page_type = page.get("type", "") + if page_type == "motion-canvas": + return page.get("scene_path", "scene.tsx") + elif page_type == "video": + return page.get("video_path", "video.mp4") + elif page_type == "video-sequence": + return page.get("output_path", "output/final.mp4") + elif page_type == "image": + return page.get("image_path", "image.png") + return "unknown" + + +def _format_intent(intent: dict, page_type: str) -> str: + """Format a single intent into a human-readable instruction.""" + action = intent.get("action", "") + + if action == "add_text": + text = intent.get("text", "") + pos = intent.get("position", {}) + style = intent.get("style", {}) + parts = [f"Add text '{text}'"] + if pos: + parts.append(f"at position ({pos.get('x', 0)}, {pos.get('y', 0)})") + if style.get("font"): + parts.append(f"using font '{style['font']}'") + if style.get("size"): + parts.append(f"size {style['size']}") + if style.get("color"): + parts.append(f"color {style['color']}") + time_range = intent.get("time_range") + if time_range and page_type in ("video", "motion-canvas", "video-sequence"): + parts.append(f"visible from {time_range.get('start', 0)}s to {time_range.get('end', 0)}s") + return " ".join(parts) + + elif action == "add_shape": + shape_id = intent.get("shape_id", "shape") + pos = intent.get("position", {}) + size = intent.get("size", {}) + animated = intent.get("animated", False) + parts = [f"Add SVG shape '{shape_id}'"] + if pos: + parts.append(f"at ({pos.get('x', 0)}, {pos.get('y', 0)})") + if size: + parts.append(f"size {size.get('width', 100)}x{size.get('height', 100)}") + if animated: + parts.append("with entrance animation") + return " ".join(parts) + + elif action == "move_element": + element_id = intent.get("element_id", "element") + pos = intent.get("position", {}) + return f"Move element '{element_id}' to ({pos.get('x', 0)}, {pos.get('y', 0)})" + + elif action == "resize_element": + element_id = intent.get("element_id", "element") + size = intent.get("size", {}) + return f"Resize element '{element_id}' to {size.get('width', 100)}x{size.get('height', 100)}" + + elif action == "delete_element": + element_id = intent.get("element_id", "element") + return f"Delete element '{element_id}'" + + elif action == "trim": + time_range = intent.get("time_range", {}) + return f"Trim to {time_range.get('start', 0)}s - {time_range.get('end', 0)}s" + + elif action == "cut": + time_range = intent.get("time_range", {}) + return f"Cut segment from {time_range.get('start', 0)}s to {time_range.get('end', 0)}s" + + return f"Unknown action: {action}" + + +def dispatch_edit( + workflow_id: str, + page: dict, + prompt: str, + session_id: Optional[str] = None, +) -> dict[str, Any]: + """Dispatch an editing prompt to the agent. + + Args: + workflow_id: DBOS workflow ID + page: Page config dict + prompt: Natural language editing prompt + session_id: If provided, inject into existing Claude session + + Returns: + dict with dispatch result (status, workflow_id or session info) + """ + if session_id: + # Mode A: Chat dispatch - inject into existing session + # This would use the StreamSession to send a message + return { + "status": "dispatched", + "mode": "chat", + "session_id": session_id, + "prompt": prompt, + } + else: + # Mode B: Background dispatch - start new agent workflow + try: + from kurt.workflows.agents import run_definition + from kurt.workflows.agents.registry import get_definition_for_workflow + + definition = get_definition_for_workflow(workflow_id) + if not definition: + return {"status": "error", "detail": "Workflow definition not found"} + + result = run_definition( + definition["name"], + inputs={"task": prompt}, + background=True, + ) + return { + "status": "started", + "mode": "background", + "workflow_id": result.get("workflow_id"), + } + except Exception as e: + return {"status": "error", "detail": str(e)} diff --git a/src/kurt/web/api/routes/files.py b/src/kurt/web/api/routes/files.py index 11b18061..dbef4825 100644 --- a/src/kurt/web/api/routes/files.py +++ b/src/kurt/web/api/routes/files.py @@ -1,4 +1,4 @@ -"""File and git routes: tree, file CRUD, git diff/status/show.""" +"""File and git routes: tree, file CRUD, git diff/status/show, raw media serving.""" from __future__ import annotations @@ -9,6 +9,7 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse from pydantic import BaseModel from kurt.web.api.server_helpers import get_storage @@ -167,6 +168,49 @@ def _git_status() -> dict[str, str]: # --- Endpoints --- +@router.get("/api/file/raw") +def api_get_file_raw(path: str = Query(...)): + """Serve raw file content (video, image, audio, font) with proper MIME type. + + Used by video/image/motion-canvas panels to load media files. + Supports cache-busting via ?v= query parameter. + """ + import mimetypes + + try: + # Resolve relative to project root, prevent path traversal + file_path = Path(path) + if file_path.is_absolute(): + resolved = file_path.resolve() + else: + resolved = (Path.cwd() / file_path).resolve() + + # Guard against path traversal + cwd_resolved = Path.cwd().resolve() + if not str(resolved).startswith(str(cwd_resolved)): + raise HTTPException(status_code=403, detail="Path traversal not allowed") + + if not resolved.exists(): + raise HTTPException(status_code=404, detail="File not found") + if not resolved.is_file(): + raise HTTPException(status_code=400, detail="Not a file") + + # Detect MIME type + mime_type, _ = mimetypes.guess_type(str(resolved)) + if not mime_type: + mime_type = "application/octet-stream" + + return FileResponse( + path=str(resolved), + media_type=mime_type, + filename=resolved.name, + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/api/tree") def api_tree(path: Optional[str] = Query(".")): try: diff --git a/src/kurt/web/api/routes/pages.py b/src/kurt/web/api/routes/pages.py new file mode 100644 index 00000000..c4bea8f5 --- /dev/null +++ b/src/kurt/web/api/routes/pages.py @@ -0,0 +1,388 @@ +"""Workflow page routes: page data, asset libraries, edit dispatch.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +router = APIRouter() + + +# --- Pydantic models --- + + +class PageDataUpdate(BaseModel): + rows: list[dict[str, Any]] = [] + + +class EditIntentAction(BaseModel): + """A single editing intent from the UI.""" + + action: str # add_text, add_shape, move_element, resize_element, delete_element, trim, cut + text: Optional[str] = None + shape_id: Optional[str] = None + element_id: Optional[str] = None + position: Optional[dict[str, float]] = None # {x, y} + size: Optional[dict[str, float]] = None # {width, height} + style: Optional[dict[str, Any]] = None # {font, size, color, ...} + time_range: Optional[dict[str, float]] = None # {start, end} for video overlays + animated: bool = False + + +class EditIntentRequest(BaseModel): + """Request body for dispatching editing intents to the agent.""" + + intents: list[EditIntentAction] = [] + session_id: Optional[str] = None # If provided, use existing Claude session + + +# --- Helper functions --- + + +def _get_file_mtime(file_path: Optional[Path]) -> Optional[float]: + """Get file modification time as Unix timestamp, or None if file doesn't exist.""" + if file_path and file_path.exists(): + return file_path.stat().st_mtime + return None + + +def _read_table_data(page: dict, offset: int, limit: int) -> dict: + """Read data from a table page's data_path file.""" + data_path = page.get("data_path") + if not data_path: + return {"columns": page.get("columns", []), "rows": [], "total": 0} + + file_path = Path(data_path) + if not file_path.exists(): + return {"columns": page.get("columns", []), "rows": [], "total": 0} + + suffix = file_path.suffix.lower() + rows = [] + + if suffix == ".json": + with open(file_path) as f: + data = json.load(f) + if isinstance(data, list): + rows = data + elif isinstance(data, dict) and "rows" in data: + rows = data["rows"] + elif suffix == ".csv": + import csv + + with open(file_path, newline="") as f: + reader = csv.DictReader(f) + rows = list(reader) + else: + return {"columns": page.get("columns", []), "rows": [], "total": 0, "error": f"Unsupported format: {suffix}"} + + total = len(rows) + paginated = rows[offset : offset + limit] + + # Auto-detect columns if not specified + columns = page.get("columns", []) + if not columns and paginated: + columns = [{"name": k, "label": k, "type": "text", "editable": True} for k in paginated[0].keys()] + + return { + "columns": columns, + "rows": paginated, + "total": total, + "offset": offset, + "limit": limit, + "seed": page.get("seed", False), + } + + +def _read_image_data(page: dict) -> dict: + """Read metadata for an image page.""" + image_path = page.get("image_path", "") + file_path = Path(image_path) if image_path else None + exists = file_path.exists() if file_path else False + + return { + "type": "image", + "image_path": image_path, + "exists": exists, + "editable": page.get("editable", False), + "title": page.get("title", "Image"), + "mtime": _get_file_mtime(file_path), + } + + +def _read_video_data(page: dict) -> dict: + """Read metadata for a video page.""" + video_path = page.get("video_path", "") + file_path = Path(video_path) if video_path else None + exists = file_path.exists() if file_path else False + + return { + "type": "video", + "video_path": video_path, + "exists": exists, + "trim": page.get("trim", True), + "max_duration": page.get("max_duration"), + "title": page.get("title", "Video"), + "mtime": _get_file_mtime(file_path), + } + + +def _read_motion_canvas_data(page: dict) -> dict: + """Read metadata for a motion canvas page.""" + scene_path = page.get("scene_path", "") + output_path = page.get("output_path", "") + file_path = Path(scene_path) if scene_path else None + output_file = Path(output_path) if output_path else None + exists = file_path.exists() if file_path else False + output_exists = output_file.exists() if output_file else False + + return { + "type": "motion-canvas", + "scene_path": scene_path, + "output_path": output_path, + "exists": exists, + "output_exists": output_exists, + "preview_url": page.get("preview_url"), + "duration": page.get("duration"), + "fps": page.get("fps", 30), + "editable": page.get("editable", False), + "title": page.get("title", "Motion Canvas"), + "mtime": _get_file_mtime(output_file or file_path), + } + + +def _read_video_sequence_data(page: dict) -> dict: + """Read metadata for a video sequence (MC composition) page.""" + output_path = page.get("output_path", "") + output_file = Path(output_path) if output_path else None + output_exists = output_file.exists() if output_file else False + scenes = page.get("scenes", []) + + # Check which scenes have rendered output + scene_status = [] + for scene in scenes: + s = dict(scene) + if scene.get("type") == "motion-canvas": + rendered = Path(scene.get("rendered_path", "")) if scene.get("rendered_path") else None + s["rendered_exists"] = rendered.exists() if rendered else False + elif scene.get("type") == "clip": + source = Path(scene.get("source_path", "")) if scene.get("source_path") else None + s["source_exists"] = source.exists() if source else False + scene_status.append(s) + + return { + "type": "video-sequence", + "output_path": output_path, + "output_exists": output_exists, + "scenes": scene_status, + "transitions": page.get("transitions", []), + "resolution": page.get("resolution", [1920, 1080]), + "title": page.get("title", "Video Sequence"), + "mtime": _get_file_mtime(output_file), + } + + +# --- Page Endpoints --- + + +@router.get("/api/workflows/{workflow_id}/pages") +def api_get_workflow_pages(workflow_id: str): + """Get page definitions for a workflow (from workflow definition file).""" + try: + from kurt.workflows.agents.registry import find_definition_by_workflow_id + + pages = find_definition_by_workflow_id(workflow_id) + return {"pages": pages} + except Exception: + # Workflow may not have pages or registry may not be available + return {"pages": []} + + +@router.get("/api/workflows/{workflow_id}/pages/{page_id}/data") +def api_get_page_data( + workflow_id: str, + page_id: str, + offset: int = Query(0, ge=0), + limit: int = Query(100, le=1000), +): + """Get data for a specific workflow page. + + For data-table pages, reads from the data_path file (CSV/JSON). + For image/video/motion-canvas pages, returns file metadata. + """ + try: + from kurt.workflows.agents.registry import get_page_config + + page = get_page_config(workflow_id, page_id) + if not page: + raise HTTPException(status_code=404, detail=f"Page '{page_id}' not found") + + if page["type"] == "data-table": + return _read_table_data(page, offset, limit) + elif page["type"] == "image": + return _read_image_data(page) + elif page["type"] == "video": + return _read_video_data(page) + elif page["type"] == "motion-canvas": + return _read_motion_canvas_data(page) + elif page["type"] == "video-sequence": + return _read_video_sequence_data(page) + else: + return {"page": page, "data": None} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/api/workflows/{workflow_id}/pages/{page_id}/data") +def api_update_page_data( + workflow_id: str, + page_id: str, + body: PageDataUpdate, +): + """Update data for a data-table page. Writes back to the data_path file.""" + try: + from kurt.workflows.agents.registry import get_page_config + + page = get_page_config(workflow_id, page_id) + if not page: + raise HTTPException(status_code=404, detail=f"Page '{page_id}' not found") + + if page["type"] != "data-table": + raise HTTPException(status_code=400, detail="Only data-table pages support data updates") + + data_path = page.get("data_path") + if not data_path: + raise HTTPException(status_code=400, detail="Page has no data_path configured") + + file_path = Path(data_path) + suffix = file_path.suffix.lower() + + if suffix == ".json": + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w") as f: + json.dump(body.rows, f, indent=2) + elif suffix == ".csv": + import csv + + file_path.parent.mkdir(parents=True, exist_ok=True) + if body.rows: + fieldnames = list(body.rows[0].keys()) + with open(file_path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(body.rows) + else: + file_path.write_text("") + else: + raise HTTPException(status_code=400, detail=f"Unsupported data format: {suffix}") + + return {"status": "ok", "rows_written": len(body.rows)} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/workflows/{workflow_id}/pages/{page_id}/run") +def api_run_workflow_from_page( + workflow_id: str, + page_id: str, +): + """Trigger a workflow re-run after seed data has been edited.""" + try: + from kurt.workflows.agents.registry import get_definition_for_workflow, get_page_config + + page = get_page_config(workflow_id, page_id) + if not page: + raise HTTPException(status_code=404, detail=f"Page '{page_id}' not found") + + if not page.get("seed"): + raise HTTPException(status_code=400, detail="Page is not a seed data page") + + definition = get_definition_for_workflow(workflow_id) + if not definition: + raise HTTPException(status_code=404, detail="Workflow definition not found") + + from kurt.workflows.agents import run_definition + + result = run_definition(definition["name"], background=True) + return {"status": "started", "workflow_id": result.get("workflow_id")} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/workflows/{workflow_id}/pages/{page_id}/edit") +def api_dispatch_page_edit( + workflow_id: str, + page_id: str, + body: EditIntentRequest, +): + """Dispatch editing intents to the agent for a workflow page. + + Converts structured intents into a natural language prompt and either: + - Injects into an existing Claude session (if session_id provided) + - Starts a new background agent workflow + """ + try: + from kurt.workflows.agents.registry import get_page_config + + page = get_page_config(workflow_id, page_id) + if not page: + raise HTTPException(status_code=404, detail=f"Page '{page_id}' not found") + + from kurt.web.api.intent_dispatch import build_edit_prompt, dispatch_edit + + prompt = build_edit_prompt(page, [i.model_dump() for i in body.intents]) + result = dispatch_edit( + workflow_id=workflow_id, + page=page, + prompt=prompt, + session_id=body.session_id, + ) + return result + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# --- Asset Library Endpoints --- + + +@router.get("/api/assets/shapes") +def api_get_shapes(): + """Get shape library manifest from assets/shapes/manifest.json.""" + manifest_path = Path.cwd() / "assets" / "shapes" / "manifest.json" + if not manifest_path.exists(): + return {"shapes": []} + try: + with open(manifest_path) as f: + data = json.load(f) + return data if isinstance(data, dict) else {"shapes": data} + except Exception: + return {"shapes": []} + + +@router.get("/api/assets/fonts") +def api_get_fonts(): + """Get font library manifest from assets/fonts/manifest.json.""" + manifest_path = Path.cwd() / "assets" / "fonts" / "manifest.json" + if not manifest_path.exists(): + return {"fonts": []} + try: + with open(manifest_path) as f: + data = json.load(f) + return data if isinstance(data, dict) else {"fonts": data} + except Exception: + return {"fonts": []} diff --git a/src/kurt/web/api/server.py b/src/kurt/web/api/server.py index cc75d1e5..25b2c398 100644 --- a/src/kurt/web/api/server.py +++ b/src/kurt/web/api/server.py @@ -22,6 +22,7 @@ from kurt.web.api.routes.files import router as files_router from kurt.web.api.routes.system import router as system_router from kurt.web.api.routes.websockets import router as websockets_router +from kurt.web.api.routes.pages import router as pages_router from kurt.web.api.routes.workflows import router as workflows_router # Ensure working directory is project root (when running from worktree) @@ -62,6 +63,7 @@ app.include_router(approval_router) app.include_router(claude_router) app.include_router(workflows_router) +app.include_router(pages_router) app.include_router(websockets_router) diff --git a/src/kurt/web/client/src/App.jsx b/src/kurt/web/client/src/App.jsx index 48527715..49acdf52 100644 --- a/src/kurt/web/client/src/App.jsx +++ b/src/kurt/web/client/src/App.jsx @@ -15,6 +15,11 @@ import ReviewPanel from './panels/ReviewPanel' import WorkflowsPanel from './panels/WorkflowsPanel' import WorkflowTerminalPanel from './panels/WorkflowTerminalPanel' import WorkflowDetailPanel from './panels/WorkflowDetailPanel' +import DataTablePanel from './panels/DataTablePanel' +import ImageViewerPanel from './panels/ImageViewerPanel' +import MotionCanvasPanel from './panels/MotionCanvasPanel' +import VideoEditorPanel from './panels/VideoEditorPanel' +import VideoSequencePanel from './panels/VideoSequencePanel' import ClaudeStreamChat from './components/chat/ClaudeStreamChat' // POC mode - add ?poc=chat, ?poc=diff, or ?poc=tiptap-diff to URL to test @@ -33,6 +38,20 @@ const components = { workflows: WorkflowsPanel, workflowTerminal: WorkflowTerminalPanel, workflowDetail: WorkflowDetailPanel, + dataTable: DataTablePanel, + imageViewer: ImageViewerPanel, + motionCanvas: MotionCanvasPanel, + videoEditor: VideoEditorPanel, + videoSequence: VideoSequencePanel, +} + +// Maps workflow page types to dockview component names +const PAGE_TYPE_TO_COMPONENT = { + 'data-table': 'dataTable', + 'image': 'imageViewer', + 'motion-canvas': 'motionCanvas', + 'video': 'videoEditor', + 'video-sequence': 'videoSequence', } const KNOWN_COMPONENTS = new Set(Object.keys(components)) @@ -1694,6 +1713,48 @@ export default function App() { [dockApi, openWorkflowTerminal] ) + // Open a workflow page panel (data-table, image, video, motion-canvas, etc.) + const openWorkflowPage = useCallback( + (workflowId, page) => { + if (!dockApi || !page) return + + const componentName = PAGE_TYPE_TO_COMPONENT[page.type] + if (!componentName) { + console.warn(`Unknown page type: ${page.type}`) + return + } + + const panelId = `workflow-page-${workflowId}-${page.id}` + const existingPanel = dockApi.getPanel(panelId) + + if (existingPanel) { + existingPanel.api.setActive() + return + } + + const position = centerGroupRef.current + ? { referenceGroup: centerGroupRef.current } + : { direction: 'right', referencePanel: 'filetree' } + + const panel = dockApi.addPanel({ + id: panelId, + component: componentName, + title: page.title || page.id, + position, + params: { + workflowId, + page, + }, + }) + + if (panel?.group) { + panel.group.header.hidden = false + centerGroupRef.current = panel.group + } + }, + [dockApi] + ) + // Update workflows panel params // projectRoot dependency ensures this runs after layout restoration useEffect(() => { @@ -1705,6 +1766,7 @@ export default function App() { onToggleCollapse: toggleWorkflows, onAttachWorkflow: openWorkflowTerminal, onOpenWorkflowDetail: openWorkflowDetail, + onOpenWorkflowPage: openWorkflowPage, }) } // Also update shell panel with collapse state for the tab button @@ -1715,7 +1777,7 @@ export default function App() { onToggleCollapse: toggleWorkflows, }) } - }, [dockApi, collapsed.workflows, toggleWorkflows, openWorkflowTerminal, openWorkflowDetail, projectRoot]) + }, [dockApi, collapsed.workflows, toggleWorkflows, openWorkflowTerminal, openWorkflowDetail, openWorkflowPage, projectRoot]) // Restore saved tabs when dockApi and projectRoot become available const hasRestoredTabs = useRef(false) diff --git a/src/kurt/web/client/src/components/IntentToolbar.jsx b/src/kurt/web/client/src/components/IntentToolbar.jsx new file mode 100644 index 00000000..1bd42190 --- /dev/null +++ b/src/kurt/web/client/src/components/IntentToolbar.jsx @@ -0,0 +1,237 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { Type, Shapes, Move, Maximize2, Trash2, Send, X, RefreshCw } from 'lucide-react' +import TextOverlayPopover from './TextOverlayPopover' +import ShapePicker from './ShapePicker' + +const apiBase = import.meta.env.VITE_API_URL || '' +const apiUrl = (path) => `${apiBase}${path}` + +/** + * Shared toolbar for intent capture across media panels. + * Collects editing intents (add text, add shape, move, resize, delete) + * and dispatches them to the agent when the user clicks Apply. + * + * Props: + * workflowId: string - DBOS workflow ID + * pageId: string - Page identifier + * pageType: string - 'motion-canvas' | 'video' | 'image' | 'video-sequence' + * onRefresh: () => void - Called after edits dispatched (to refresh preview) + * disabled: boolean - Disable all controls + */ +export default function IntentToolbar({ workflowId, pageId, pageType, onRefresh, disabled = false }) { + const [mode, setMode] = useState(null) // null | 'text' | 'shape' | 'select' + const [intents, setIntents] = useState([]) + const [showShapePicker, setShowShapePicker] = useState(false) + const [showTextPopover, setShowTextPopover] = useState(false) + const [textPopoverPos, setTextPopoverPos] = useState(null) + const [pendingShape, setPendingShape] = useState(null) + const [isDispatching, setIsDispatching] = useState(false) + const [fonts, setFonts] = useState([]) + + // Load available fonts + useEffect(() => { + fetch(apiUrl('/api/assets/fonts')) + .then((r) => r.json()) + .then((d) => setFonts(d.fonts || [])) + .catch(() => {}) + }, []) + + const addIntent = useCallback((intent) => { + setIntents((prev) => [...prev, intent]) + }, []) + + const removeIntent = useCallback((idx) => { + setIntents((prev) => prev.filter((_, i) => i !== idx)) + }, []) + + const clearIntents = useCallback(() => { + setIntents([]) + setMode(null) + setShowShapePicker(false) + setShowTextPopover(false) + setPendingShape(null) + }, []) + + // Handle canvas/preview click for placement + const handlePreviewClick = useCallback((e) => { + if (!mode) return + const rect = e.currentTarget.getBoundingClientRect() + const x = Math.round(e.clientX - rect.left) + const y = Math.round(e.clientY - rect.top) + + if (mode === 'text') { + setTextPopoverPos({ x, y }) + setShowTextPopover(true) + } else if (mode === 'shape' && pendingShape) { + addIntent({ + action: 'add_shape', + shape_id: pendingShape.shape_id, + position: { x, y }, + size: { width: 100, height: 100 }, + animated: pendingShape.animated, + }) + setPendingShape(null) + setMode(null) + } + }, [mode, pendingShape, addIntent]) + + const handleTextConfirm = useCallback((textData) => { + addIntent({ + action: 'add_text', + text: textData.text, + position: textPopoverPos, + style: textData.style, + }) + setShowTextPopover(false) + setTextPopoverPos(null) + setMode(null) + }, [textPopoverPos, addIntent]) + + const handleShapeSelect = useCallback((shapeData) => { + setPendingShape(shapeData) + setShowShapePicker(false) + setMode('shape') + }, []) + + const handleDispatch = useCallback(async () => { + if (intents.length === 0 || !workflowId || !pageId) return + setIsDispatching(true) + try { + const res = await fetch( + apiUrl(`/api/workflows/${workflowId}/pages/${pageId}/edit`), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intents }), + } + ) + if (!res.ok) throw new Error(`Dispatch failed: ${res.status}`) + clearIntents() + if (onRefresh) onRefresh() + } catch (err) { + console.error('Edit dispatch failed:', err) + } finally { + setIsDispatching(false) + } + }, [intents, workflowId, pageId, clearIntents, onRefresh]) + + const intentSummary = (intent) => { + switch (intent.action) { + case 'add_text': return `Text: "${intent.text}" at (${intent.position?.x}, ${intent.position?.y})` + case 'add_shape': return `Shape: ${intent.shape_id} at (${intent.position?.x}, ${intent.position?.y})` + case 'move_element': return `Move: ${intent.element_id}` + case 'resize_element': return `Resize: ${intent.element_id}` + case 'delete_element': return `Delete: ${intent.element_id}` + default: return intent.action + } + } + + return ( +
+
+
+ + + +
+ + {mode && ( +
+ {mode === 'text' && 'Click on preview to place text'} + {mode === 'shape' && pendingShape && `Click to place "${pendingShape.title}"`} + {mode === 'shape' && !pendingShape && 'Select a shape...'} + +
+ )} + + {intents.length > 0 && ( +
+ {intents.length} edit{intents.length > 1 ? 's' : ''} + + +
+ )} +
+ + {/* Pending intents list */} + {intents.length > 0 && ( +
+ {intents.map((intent, idx) => ( +
+ {intentSummary(intent)} + +
+ ))} +
+ )} + + {/* Shape picker dropdown */} + {showShapePicker && ( + setShowShapePicker(false)} + /> + )} + + {/* Text popover (positioned at click point) */} + {showTextPopover && ( + { setShowTextPopover(false); setTextPopoverPos(null); setMode(null) }} + /> + )} +
+ ) +} + +/** + * Hook to wire IntentToolbar's click handler to a preview container. + * Returns a ref and the click handler to attach to the preview area. + */ +export function useIntentCapture(toolbarRef) { + // The toolbar exposes handlePreviewClick which panels should call + // when the preview area is clicked during an active mode. + return { handlePreviewClick: toolbarRef?.current?.handlePreviewClick } +} diff --git a/src/kurt/web/client/src/components/ShapePicker.jsx b/src/kurt/web/client/src/components/ShapePicker.jsx new file mode 100644 index 00000000..6d471576 --- /dev/null +++ b/src/kurt/web/client/src/components/ShapePicker.jsx @@ -0,0 +1,124 @@ +import { useState, useEffect, useCallback } from 'react' +import { X, Search } from 'lucide-react' + +const apiBase = import.meta.env.VITE_API_URL || '' +const apiUrl = (path) => `${apiBase}${path}` + +/** + * Grid browser for the SVG shape library. + * Loads shapes from /api/assets/shapes manifest and displays thumbnails. + * User selects a shape, then clicks on the canvas to place it. + */ +export default function ShapePicker({ onSelect, onClose }) { + const [shapes, setShapes] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [filter, setFilter] = useState('') + const [selectedCategory, setSelectedCategory] = useState('all') + + const fetchShapes = useCallback(async () => { + setIsLoading(true) + try { + const res = await fetch(apiUrl('/api/assets/shapes')) + if (!res.ok) throw new Error('Failed to load shapes') + const data = await res.json() + setShapes(data.shapes || []) + } catch { + setShapes([]) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + fetchShapes() + }, [fetchShapes]) + + const categories = ['all', ...new Set(shapes.map((s) => s.category).filter(Boolean))] + + const filtered = shapes.filter((s) => { + if (selectedCategory !== 'all' && s.category !== selectedCategory) return false + if (filter && !s.title?.toLowerCase().includes(filter.toLowerCase()) && !s.id?.toLowerCase().includes(filter.toLowerCase())) return false + return true + }) + + const handleSelect = (shape) => { + onSelect({ + shape_id: shape.id, + animated: shape.animated || false, + title: shape.title || shape.id, + }) + } + + return ( +
e.stopPropagation()}> +
+ Shapes + +
+ +
+ + setFilter(e.target.value)} + placeholder="Search shapes..." + className="sp-search-input" + /> +
+ + {categories.length > 2 && ( +
+ {categories.map((cat) => ( + + ))} +
+ )} + +
+ {isLoading ? ( +
Loading shapes...
+ ) : filtered.length === 0 ? ( +
+ {shapes.length === 0 + ? 'No shapes available. Add SVGs to assets/shapes/' + : 'No matching shapes'} +
+ ) : ( + filtered.map((shape) => ( + + )) + )} +
+
+ ) +} diff --git a/src/kurt/web/client/src/components/TextOverlayPopover.jsx b/src/kurt/web/client/src/components/TextOverlayPopover.jsx new file mode 100644 index 00000000..3879f73d --- /dev/null +++ b/src/kurt/web/client/src/components/TextOverlayPopover.jsx @@ -0,0 +1,114 @@ +import { useState, useEffect, useRef } from 'react' +import { X } from 'lucide-react' + +const apiBase = import.meta.env.VITE_API_URL || '' +const apiUrl = (path) => `${apiBase}${path}` + +/** + * Popover for entering text overlay details (text, font, size, color). + * Used by IntentToolbar when "Add Text" mode is active and user clicks to place. + */ +export default function TextOverlayPopover({ position, onConfirm, onCancel, fonts = [] }) { + const [text, setText] = useState('') + const [fontSize, setFontSize] = useState(24) + const [fontFamily, setFontFamily] = useState('') + const [color, setColor] = useState('#ffffff') + const inputRef = useRef(null) + + useEffect(() => { + if (inputRef.current) inputRef.current.focus() + }, []) + + const handleSubmit = (e) => { + e.preventDefault() + if (!text.trim()) return + onConfirm({ + text: text.trim(), + style: { + font: fontFamily || undefined, + size: fontSize, + color, + }, + }) + } + + return ( +
e.stopPropagation()} + > +
+
+ Add Text + +
+ + setText(e.target.value)} + placeholder="Enter text..." + className="it-text-input" + /> + +
+
+ + setFontSize(Number(e.target.value) || 24)} + min={8} + max={200} + className="it-text-num" + /> +
+ +
+ + setColor(e.target.value)} + className="it-text-color" + /> +
+ + {fonts.length > 0 && ( +
+ + +
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/src/kurt/web/client/src/components/WorkflowList.jsx b/src/kurt/web/client/src/components/WorkflowList.jsx index 8a4eded1..787b2800 100644 --- a/src/kurt/web/client/src/components/WorkflowList.jsx +++ b/src/kurt/web/client/src/components/WorkflowList.jsx @@ -37,7 +37,7 @@ const apiUrl = (path) => `${apiBase}${path}` // Debounce delay for search input (in milliseconds) const SEARCH_DEBOUNCE_MS = 300 -export default function WorkflowList({ onAttachWorkflow, onOpenWorkflowDetail }) { +export default function WorkflowList({ onAttachWorkflow, onOpenWorkflowDetail, onOpenWorkflowPage }) { const [workflows, setWorkflows] = useState([]) const [isLoading, setIsLoading] = useState(false) const [statusFilter, setStatusFilter] = useState('') @@ -314,6 +314,7 @@ export default function WorkflowList({ onAttachWorkflow, onOpenWorkflowDetail }) onCancel={() => handleCancel(workflow.workflow_uuid)} onRetry={() => handleRetry(workflow.workflow_uuid)} onOpenDetail={() => onOpenWorkflowDetail?.(workflow.workflow_uuid)} + onOpenPage={(page) => onOpenWorkflowPage?.(workflow.workflow_uuid, page)} getStatusBadgeClass={getStatusBadgeClass} /> ))} diff --git a/src/kurt/web/client/src/components/WorkflowRow.jsx b/src/kurt/web/client/src/components/WorkflowRow.jsx index 68f85ff9..ccf6a35e 100644 --- a/src/kurt/web/client/src/components/WorkflowRow.jsx +++ b/src/kurt/web/client/src/components/WorkflowRow.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo } from 'react' -import { Copy, ChevronDown, ChevronRight } from 'lucide-react' +import { Copy, ChevronDown, ChevronRight, Table2, Image, Film, Video } from 'lucide-react' const apiBase = import.meta.env.VITE_API_URL || '' const apiUrl = (path) => `${apiBase}${path}` @@ -39,6 +39,42 @@ const formatDuration = (ms) => { return `${minutes}m ${remainingSeconds}s` } +const PAGE_TYPE_ICONS = { + 'data-table': Table2, + 'image': Image, + 'motion-canvas': Film, + 'video': Video, + 'video-sequence': Video, +} + +const PAGE_TYPE_LABELS = { + 'data-table': 'Data Table', + 'image': 'Image', + 'motion-canvas': 'Motion Canvas', + 'video': 'Video', + 'video-sequence': 'Video Sequence', +} + +function PageButton({ page, workflowId, onOpenPage }) { + const Icon = PAGE_TYPE_ICONS[page.type] || Table2 + const label = page.title || PAGE_TYPE_LABELS[page.type] || page.id + return ( + + ) +} + const normalizeInputsArgs = (inputs) => { if (!inputs) return null if (Array.isArray(inputs)) return inputs @@ -1118,11 +1154,13 @@ export default function WorkflowRow({ onCancel, onRetry, onOpenDetail, + onOpenPage, getStatusBadgeClass, depth = 0, }) { const [liveStatus, setLiveStatus] = useState(null) const [isRetrying, setIsRetrying] = useState(false) + const [pages, setPages] = useState([]) const isRunning = workflow.status === 'PENDING' || workflow.status === 'ENQUEUED' const canRetry = ['SUCCESS', 'ERROR', 'CANCELLED', 'WARNING', 'RETRIES_EXCEEDED'].includes(workflow.status) @@ -1200,6 +1238,23 @@ export default function WorkflowRow({ fetchStatus() }, [isExpanded, workflow?.workflow_uuid, fetchStatus]) + // Fetch pages when expanded + useEffect(() => { + if (!isExpanded || !workflow?.workflow_uuid) return + const fetchPages = async () => { + try { + const res = await fetch(apiUrl(`/api/workflows/${workflow.workflow_uuid}/pages`)) + if (res.ok) { + const data = await res.json() + setPages(data.pages || []) + } + } catch (err) { + // Pages are optional + } + } + fetchPages() + }, [isExpanded, workflow?.workflow_uuid]) + const shortId = workflow.workflow_uuid?.slice(0, 8) || '' // Use definition_name for agent workflows, otherwise use the workflow name const workflowName = workflow.definition_name || workflow.name || 'Unknown' @@ -1421,6 +1476,19 @@ export default function WorkflowRow({ )} + {pages.length > 0 && ( +
+ {pages.map((page) => ( + + ))} +
+ )} + {liveStatus && ( diff --git a/src/kurt/web/client/src/hooks/useFileWatch.jsx b/src/kurt/web/client/src/hooks/useFileWatch.jsx new file mode 100644 index 00000000..648cbdcc --- /dev/null +++ b/src/kurt/web/client/src/hooks/useFileWatch.jsx @@ -0,0 +1,61 @@ +import { useState, useEffect, useRef, useCallback } from 'react' + +const apiBase = import.meta.env.VITE_API_URL || '' +const apiUrl = (path) => `${apiBase}${path}` + +/** + * Hook that polls a workflow page's metadata for file changes. + * When the file's mtime changes, triggers a refresh callback. + * + * @param {string} workflowId - DBOS workflow ID + * @param {string} pageId - Page identifier + * @param {object} options - { interval: poll interval ms (default 2000), enabled: boolean } + * @returns {{ mtime, isStale, refresh }} + */ +export default function useFileWatch(workflowId, pageId, options = {}) { + const { interval = 2000, enabled = true } = options + const [mtime, setMtime] = useState(null) + const [isStale, setIsStale] = useState(false) + const [refreshKey, setRefreshKey] = useState(0) + const lastMtime = useRef(null) + const pollRef = useRef(null) + + const refresh = useCallback(() => { + setRefreshKey((k) => k + 1) + setIsStale(false) + }, []) + + useEffect(() => { + if (!enabled || !workflowId || !pageId) return + + const poll = async () => { + try { + const res = await fetch( + apiUrl(`/api/workflows/${workflowId}/pages/${pageId}/data`) + ) + if (!res.ok) return + const data = await res.json() + const newMtime = data.mtime + + if (newMtime && lastMtime.current !== null && newMtime !== lastMtime.current) { + setIsStale(true) + setMtime(newMtime) + } else if (newMtime) { + setMtime(newMtime) + } + lastMtime.current = newMtime + } catch { + // Ignore poll errors + } + } + + poll() + pollRef.current = setInterval(poll, interval) + + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } + }, [workflowId, pageId, interval, enabled, refreshKey]) + + return { mtime, isStale, refresh, refreshKey } +} diff --git a/src/kurt/web/client/src/panels/DataTablePanel.jsx b/src/kurt/web/client/src/panels/DataTablePanel.jsx new file mode 100644 index 00000000..f71e38fb --- /dev/null +++ b/src/kurt/web/client/src/panels/DataTablePanel.jsx @@ -0,0 +1,394 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { Plus, Trash2, Play, Save, Download, Upload, ChevronLeft, ChevronRight } from 'lucide-react' + +const apiBase = import.meta.env.VITE_API_URL || '' +const apiUrl = (path) => `${apiBase}${path}` + +const ROWS_PER_PAGE = 50 + +function EditableCell({ value, column, onChange, onCommit }) { + const [editing, setEditing] = useState(false) + const [editValue, setEditValue] = useState(value ?? '') + const inputRef = useRef(null) + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [editing]) + + useEffect(() => { + setEditValue(value ?? '') + }, [value]) + + if (!column.editable) { + return {String(value ?? '')} + } + + if (!editing) { + return ( + setEditing(true)} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'F2') { + e.preventDefault() + setEditing(true) + } + }} + role="button" + > + {column.type === 'boolean' ? ( + { + onChange(e.target.checked) + onCommit() + }} + className="dt-checkbox" + /> + ) : ( + String(value ?? '') + )} + + ) + } + + const handleBlur = () => { + setEditing(false) + if (editValue !== (value ?? '')) { + let parsed = editValue + if (column.type === 'number') { + parsed = editValue === '' ? null : Number(editValue) + } + onChange(parsed) + onCommit() + } + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleBlur() + } else if (e.key === 'Escape') { + setEditValue(value ?? '') + setEditing(false) + } + } + + if (column.type === 'select' && column.options) { + return ( + + ) + } + + return ( + setEditValue(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + /> + ) +} + +export default function DataTablePanel({ params }) { + const { + workflowId, + pageId, + pageConfig, + onRunWorkflow, + } = params || {} + + const [columns, setColumns] = useState(pageConfig?.columns || []) + const [rows, setRows] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(0) + const [isDirty, setIsDirty] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [isSeed, setIsSeed] = useState(pageConfig?.seed || false) + const [showRunPrompt, setShowRunPrompt] = useState(false) + + const fetchData = useCallback(async () => { + if (!workflowId || !pageId) return + setIsLoading(true) + setError(null) + try { + const offset = page * ROWS_PER_PAGE + const response = await fetch( + apiUrl(`/api/workflows/${workflowId}/pages/${pageId}/data?offset=${offset}&limit=${ROWS_PER_PAGE}`) + ) + if (!response.ok) throw new Error(`Failed to load: ${response.status}`) + const data = await response.json() + if (data.columns?.length > 0) setColumns(data.columns) + setRows(data.rows || []) + setTotal(data.total || 0) + setIsSeed(data.seed || false) + } catch (err) { + setError(err.message) + } finally { + setIsLoading(false) + } + }, [workflowId, pageId, page]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const handleCellChange = (rowIdx, colName, newValue) => { + setRows((prev) => { + const next = [...prev] + next[rowIdx] = { ...next[rowIdx], [colName]: newValue } + return next + }) + setIsDirty(true) + } + + const handleAddRow = () => { + const newRow = {} + columns.forEach((col) => { + newRow[col.name] = col.type === 'number' ? 0 : col.type === 'boolean' ? false : '' + }) + setRows((prev) => [...prev, newRow]) + setTotal((prev) => prev + 1) + setIsDirty(true) + } + + const handleDeleteRow = (idx) => { + setRows((prev) => prev.filter((_, i) => i !== idx)) + setTotal((prev) => prev - 1) + setIsDirty(true) + } + + const handleSave = async () => { + if (!workflowId || !pageId) return + setIsSaving(true) + try { + const response = await fetch( + apiUrl(`/api/workflows/${workflowId}/pages/${pageId}/data`), + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rows }), + } + ) + if (!response.ok) throw new Error(`Save failed: ${response.status}`) + setIsDirty(false) + if (isSeed) { + setShowRunPrompt(true) + } + } catch (err) { + setError(err.message) + } finally { + setIsSaving(false) + } + } + + const handleRunWorkflow = async () => { + setShowRunPrompt(false) + try { + const response = await fetch( + apiUrl(`/api/workflows/${workflowId}/pages/${pageId}/run`), + { method: 'POST' } + ) + if (!response.ok) throw new Error(`Run failed: ${response.status}`) + const data = await response.json() + onRunWorkflow?.(data.workflow_id) + } catch (err) { + setError(err.message) + } + } + + const handleExport = () => { + const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${pageId}-data.json` + a.click() + URL.revokeObjectURL(url) + } + + const handleImport = () => { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.json,.csv' + input.onchange = async (e) => { + const file = e.target.files[0] + if (!file) return + const text = await file.text() + try { + if (file.name.endsWith('.json')) { + const data = JSON.parse(text) + setRows(Array.isArray(data) ? data : data.rows || []) + } else if (file.name.endsWith('.csv')) { + const lines = text.split('\n').filter((l) => l.trim()) + if (lines.length < 2) return + const headers = lines[0].split(',').map((h) => h.trim()) + const imported = lines.slice(1).map((line) => { + const vals = line.split(',') + const row = {} + headers.forEach((h, i) => { row[h] = vals[i]?.trim() ?? '' }) + return row + }) + setRows(imported) + } + setIsDirty(true) + } catch (err) { + setError(`Import failed: ${err.message}`) + } + } + input.click() + } + + const totalPages = Math.ceil(total / ROWS_PER_PAGE) + const title = pageConfig?.title || 'Data Table' + + return ( +
+
+ {title} + {isSeed && Seed Data} +
+ + + + +
+
+ + {showRunPrompt && ( +
+ Seed data updated. Run the workflow with new data? + + +
+ )} + + {error &&
{error}
} + + {isLoading ? ( +
Loading data...
+ ) : ( + <> +
+ + + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, rowIdx) => ( + + + {columns.map((col) => ( + + ))} + + + ))} + {rows.length === 0 && ( + + + + )} + +
# + {col.label || col.name} + {col.required && *} + +
{page * ROWS_PER_PAGE + rowIdx + 1} + handleCellChange(rowIdx, col.name, val)} + onCommit={() => {}} + /> + + +
+ No data. Click "Add Row" to get started. +
+
+ + {totalPages > 1 && ( +
+ + + Page {page + 1} of {totalPages} ({total} rows) + + +
+ )} + + )} +
+ ) +} diff --git a/src/kurt/web/client/src/panels/ImageViewerPanel.jsx b/src/kurt/web/client/src/panels/ImageViewerPanel.jsx new file mode 100644 index 00000000..01529861 --- /dev/null +++ b/src/kurt/web/client/src/panels/ImageViewerPanel.jsx @@ -0,0 +1,307 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { ZoomIn, ZoomOut, RotateCw, Download, Maximize2, Pen, Undo2, Redo2, RefreshCw, AlertCircle } from 'lucide-react' +import IntentToolbar from '../components/IntentToolbar' +import useFileWatch from '../hooks/useFileWatch' + +const apiBase = import.meta.env.VITE_API_URL || '' +const apiUrl = (path) => `${apiBase}${path}` + +const MIN_ZOOM = 0.1 +const MAX_ZOOM = 5 +const ZOOM_STEP = 0.25 + +/** + * Image Viewer Panel - View images with zoom/rotate/annotate and agent-as-editor. + * + * When editable, includes IntentToolbar for dispatching text/shape overlay + * intents to the agent (which uses ImageMagick/Pillow to edit the image). + * Also supports manual annotation drawing for quick markup. + */ +export default function ImageViewerPanel({ params }) { + const { + workflowId, + pageId, + pageConfig, + } = params || {} + + const [imageUrl, setImageUrl] = useState(null) + const [imageMeta, setImageMeta] = useState(null) + const [zoom, setZoom] = useState(1) + const [rotation, setRotation] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [fitMode, setFitMode] = useState('contain') + const [annotating, setAnnotating] = useState(false) + const [annotations, setAnnotations] = useState([]) + const [undoStack, setUndoStack] = useState([]) + const [currentPath, setCurrentPath] = useState([]) + const canvasRef = useRef(null) + const containerRef = useRef(null) + const imgRef = useRef(null) + const isDrawing = useRef(false) + + const editable = pageConfig?.editable !== false + + // File watch for auto-refresh after agent edits + const { isStale, refresh, refreshKey } = useFileWatch(workflowId, pageId, { + enabled: Boolean(workflowId && pageId), + }) + + const fetchImageData = useCallback(async () => { + if (!workflowId || !pageId) return + setIsLoading(true) + setError(null) + try { + const response = await fetch( + apiUrl(`/api/workflows/${workflowId}/pages/${pageId}/data`) + ) + if (!response.ok) throw new Error(`Failed to load: ${response.status}`) + const data = await response.json() + setImageMeta(data) + + if (data.image_path && data.exists) { + setImageUrl(apiUrl(`/api/file/raw?path=${encodeURIComponent(data.image_path)}&v=${refreshKey}`)) + } + } catch (err) { + setError(err.message) + } finally { + setIsLoading(false) + } + }, [workflowId, pageId, refreshKey]) + + useEffect(() => { + fetchImageData() + }, [fetchImageData]) + + // Auto-refresh when file changes detected + useEffect(() => { + if (isStale) { + fetchImageData() + refresh() + } + }, [isStale, fetchImageData, refresh]) + + const handleRefresh = () => fetchImageData() + + const handleZoomIn = () => setZoom((z) => Math.min(z + ZOOM_STEP, MAX_ZOOM)) + const handleZoomOut = () => setZoom((z) => Math.max(z - ZOOM_STEP, MIN_ZOOM)) + const handleRotate = () => setRotation((r) => (r + 90) % 360) + + const handleFitToggle = () => { + if (fitMode === 'contain') { + setFitMode('actual') + setZoom(1) + } else { + setFitMode('contain') + setZoom(1) + } + } + + const handleDownload = () => { + if (!imageUrl) return + const a = document.createElement('a') + a.href = imageUrl + a.download = imageMeta?.image_path?.split('/').pop() || 'image' + a.click() + } + + const handleWheel = useCallback((e) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP + setZoom((z) => Math.max(MIN_ZOOM, Math.min(z + delta, MAX_ZOOM))) + } + }, []) + + // Annotation drawing + const startDrawing = (e) => { + if (!annotating) return + isDrawing.current = true + const rect = canvasRef.current.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + setCurrentPath([{ x, y }]) + } + + const draw = (e) => { + if (!isDrawing.current || !annotating) return + const rect = canvasRef.current.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + setCurrentPath((prev) => [...prev, { x, y }]) + } + + const endDrawing = () => { + if (!isDrawing.current) return + isDrawing.current = false + if (currentPath.length > 1) { + setAnnotations((prev) => [...prev, currentPath]) + setUndoStack([]) + } + setCurrentPath([]) + } + + const handleUndo = () => { + if (annotations.length === 0) return + const last = annotations[annotations.length - 1] + setAnnotations((prev) => prev.slice(0, -1)) + setUndoStack((prev) => [...prev, last]) + } + + const handleRedo = () => { + if (undoStack.length === 0) return + const last = undoStack[undoStack.length - 1] + setUndoStack((prev) => prev.slice(0, -1)) + setAnnotations((prev) => [...prev, last]) + } + + // Draw annotations on canvas + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + const img = imgRef.current + if (!img || !img.naturalWidth) return + + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + ctx.clearRect(0, 0, canvas.width, canvas.height) + + ctx.strokeStyle = '#ff4444' + ctx.lineWidth = 3 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + + const scaleX = img.naturalWidth / (img.clientWidth || 1) + const scaleY = img.naturalHeight / (img.clientHeight || 1) + + const drawPath = (path) => { + if (path.length < 2) return + ctx.beginPath() + ctx.moveTo(path[0].x * scaleX, path[0].y * scaleY) + for (let i = 1; i < path.length; i++) { + ctx.lineTo(path[i].x * scaleX, path[i].y * scaleY) + } + ctx.stroke() + } + + annotations.forEach(drawPath) + if (currentPath.length > 1) drawPath(currentPath) + }, [annotations, currentPath]) + + const title = pageConfig?.title || 'Image Viewer' + + return ( +
+
+ {title} +
+ + + {Math.round(zoom * 100)}% + +
+ + + {editable && ( + <> +
+ + {annotating && ( + <> + + + + )} + + )} +
+ +
+
+ + {/* Intent toolbar for text/shape overlays (agent-dispatched) */} + {editable && ( + + )} + + {error &&
{error}
} + + {isLoading ? ( +
Loading image...
+ ) : !imageUrl ? ( +
+
No image available
+ {imageMeta?.image_path && ( +
Expected at: {imageMeta.image_path}
+ )} +
+ ) : ( +
+
+ {title} + {annotating && ( + + )} +
+
+ )} +
+ ) +} diff --git a/src/kurt/web/client/src/panels/MotionCanvasPanel.jsx b/src/kurt/web/client/src/panels/MotionCanvasPanel.jsx new file mode 100644 index 00000000..957ef6fa --- /dev/null +++ b/src/kurt/web/client/src/panels/MotionCanvasPanel.jsx @@ -0,0 +1,219 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { Play, Pause, SkipBack, SkipForward, RefreshCw, Settings, Code, AlertCircle } from 'lucide-react' +import IntentToolbar from '../components/IntentToolbar' +import useFileWatch from '../hooks/useFileWatch' + +const apiBase = import.meta.env.VITE_API_URL || '' +const apiUrl = (path) => `${apiBase}${path}` + +/** + * Motion Canvas Panel - Rendered output viewer with agent-as-editor model. + * + * Displays rendered Motion Canvas output (video or iframe preview). + * The agent edits .tsx scene files; this panel is the viewer. + * IntentToolbar captures user editing intent and dispatches to agent. + */ +export default function MotionCanvasPanel({ params }) { + const { + workflowId, + pageId, + pageConfig, + onOpenFile, + } = params || {} + + const [sceneMeta, setSceneMeta] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [showSettings, setShowSettings] = useState(false) + const [previewScale, setPreviewScale] = useState(1) + const videoRef = useRef(null) + const iframeRef = useRef(null) + const previewRef = useRef(null) + + // File watch for auto-refresh after agent edits + const { isStale, refresh, refreshKey } = useFileWatch(workflowId, pageId, { + enabled: Boolean(workflowId && pageId), + }) + + const fetchSceneData = useCallback(async () => { + if (!workflowId || !pageId) return + setIsLoading(true) + setError(null) + try { + const response = await fetch( + apiUrl(`/api/workflows/${workflowId}/pages/${pageId}/data`) + ) + if (!response.ok) throw new Error(`Failed to load: ${response.status}`) + const data = await response.json() + setSceneMeta(data) + } catch (err) { + setError(err.message) + } finally { + setIsLoading(false) + } + }, [workflowId, pageId, refreshKey]) + + useEffect(() => { + fetchSceneData() + }, [fetchSceneData]) + + // Auto-refresh when file changes detected + useEffect(() => { + if (isStale) { + fetchSceneData() + refresh() + } + }, [isStale, fetchSceneData, refresh]) + + const handleOpenScene = () => { + if (sceneMeta?.scene_path && onOpenFile) { + onOpenFile(sceneMeta.scene_path) + } + } + + const handleRefresh = () => { + fetchSceneData() + // Also reload video/iframe + if (videoRef.current) { + videoRef.current.load() + } + if (iframeRef.current) { + iframeRef.current.src = iframeRef.current.src + } + } + + // Determine render mode: iframe (live preview) > video (rendered output) > placeholder + const hasLivePreview = sceneMeta?.preview_url + const hasRenderedOutput = sceneMeta?.output_path && sceneMeta?.output_exists + const outputUrl = hasRenderedOutput + ? apiUrl(`/api/file/raw?path=${encodeURIComponent(sceneMeta.output_path)}&v=${refreshKey}`) + : null + + const title = pageConfig?.title || 'Motion Canvas' + const editable = pageConfig?.editable !== false + + return ( +
+
+ {title} +
+ + {sceneMeta?.scene_path && ( + + )} + +
+
+ + {showSettings && ( +
+ + {sceneMeta?.duration && ( + Duration: {sceneMeta.duration}s + )} + {sceneMeta?.fps && ( + FPS: {sceneMeta.fps} + )} +
+ )} + + {/* Intent toolbar for agent-dispatched editing */} + {editable && ( + + )} + + {error &&
{error}
} + + {isLoading ? ( +
Loading scene...
+ ) : ( +
+ {hasLivePreview ? ( + /* Mode 1: Live preview via Motion Canvas dev server iframe */ +
+