Skip to content

Commit 7e5fc35

Browse files
breadonceejerelvelarde
authored andcommitted
feat: add UI template system for saving and reusing generated widgets
- 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
1 parent 178d63b commit 7e5fc35

8 files changed

Lines changed: 670 additions & 18 deletions

File tree

apps/agent/main.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
from src.query import query_data
1111
from src.todos import AgentState, todo_tools
1212
from src.form import generate_form
13+
from src.templates import template_tools
1314
from skills import load_all_skills
1415

1516
# Load all visualization skills
1617
_skills_text = load_all_skills()
1718

1819
agent = create_agent(
1920
model=ChatOpenAI(model="gpt-5.4-2026-03-05"),
20-
tools=[query_data, *todo_tools, generate_form],
21+
tools=[query_data, *todo_tools, generate_form, *template_tools],
2122
middleware=[CopilotKitMiddleware()],
2223
state_schema=AgentState,
2324
system_prompt=f"""
@@ -47,6 +48,20 @@
4748
Follow the skills below for how to produce high-quality visuals:
4849
4950
{_skills_text}
51+
52+
## UI Templates
53+
54+
Users can save generated UIs as reusable templates and apply them later:
55+
56+
- When a user asks to save a widget as a template, call `save_template` with the
57+
widget's HTML, a short name, description, and a description of the data shape.
58+
- When a user asks to apply a template, first call `list_templates` to find the
59+
right one, then call `apply_template` to get its HTML. Adapt the HTML with the
60+
user's new data and render via `widgetRenderer`.
61+
- When a user asks to see their templates, call `list_templates`.
62+
- When a user asks to delete a template, call `delete_template`.
63+
- A "save-as-template" message from the frontend means the user clicked the save
64+
button on a widget. Extract the template details and call `save_template`.
5065
""",
5166
)
5267

apps/agent/src/templates.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from langchain.tools import ToolRuntime, tool
2+
from langchain.messages import ToolMessage
3+
from langgraph.types import Command
4+
from typing import TypedDict
5+
import uuid
6+
from datetime import datetime
7+
8+
9+
class UITemplate(TypedDict):
10+
id: str
11+
name: str
12+
description: str
13+
html: str
14+
data_description: str
15+
created_at: str
16+
version: int
17+
18+
19+
@tool
20+
def save_template(
21+
name: str,
22+
description: str,
23+
html: str,
24+
data_description: str,
25+
runtime: ToolRuntime,
26+
) -> Command:
27+
"""
28+
Save a generated UI as a reusable template.
29+
Call this when the user wants to save a widget/visualization they liked for reuse later.
30+
31+
Args:
32+
name: Short name for the template (e.g. "Invoice", "Dashboard")
33+
description: What the template displays or does
34+
html: The raw HTML string of the widget to save as a template
35+
data_description: Description of the data shape this template expects
36+
"""
37+
templates = list(runtime.state.get("templates", []))
38+
39+
template: UITemplate = {
40+
"id": str(uuid.uuid4()),
41+
"name": name,
42+
"description": description,
43+
"html": html,
44+
"data_description": data_description,
45+
"created_at": datetime.now().isoformat(),
46+
"version": 1,
47+
}
48+
templates.append(template)
49+
50+
return Command(update={
51+
"templates": templates,
52+
"messages": [
53+
ToolMessage(
54+
content=f"Template '{name}' saved successfully (id: {template['id']})",
55+
tool_call_id=runtime.tool_call_id,
56+
)
57+
],
58+
})
59+
60+
61+
@tool
62+
def list_templates(runtime: ToolRuntime):
63+
"""
64+
List all saved UI templates. Returns template summaries (id, name, description, data_description).
65+
"""
66+
templates = runtime.state.get("templates", [])
67+
return [
68+
{
69+
"id": t["id"],
70+
"name": t["name"],
71+
"description": t["description"],
72+
"data_description": t["data_description"],
73+
"version": t["version"],
74+
}
75+
for t in templates
76+
]
77+
78+
79+
@tool
80+
def apply_template(template_id: str, runtime: ToolRuntime):
81+
"""
82+
Retrieve a saved template's HTML so you can adapt it with new data.
83+
After calling this, modify the HTML to fit the user's new data and render it via widgetRenderer.
84+
85+
Args:
86+
template_id: The ID of the template to apply
87+
"""
88+
templates = runtime.state.get("templates", [])
89+
for t in templates:
90+
if t["id"] == template_id:
91+
return {
92+
"name": t["name"],
93+
"description": t["description"],
94+
"html": t["html"],
95+
"data_description": t["data_description"],
96+
}
97+
return {"error": f"Template with id '{template_id}' not found"}
98+
99+
100+
@tool
101+
def delete_template(template_id: str, runtime: ToolRuntime) -> Command:
102+
"""
103+
Delete a saved UI template.
104+
105+
Args:
106+
template_id: The ID of the template to delete
107+
"""
108+
templates = list(runtime.state.get("templates", []))
109+
original_len = len(templates)
110+
templates = [t for t in templates if t["id"] != template_id]
111+
112+
if len(templates) == original_len:
113+
return Command(update={
114+
"messages": [
115+
ToolMessage(
116+
content=f"Template with id '{template_id}' not found",
117+
tool_call_id=runtime.tool_call_id,
118+
)
119+
],
120+
})
121+
122+
return Command(update={
123+
"templates": templates,
124+
"messages": [
125+
ToolMessage(
126+
content=f"Template deleted successfully",
127+
tool_call_id=runtime.tool_call_id,
128+
)
129+
],
130+
})
131+
132+
133+
template_tools = [
134+
save_template,
135+
list_templates,
136+
apply_template,
137+
delete_template,
138+
]

apps/agent/src/todos.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from typing import TypedDict, Literal
66
import uuid
77

8+
from src.templates import UITemplate
9+
810
class Todo(TypedDict):
911
id: str
1012
title: str
@@ -14,6 +16,7 @@ class Todo(TypedDict):
1416

1517
class AgentState(BaseAgentState):
1618
todos: list[Todo]
19+
templates: list[UITemplate]
1720

1821
@tool
1922
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:

apps/app/src/app/globals.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,3 +621,19 @@ body, html {
621621
@keyframes spin {
622622
to { transform: rotate(360deg); }
623623
}
624+
625+
/* Template save animations */
626+
@keyframes tmpl-pop {
627+
0% { transform: scale(0.8); opacity: 0; }
628+
50% { transform: scale(1.05); }
629+
100% { transform: scale(1); opacity: 1; }
630+
}
631+
632+
@keyframes tmpl-check {
633+
to { stroke-dashoffset: 0; }
634+
}
635+
636+
@keyframes tmpl-slideIn {
637+
from { transform: translateY(-4px) scale(0.97); opacity: 0; }
638+
to { transform: translateY(0) scale(1); opacity: 1; }
639+
}

apps/app/src/app/page.tsx

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,76 @@
11
"use client";
22

3-
import { useEffect } from "react";
3+
import { useEffect, useState, useCallback } from "react";
44
import { ExampleLayout } from "@/components/example-layout";
55
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
66
import { ExplainerCardsPortal } from "@/components/explainer-cards";
7+
import { TemplateLibrary } from "@/components/template-library";
78

89
import { CopilotChat } from "@copilotkit/react-core/v2";
10+
import { useAgent } from "@copilotkit/react-core/v2";
911

1012
export default function HomePage() {
1113
useGenerativeUIExamples();
1214
useExampleSuggestions();
1315

14-
// Widget bridge: handle openLink from widget iframes
16+
const { agent } = useAgent();
17+
const [templateDrawerOpen, setTemplateDrawerOpen] = useState(false);
18+
19+
// Save a template directly to agent state — no chat round-trip
20+
const saveTemplate = useCallback((data: {
21+
name: string;
22+
title: string;
23+
description: string;
24+
html: string;
25+
}) => {
26+
const templates = agent.state?.templates || [];
27+
const newTemplate = {
28+
id: crypto.randomUUID(),
29+
name: data.name || data.title || "Untitled Template",
30+
description: data.description || data.title || "",
31+
html: data.html,
32+
data_description: "",
33+
created_at: new Date().toISOString(),
34+
version: 1,
35+
};
36+
agent.setState({ templates: [...templates, newTemplate] });
37+
}, [agent]);
38+
39+
// Send a prompt to the CopilotChat by finding its textarea and submitting
40+
const sendPrompt = useCallback((text: string) => {
41+
const input = document.querySelector<HTMLTextAreaElement>(
42+
'[class*="copilot"] textarea, [data-copilotkit] textarea'
43+
);
44+
if (input) {
45+
const setter = Object.getOwnPropertyDescriptor(
46+
window.HTMLTextAreaElement.prototype,
47+
"value"
48+
)?.set;
49+
setter?.call(input, text);
50+
input.dispatchEvent(new Event("input", { bubbles: true }));
51+
setTimeout(() => {
52+
const form = input.closest("form");
53+
if (form) {
54+
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
55+
}
56+
}, 50);
57+
}
58+
}, []);
59+
60+
// Widget bridge: handle messages from widget iframes
1561
useEffect(() => {
1662
const handler = (e: MessageEvent) => {
1763
if (e.data?.type === "open-link" && typeof e.data.url === "string") {
1864
window.open(e.data.url, "_blank", "noopener,noreferrer");
1965
}
66+
// Handle save-as-template from WidgetRenderer — save directly to state
67+
if (e.data?.type === "save-as-template") {
68+
saveTemplate(e.data);
69+
}
2070
};
2171
window.addEventListener("message", handler);
2272
return () => window.removeEventListener("message", handler);
23-
}, []);
73+
}, [saveTemplate]);
2474

2575
return (
2676
<>
@@ -58,19 +108,38 @@ export default function HomePage() {
58108
<span className="font-normal" style={{ color: "var(--text-secondary)" }}> — powered by CopilotKit</span>
59109
</p>
60110
</div>
61-
<a
62-
href="https://github.com/CopilotKit/OpenGenerativeUI"
63-
target="_blank"
64-
rel="noopener noreferrer"
65-
className="inline-flex items-center px-5 py-2 rounded-full text-sm font-semibold text-white no-underline whitespace-nowrap transition-all duration-150 hover:-translate-y-px"
66-
style={{
67-
background: "linear-gradient(135deg, var(--color-lilac-dark), var(--color-mint-dark))",
68-
boxShadow: "0 1px 4px rgba(149,153,204,0.3)",
69-
fontFamily: "var(--font-family)",
70-
}}
71-
>
72-
Get started
73-
</a>
111+
<div className="flex items-center gap-2">
112+
{/* Template Library toggle */}
113+
<button
114+
onClick={() => setTemplateDrawerOpen(true)}
115+
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium no-underline whitespace-nowrap transition-all duration-150 hover:-translate-y-px"
116+
style={{
117+
color: "var(--text-secondary)",
118+
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
119+
background: "var(--surface-primary, rgba(255,255,255,0.6))",
120+
fontFamily: "var(--font-family)",
121+
}}
122+
title="Open Template Library"
123+
>
124+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
125+
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
126+
</svg>
127+
Templates
128+
</button>
129+
<a
130+
href="https://github.com/CopilotKit/OpenGenerativeUI"
131+
target="_blank"
132+
rel="noopener noreferrer"
133+
className="inline-flex items-center px-5 py-2 rounded-full text-sm font-semibold text-white no-underline whitespace-nowrap transition-all duration-150 hover:-translate-y-px"
134+
style={{
135+
background: "linear-gradient(135deg, var(--color-lilac-dark), var(--color-mint-dark))",
136+
boxShadow: "0 1px 4px rgba(149,153,204,0.3)",
137+
fontFamily: "var(--font-family)",
138+
}}
139+
>
140+
Get started
141+
</a>
142+
</div>
74143
</div>
75144
</div>
76145

@@ -84,6 +153,13 @@ export default function HomePage() {
84153
<ExplainerCardsPortal />
85154
</div>
86155
</div>
156+
157+
{/* Template Library Drawer */}
158+
<TemplateLibrary
159+
open={templateDrawerOpen}
160+
onClose={() => setTemplateDrawerOpen(false)}
161+
onSendPrompt={sendPrompt}
162+
/>
87163
</>
88164
);
89165
}

0 commit comments

Comments
 (0)