diff --git a/apps/agent/main.py b/apps/agent/main.py index 35aadda..62245c1 100644 --- a/apps/agent/main.py +++ b/apps/agent/main.py @@ -10,6 +10,7 @@ from src.query import query_data from src.todos import AgentState, todo_tools from src.form import generate_form +from src.templates import template_tools from skills import load_all_skills # Load all visualization skills @@ -17,7 +18,7 @@ agent = create_agent( model=ChatOpenAI(model="gpt-5.4-2026-03-05"), - tools=[query_data, *todo_tools, generate_form], + tools=[query_data, *todo_tools, generate_form, *template_tools], middleware=[CopilotKitMiddleware()], state_schema=AgentState, system_prompt=f""" @@ -47,6 +48,28 @@ Follow the skills below for how to produce high-quality visuals: {_skills_text} + + ## UI Templates + + Users can save generated UIs as reusable templates and apply them later. + You have backend tools: `save_template`, `list_templates`, `apply_template`, `delete_template`. + + **When a user asks to apply/recreate a template with new data:** + Check `pending_template` in state — the frontend sets this when the user picks a template. + If `pending_template` is present (has `id` and `name`): + 1. Call `apply_template(template_id=pending_template["id"])` to retrieve the HTML + 2. Take the returned HTML and COPY IT EXACTLY, only replacing the data values + (names, numbers, dates, labels, amounts) to match the user's message + 3. Render the modified HTML using `widgetRenderer` + 4. Call `clear_pending_template` to reset the pending state + + If no `pending_template` is set but the user mentions a template by name, use + `apply_template(name="...")` instead. + + CRITICAL: Do NOT rewrite or generate HTML from scratch. Take the original HTML string, + find-and-replace ONLY the data values, and pass the result to widgetRenderer. + This preserves the exact layout and styling of the original template. + For bar/pie chart templates, use `barChart` or `pieChart` component instead. """, ) diff --git a/apps/agent/src/templates.py b/apps/agent/src/templates.py new file mode 100644 index 0000000..7eb1605 --- /dev/null +++ b/apps/agent/src/templates.py @@ -0,0 +1,186 @@ +from langchain.tools import ToolRuntime, tool +from langchain.messages import ToolMessage +from langgraph.types import Command +from typing import Any, Optional, TypedDict +import uuid +from datetime import datetime + + +class UITemplate(TypedDict, total=False): + id: str + name: str + description: str + html: str + data_description: str + created_at: str + version: int + component_type: Optional[str] + component_data: Optional[dict[str, Any]] + + +@tool +def save_template( + name: str, + description: str, + html: str, + data_description: str, + runtime: ToolRuntime, +) -> Command: + """ + Save a generated UI as a reusable template. + Call this when the user wants to save a widget/visualization they liked for reuse later. + + Args: + name: Short name for the template (e.g. "Invoice", "Dashboard") + description: What the template displays or does + html: The raw HTML string of the widget to save as a template + data_description: Description of the data shape this template expects + """ + templates = list(runtime.state.get("templates", [])) + + template: UITemplate = { + "id": str(uuid.uuid4()), + "name": name, + "description": description, + "html": html, + "data_description": data_description, + "created_at": datetime.now().isoformat(), + "version": 1, + } + templates.append(template) + + return Command(update={ + "templates": templates, + "messages": [ + ToolMessage( + content=f"Template '{name}' saved successfully (id: {template['id']})", + tool_call_id=runtime.tool_call_id, + ) + ], + }) + + +@tool +def list_templates(runtime: ToolRuntime): + """ + List all saved UI templates. Returns template summaries (id, name, description, data_description). + """ + templates = runtime.state.get("templates", []) + return [ + { + "id": t["id"], + "name": t["name"], + "description": t["description"], + "data_description": t["data_description"], + "version": t["version"], + } + for t in templates + ] + + +@tool +def apply_template(name: str = "", template_id: str = "", runtime: ToolRuntime = None): + """ + Retrieve a saved template's HTML so you can adapt it with new data. + After calling this, generate a NEW widget in the same style and render via widgetRenderer. + + This tool automatically checks for a pending_template in state (set by the + frontend when the user picks a template from the library). If pending_template + is present, it takes priority over name/template_id arguments. + + Args: + name: The name of the template to apply (fallback if no pending_template) + template_id: The ID of the template to apply (fallback if no pending_template) + """ + templates = runtime.state.get("templates", []) + + # Check pending_template from frontend first — this is the most reliable source + pending = runtime.state.get("pending_template") + if pending and pending.get("id"): + template_id = pending["id"] + + # Look up by ID first + if template_id: + for t in templates: + if t["id"] == template_id: + return { + "name": t["name"], + "description": t["description"], + "html": t["html"], + "data_description": t.get("data_description", ""), + } + return {"error": f"Template with id '{template_id}' not found"} + + # Look up by name (most recent match) + if name: + matches = [t for t in templates if t["name"].lower() == name.lower()] + if matches: + t = max(matches, key=lambda x: x.get("created_at", "")) + return { + "name": t["name"], + "description": t["description"], + "html": t["html"], + "data_description": t.get("data_description", ""), + } + return {"error": f"No template named '{name}' found"} + + return {"error": "Provide either a name or template_id"} + + +@tool +def delete_template(template_id: str, runtime: ToolRuntime) -> Command: + """ + Delete a saved UI template. + + Args: + template_id: The ID of the template to delete + """ + templates = list(runtime.state.get("templates", [])) + original_len = len(templates) + templates = [t for t in templates if t["id"] != template_id] + + if len(templates) == original_len: + return Command(update={ + "messages": [ + ToolMessage( + content=f"Template with id '{template_id}' not found", + tool_call_id=runtime.tool_call_id, + ) + ], + }) + + return Command(update={ + "templates": templates, + "messages": [ + ToolMessage( + content=f"Template deleted successfully", + tool_call_id=runtime.tool_call_id, + ) + ], + }) + + +@tool +def clear_pending_template(runtime: ToolRuntime) -> Command: + """ + Clear the pending_template from state after applying it. + Call this after you have finished applying a template. + """ + return Command(update={ + "pending_template": None, + "messages": [ + ToolMessage( + content="Pending template cleared", + tool_call_id=runtime.tool_call_id, + ) + ], + }) + + +template_tools = [ + save_template, + list_templates, + apply_template, + delete_template, + clear_pending_template, +] diff --git a/apps/agent/src/todos.py b/apps/agent/src/todos.py index b647fee..2d934b5 100644 --- a/apps/agent/src/todos.py +++ b/apps/agent/src/todos.py @@ -2,9 +2,11 @@ from langchain.tools import ToolRuntime, tool from langchain.messages import ToolMessage from langgraph.types import Command -from typing import TypedDict, Literal +from typing import Optional, TypedDict, Literal import uuid +from src.templates import UITemplate + class Todo(TypedDict): id: str title: str @@ -12,8 +14,14 @@ class Todo(TypedDict): emoji: str status: Literal["pending", "completed"] +class PendingTemplate(TypedDict, total=False): + id: str + name: str + class AgentState(BaseAgentState): todos: list[Todo] + templates: list[UITemplate] + pending_template: Optional[PendingTemplate] @tool def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command: diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css index 0c98827..7c6d573 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -621,3 +621,24 @@ body, html { @keyframes spin { to { transform: rotate(360deg); } } + +/* Template save animations */ +@keyframes tmpl-pop { + 0% { transform: scale(0.8); opacity: 0; } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); opacity: 1; } +} + +@keyframes tmpl-check { + to { stroke-dashoffset: 0; } +} + +@keyframes tmpl-slideIn { + from { transform: translateY(-4px) scale(0.97); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +@keyframes chipIn { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 7994182..5f52618 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -1,9 +1,11 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { ExampleLayout } from "@/components/example-layout"; import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks"; import { ExplainerCardsPortal } from "@/components/explainer-cards"; +import { TemplateLibrary } from "@/components/template-library"; +import { TemplateChip } from "@/components/template-library/template-chip"; import { CopilotChat } from "@copilotkit/react-core/v2"; @@ -11,7 +13,9 @@ export default function HomePage() { useGenerativeUIExamples(); useExampleSuggestions(); - // Widget bridge: handle openLink from widget iframes + const [templateDrawerOpen, setTemplateDrawerOpen] = useState(false); + + // Widget bridge: handle messages from widget iframes useEffect(() => { const handler = (e: MessageEvent) => { if (e.data?.type === "open-link" && typeof e.data.url === "string") { @@ -22,6 +26,7 @@ export default function HomePage() { return () => window.removeEventListener("message", handler); }, []); + return ( <> {/* Animated background */} @@ -58,19 +63,38 @@ export default function HomePage() { — powered by CopilotKit

- - Get started - +
+ {/* Template Library toggle */} + + + Get started + +
@@ -84,6 +108,15 @@ export default function HomePage() { + + {/* Template chip — portal renders above chat input */} + + + {/* Template Library Drawer */} + setTemplateDrawerOpen(false)} + /> ); } diff --git a/apps/app/src/components/generative-ui/charts/bar-chart.tsx b/apps/app/src/components/generative-ui/charts/bar-chart.tsx index 69102c8..aad11bc 100644 --- a/apps/app/src/components/generative-ui/charts/bar-chart.tsx +++ b/apps/app/src/components/generative-ui/charts/bar-chart.tsx @@ -1,6 +1,7 @@ import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { z } from 'zod'; import { CHART_COLORS, CHART_CONFIG } from './config'; +import { SaveTemplateOverlay } from '../save-template-overlay'; export const BarChartProps = z.object({ title: z.string().describe("Chart title"), @@ -16,7 +17,9 @@ export const BarChartProps = z.object({ type BarChartProps = z.infer; export function BarChart({ title, description, data }: BarChartProps) { - if (!data || !Array.isArray(data) || data.length === 0) { + const hasData = data && Array.isArray(data) && data.length > 0; + + if (!hasData) { return (
@@ -35,27 +38,34 @@ export function BarChart({ title, description, data }: BarChartProps) { })); return ( -
-
-

{title}

-

{description}

-
+ +
+
+

{title}

+

{description}

+
- - - - - - - - -
+ + + + + + + + +
+ ); } diff --git a/apps/app/src/components/generative-ui/charts/pie-chart.tsx b/apps/app/src/components/generative-ui/charts/pie-chart.tsx index 68f8fc6..8701b84 100644 --- a/apps/app/src/components/generative-ui/charts/pie-chart.tsx +++ b/apps/app/src/components/generative-ui/charts/pie-chart.tsx @@ -6,6 +6,7 @@ import { } from "recharts"; import { z } from "zod"; import { CHART_COLORS, CHART_CONFIG } from "./config"; +import { SaveTemplateOverlay } from "../save-template-overlay"; export const PieChartProps = z.object({ title: z.string().describe("Chart title"), @@ -21,7 +22,9 @@ export const PieChartProps = z.object({ type PieChartProps = z.infer; export function PieChart({ title, description, data }: PieChartProps) { - if (!data || !Array.isArray(data) || data.length === 0) { + const hasData = data && Array.isArray(data) && data.length > 0; + + if (!hasData) { return (
@@ -44,43 +47,50 @@ export function PieChart({ title, description, data }: PieChartProps) { })); return ( -
-
-

{title}

-

- {description} -

-
- - - - - - - + +
+
+

{title}

+

+ {description} +

+
- {/* Legend */} -
- {data.map((item, index) => ( -
-
+ + - {item.label} -
- ))} + + + + + {/* Legend */} +
+ {data.map((item, index) => ( +
+
+ {item.label} +
+ ))} +
-
+ ); } diff --git a/apps/app/src/components/generative-ui/save-template-overlay.tsx b/apps/app/src/components/generative-ui/save-template-overlay.tsx new file mode 100644 index 0000000..e071bc9 --- /dev/null +++ b/apps/app/src/components/generative-ui/save-template-overlay.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useState, useCallback, useMemo, useRef, type ReactNode } from "react"; +import { useAgent } from "@copilotkit/react-core/v2"; +import { SEED_TEMPLATES } from "@/components/template-library/seed-templates"; + +type SaveState = "idle" | "input" | "saving" | "saved"; + +interface SaveTemplateOverlayProps { + /** Title used as default template name */ + title: string; + /** Description stored with the template */ + description: string; + /** Raw HTML to save (for widget renderer templates) */ + html?: string; + /** Structured data to save (for chart templates) */ + componentData?: Record; + /** The component type that produced this (e.g. "barChart", "pieChart", "widgetRenderer") */ + componentType: string; + /** Whether content has finished rendering — button hidden until true */ + ready?: boolean; + children: ReactNode; +} + +export function SaveTemplateOverlay({ + title, + description, + html, + componentData, + componentType, + ready = true, + children, +}: SaveTemplateOverlayProps) { + const { agent } = useAgent(); + const [saveState, setSaveState] = useState("idle"); + const [templateName, setTemplateName] = useState(""); + + // Capture pending_template at mount time — it may be cleared by the agent later + const pending = agent.state?.pending_template as { id: string; name: string } | null | undefined; + const sourceRef = useRef<{ id: string; name: string } | null>(null); + if (pending?.id && !sourceRef.current) { + sourceRef.current = pending; + } + + // Check if this content matches an existing template: + // 1. Exact HTML match (seed templates rendered as-is) + // 2. Source template captured from pending_template (applied templates with modified data) + const matchedTemplate = useMemo(() => { + // First check source template from apply flow + if (sourceRef.current) { + const allTemplates = [ + ...SEED_TEMPLATES, + ...((agent.state?.templates as { id: string; name: string }[]) || []), + ]; + const source = allTemplates.find((t) => t.id === sourceRef.current!.id); + if (source) return source; + } + // Then check exact HTML match + if (!html) return null; + const normalise = (s: string) => s.replace(/\s+/g, " ").trim(); + const norm = normalise(html); + const allTemplates = [ + ...SEED_TEMPLATES, + ...((agent.state?.templates as { id: string; name: string; html: string }[]) || []), + ]; + return allTemplates.find((t) => t.html && normalise(t.html) === norm) ?? null; + }, [html, agent.state?.templates]); + + const handleSave = useCallback(() => { + const name = templateName.trim() || title || "Untitled Template"; + setSaveState("saving"); + + const templates = agent.state?.templates || []; + const newTemplate = { + id: crypto.randomUUID(), + name, + description: description || title || "", + html: html || "", + component_type: componentType, + component_data: componentData || null, + data_description: "", + created_at: new Date().toISOString(), + version: 1, + }; + agent.setState({ ...agent.state, templates: [...templates, newTemplate] }); + + setTemplateName(""); + setTimeout(() => { + setSaveState("saved"); + setTimeout(() => setSaveState("idle"), 1800); + }, 400); + }, [agent, templateName, title, description, html, componentData, componentType]); + + return ( +
+ {/* Save as Template button — hidden until content is ready */} +
+ {/* Saved confirmation */} + {saveState === "saved" && ( +
+ + + + Saved! +
+ )} + + {/* Saving spinner */} + {saveState === "saving" && ( +
+
+ Saving... +
+ )} + + {/* Name input */} + {saveState === "input" && ( +
+ setTemplateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") { + setSaveState("idle"); + setTemplateName(""); + } + }} + autoFocus + className="text-xs px-2 py-1 rounded-md outline-none" + style={{ + width: 140, + background: "var(--color-background-secondary, #f5f5f5)", + color: "var(--text-primary, #1a1a1a)", + border: "1px solid var(--color-border-tertiary, rgba(0,0,0,0.1))", + }} + /> + + +
+ )} + + {/* Idle: show save button (badge moved outside this container) */} + {saveState === "idle" && !matchedTemplate && ( + + )} +
+ + {/* Template name badge — shown above widget when matched */} + {saveState === "idle" && matchedTemplate && ready && ( +
+
+ + + + {matchedTemplate.name} +
+
+ )} + + {children} +
+ ); +} diff --git a/apps/app/src/components/generative-ui/widget-renderer.tsx b/apps/app/src/components/generative-ui/widget-renderer.tsx index f978385..3aa17fd 100644 --- a/apps/app/src/components/generative-ui/widget-renderer.tsx +++ b/apps/app/src/components/generative-ui/widget-renderer.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { z } from "zod"; +import { SaveTemplateOverlay } from "./save-template-overlay"; // ─── Zod Schema (CopilotKit parameter contract) ───────────────────── export const WidgetRendererProps = z.object({ @@ -24,7 +25,7 @@ export const WidgetRendererProps = z.object({ type WidgetRendererProps = z.infer; // ─── Injected CSS: Theme Variables (Layer 3) ───────────────────────── -const THEME_CSS = ` +export const THEME_CSS = ` :root { --color-background-primary: #ffffff; --color-background-secondary: #f7f6f3; @@ -346,6 +347,40 @@ document.addEventListener('click', function(e) { } }); +// Listen for streaming content updates from parent +window.addEventListener('message', function(e) { + if (e.source !== window.parent) return; + if (e.data && e.data.type === 'update-content') { + var content = document.getElementById('content'); + if (content) { + // Strip script tags from HTML before inserting — scripts are handled separately below + var tmp = document.createElement('div'); + tmp.innerHTML = e.data.html; + var incomingScripts = []; + tmp.querySelectorAll('script').forEach(function(s) { + incomingScripts.push({ src: s.src, text: s.textContent }); + s.remove(); + }); + content.innerHTML = tmp.innerHTML; + + // Execute only new scripts (not previously executed) + incomingScripts.forEach(function(scriptInfo) { + var key = scriptInfo.src || scriptInfo.text; + if (content.getAttribute('data-exec-' + btoa(key).slice(0, 16))) return; + content.setAttribute('data-exec-' + btoa(key).slice(0, 16), '1'); + var newScript = document.createElement('script'); + if (scriptInfo.src) { + newScript.src = scriptInfo.src; + } else { + newScript.textContent = scriptInfo.text; + } + content.appendChild(newScript); + }); + reportHeight(); + } + } +}); + // Auto-resize: report content height to host function reportHeight() { var content = document.getElementById('content'); @@ -361,7 +396,8 @@ setTimeout(function() { clearInterval(_resizeInterval); }, 15000); `; // ─── Document Assembly ─────────────────────────────────────────────── -function assembleDocument(html: string): string { +/** Empty shell or shell with initial content — iframe loads once, content streamed via postMessage */ +function assembleShell(initialHtml: string = ""): string { return ` @@ -387,7 +423,7 @@ function assembleDocument(html: string): string {
- ${html} + ${initialHtml}