From b84598b8d22759ddc034544f84f66bd3bcdae04e Mon Sep 17 00:00:00 2001 From: davidpanonce-nx Date: Fri, 20 Mar 2026 21:59:27 +0800 Subject: [PATCH 01/16] feat: add UI template system for saving and reusing generated widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add template tools (save, list, apply, delete) in agent backend - Extend AgentState with templates field - Add "Save as Template" button overlay on widget renderer with animated save flow (input → saving spinner → checkmark confirmation) - Add template library drawer panel (toggle from header) - Templates saved directly to agent state via setState (no chat round-trip) - Template deletion from drawer updates state directly - Apply template sends chat prompt for agent to adapt HTML with new data Closes #16 --- apps/agent/main.py | 17 ++- apps/agent/src/templates.py | 138 +++++++++++++++++ apps/agent/src/todos.py | 3 + apps/app/src/app/globals.css | 16 ++ apps/app/src/app/page.tsx | 108 ++++++++++++-- .../generative-ui/widget-renderer.tsx | 137 ++++++++++++++++- .../src/components/template-library/index.tsx | 139 ++++++++++++++++++ .../template-library/template-card.tsx | 130 ++++++++++++++++ 8 files changed, 670 insertions(+), 18 deletions(-) create mode 100644 apps/agent/src/templates.py create mode 100644 apps/app/src/components/template-library/index.tsx create mode 100644 apps/app/src/components/template-library/template-card.tsx diff --git a/apps/agent/main.py b/apps/agent/main.py index 35aadda..1d03975 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,20 @@ 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: + + - When a user asks to save a widget as a template, call `save_template` with the + widget's HTML, a short name, description, and a description of the data shape. + - When a user asks to apply a template, first call `list_templates` to find the + right one, then call `apply_template` to get its HTML. Adapt the HTML with the + user's new data and render via `widgetRenderer`. + - When a user asks to see their templates, call `list_templates`. + - When a user asks to delete a template, call `delete_template`. + - A "save-as-template" message from the frontend means the user clicked the save + button on a widget. Extract the template details and call `save_template`. """, ) diff --git a/apps/agent/src/templates.py b/apps/agent/src/templates.py new file mode 100644 index 0000000..48f4eb9 --- /dev/null +++ b/apps/agent/src/templates.py @@ -0,0 +1,138 @@ +from langchain.tools import ToolRuntime, tool +from langchain.messages import ToolMessage +from langgraph.types import Command +from typing import TypedDict +import uuid +from datetime import datetime + + +class UITemplate(TypedDict): + id: str + name: str + description: str + html: str + data_description: str + created_at: str + version: int + + +@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(template_id: str, runtime: ToolRuntime): + """ + Retrieve a saved template's HTML so you can adapt it with new data. + After calling this, modify the HTML to fit the user's new data and render it via widgetRenderer. + + Args: + template_id: The ID of the template to apply + """ + templates = runtime.state.get("templates", []) + for t in templates: + if t["id"] == template_id: + return { + "name": t["name"], + "description": t["description"], + "html": t["html"], + "data_description": t["data_description"], + } + return {"error": f"Template with id '{template_id}' not found"} + + +@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, + ) + ], + }) + + +template_tools = [ + save_template, + list_templates, + apply_template, + delete_template, +] diff --git a/apps/agent/src/todos.py b/apps/agent/src/todos.py index b647fee..ce1f731 100644 --- a/apps/agent/src/todos.py +++ b/apps/agent/src/todos.py @@ -5,6 +5,8 @@ from typing import TypedDict, Literal import uuid +from src.templates import UITemplate + class Todo(TypedDict): id: str title: str @@ -14,6 +16,7 @@ class Todo(TypedDict): class AgentState(BaseAgentState): todos: list[Todo] + templates: list[UITemplate] @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..75d41c6 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -621,3 +621,19 @@ 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; } +} diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 7994182..c23ab68 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -1,26 +1,76 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState, useCallback } 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 { CopilotChat } from "@copilotkit/react-core/v2"; +import { useAgent } from "@copilotkit/react-core/v2"; export default function HomePage() { useGenerativeUIExamples(); useExampleSuggestions(); - // Widget bridge: handle openLink from widget iframes + const { agent } = useAgent(); + const [templateDrawerOpen, setTemplateDrawerOpen] = useState(false); + + // Save a template directly to agent state — no chat round-trip + const saveTemplate = useCallback((data: { + name: string; + title: string; + description: string; + html: string; + }) => { + const templates = agent.state?.templates || []; + const newTemplate = { + id: crypto.randomUUID(), + name: data.name || data.title || "Untitled Template", + description: data.description || data.title || "", + html: data.html, + data_description: "", + created_at: new Date().toISOString(), + version: 1, + }; + agent.setState({ templates: [...templates, newTemplate] }); + }, [agent]); + + // Send a prompt to the CopilotChat by finding its textarea and submitting + const sendPrompt = useCallback((text: string) => { + const input = document.querySelector( + '[class*="copilot"] textarea, [data-copilotkit] textarea' + ); + if (input) { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value" + )?.set; + setter?.call(input, text); + input.dispatchEvent(new Event("input", { bubbles: true })); + setTimeout(() => { + const form = input.closest("form"); + if (form) { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + } + }, 50); + } + }, []); + + // Widget bridge: handle messages from widget iframes useEffect(() => { const handler = (e: MessageEvent) => { if (e.data?.type === "open-link" && typeof e.data.url === "string") { window.open(e.data.url, "_blank", "noopener,noreferrer"); } + // Handle save-as-template from WidgetRenderer — save directly to state + if (e.data?.type === "save-as-template") { + saveTemplate(e.data); + } }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); - }, []); + }, [saveTemplate]); return ( <> @@ -58,19 +108,38 @@ export default function HomePage() { — powered by CopilotKit

- - Get started - +
+ {/* Template Library toggle */} + + + Get started + +
@@ -84,6 +153,13 @@ export default function HomePage() { + + {/* Template Library Drawer */} + setTemplateDrawerOpen(false)} + onSendPrompt={sendPrompt} + /> ); } diff --git a/apps/app/src/components/generative-ui/widget-renderer.tsx b/apps/app/src/components/generative-ui/widget-renderer.tsx index f978385..a69eb4c 100644 --- a/apps/app/src/components/generative-ui/widget-renderer.tsx +++ b/apps/app/src/components/generative-ui/widget-renderer.tsx @@ -421,10 +421,14 @@ function useLoadingPhrase(active: boolean) { } // ─── React Component ───────────────────────────────────────────────── +type SaveState = "idle" | "input" | "saving" | "saved"; + export function WidgetRenderer({ title, description, html }: WidgetRendererProps) { const iframeRef = useRef(null); const [height, setHeight] = useState(0); const [loaded, setLoaded] = useState(false); + const [saveState, setSaveState] = useState("idle"); + const [templateName, setTemplateName] = useState(""); // Track what html has been committed to the iframe to avoid redundant reloads const committedHtmlRef = useRef(""); @@ -473,8 +477,139 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps const showLoading = !!html && !ready; const loadingPhrase = useLoadingPhrase(showLoading); + const handleSaveTemplate = useCallback(() => { + const name = templateName.trim() || title || "Untitled Template"; + setSaveState("saving"); + window.postMessage( + { type: "save-as-template", name, title, description, html }, + "*" + ); + setTemplateName(""); + // Brief "saving" pulse then show "saved" confirmation + setTimeout(() => { + setSaveState("saved"); + // Return to idle after the confirmation + setTimeout(() => setSaveState("idle"), 1800); + }, 400); + }, [templateName, title, description, html]); + return ( -
+
+ {/* Save as Template — only shown when widget is ready */} + {ready && html && ( +
+ {/* ── Saved confirmation ── */} + {saveState === "saved" && ( +
+ + + + Saved! +
+ )} + + {/* ── Saving spinner ── */} + {saveState === "saving" && ( +
+
+ Saving... +
+ )} + + {/* ── Name input ── */} + {saveState === "input" && ( +
+ setTemplateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveTemplate(); + 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 bookmark button ── */} + {saveState === "idle" && ( + + )} +
+ )} {/* Loading indicator: visible until iframe is fully ready */} {showLoading && (
void; + onSendPrompt: (text: string) => void; +} + +interface Template { + id: string; + name: string; + description: string; + html: string; + data_description: string; + version: number; +} + +export function TemplateLibrary({ open, onClose, onSendPrompt }: TemplateLibraryProps) { + const { agent } = useAgent(); + const templates: Template[] = agent.state?.templates || []; + + const handleApply = (id: string, name: string) => { + onSendPrompt(`Apply the "${name}" template (id: ${id}) to my new data`); + onClose(); + }; + + const handleDelete = (id: string) => { + agent.setState({ + templates: templates.filter((t) => t.id !== id), + }); + }; + + return ( + <> + {/* Backdrop */} + {open && ( +
+ )} + + {/* Drawer panel */} +
+ {/* Header */} +
+
+ + + +

+ Templates +

+ + {templates.length} + +
+ +
+ + {/* Content */} +
+ {templates.length === 0 ? ( +
+ + + +

+ No templates yet +

+

+ Hover over a widget and click "Save as Template" to save it for reuse. +

+
+ ) : ( +
+ {templates.map((t) => ( + + ))} +
+ )} +
+
+ + ); +} diff --git a/apps/app/src/components/template-library/template-card.tsx b/apps/app/src/components/template-library/template-card.tsx new file mode 100644 index 0000000..304cbac --- /dev/null +++ b/apps/app/src/components/template-library/template-card.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useRef, useEffect, useState } from "react"; + +interface TemplateCardProps { + id: string; + name: string; + description: string; + html: string; + dataDescription: string; + version: number; + onApply: (id: string, name: string) => void; + onDelete: (id: string, name: string) => void; +} + +export function TemplateCard({ + id, + name, + description, + html, + dataDescription, + version, + onApply, + onDelete, +}: TemplateCardProps) { + const iframeRef = useRef(null); + const [previewReady, setPreviewReady] = useState(false); + + useEffect(() => { + if (!iframeRef.current || !html) return; + const doc = ` + +
${html}
`; + iframeRef.current.srcdoc = doc; + const timer = setTimeout(() => setPreviewReady(true), 500); + return () => clearTimeout(timer); + }, [html]); + + return ( +
+ {/* Preview */} +
+