Skip to content

Commit ba2b133

Browse files
authored
Merge pull request #20 from breadoncee/feat/ui-template-system
feat: UI template system for saving and reusing generated widgets
2 parents 178d63b + 2b78e17 commit ba2b133

15 files changed

Lines changed: 1620 additions & 138 deletions

File tree

apps/agent/main.py

Lines changed: 24 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,28 @@
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+
You have backend tools: `save_template`, `list_templates`, `apply_template`, `delete_template`.
56+
57+
**When a user asks to apply/recreate a template with new data:**
58+
Check `pending_template` in state — the frontend sets this when the user picks a template.
59+
If `pending_template` is present (has `id` and `name`):
60+
1. Call `apply_template(template_id=pending_template["id"])` to retrieve the HTML
61+
2. Take the returned HTML and COPY IT EXACTLY, only replacing the data values
62+
(names, numbers, dates, labels, amounts) to match the user's message
63+
3. Render the modified HTML using `widgetRenderer`
64+
4. Call `clear_pending_template` to reset the pending state
65+
66+
If no `pending_template` is set but the user mentions a template by name, use
67+
`apply_template(name="...")` instead.
68+
69+
CRITICAL: Do NOT rewrite or generate HTML from scratch. Take the original HTML string,
70+
find-and-replace ONLY the data values, and pass the result to widgetRenderer.
71+
This preserves the exact layout and styling of the original template.
72+
For bar/pie chart templates, use `barChart` or `pieChart` component instead.
5073
""",
5174
)
5275

apps/agent/src/templates.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
from langchain.tools import ToolRuntime, tool
2+
from langchain.messages import ToolMessage
3+
from langgraph.types import Command
4+
from typing import Any, Optional, TypedDict
5+
import uuid
6+
from datetime import datetime
7+
8+
9+
class UITemplate(TypedDict, total=False):
10+
id: str
11+
name: str
12+
description: str
13+
html: str
14+
data_description: str
15+
created_at: str
16+
version: int
17+
component_type: Optional[str]
18+
component_data: Optional[dict[str, Any]]
19+
20+
21+
# Built-in seed templates — must stay in sync with apps/app/src/components/template-library/seed-templates.ts
22+
# Only id, name, description, html, and data_description are needed for apply_template lookups.
23+
SEED_TEMPLATES: list[UITemplate] = [
24+
{
25+
"id": "seed-weather-001",
26+
"name": "Weather",
27+
"description": "Current weather conditions card with temperature, humidity, wind, and UV index",
28+
"html": "", # Populated at module load from _SEED_HTML below
29+
"data_description": "City name, date, temperature, condition, humidity, wind speed/direction, UV index",
30+
"version": 1,
31+
},
32+
{
33+
"id": "seed-invoice-001",
34+
"name": "Invoice Card",
35+
"description": "Compact invoice card with amount, client info, and action buttons",
36+
"html": "",
37+
"data_description": "Title, amount, description, client name, billing month, invoice number, due date",
38+
"version": 1,
39+
},
40+
{
41+
"id": "seed-dashboard-001",
42+
"name": "Dashboard",
43+
"description": "KPI dashboard with metrics cards and bar chart for quarterly performance",
44+
"html": "",
45+
"data_description": "Title, subtitle, KPI labels/values/changes, monthly bar chart data, legend items",
46+
"version": 1,
47+
},
48+
]
49+
50+
# Load seed HTML from the frontend source so there's a single source of truth.
51+
# If the file isn't available (e.g. in a standalone agent deploy), seeds will
52+
# still be discoverable by name but with empty HTML — the agent can regenerate.
53+
def _load_seed_html() -> None:
54+
from pathlib import Path
55+
56+
seed_file = Path(__file__).resolve().parents[2] / "app" / "src" / "components" / "template-library" / "seed-templates.ts"
57+
if not seed_file.exists():
58+
return
59+
text = seed_file.read_text()
60+
# Map TS variable names to seed IDs
61+
mapping = {
62+
"weatherHtml": "seed-weather-001",
63+
"invoiceHtml": "seed-invoice-001",
64+
"dashboardHtml": "seed-dashboard-001",
65+
}
66+
for var_name, seed_id in mapping.items():
67+
# Extract template literal content between first ` and last `
68+
marker = f"const {var_name} = `"
69+
start = text.find(marker)
70+
if start == -1:
71+
continue
72+
start += len(marker)
73+
end = text.find("`;", start)
74+
if end == -1:
75+
continue
76+
html = text[start:end]
77+
for seed in SEED_TEMPLATES:
78+
if seed["id"] == seed_id:
79+
seed["html"] = html
80+
break
81+
82+
_load_seed_html()
83+
84+
85+
@tool
86+
def save_template(
87+
name: str,
88+
description: str,
89+
html: str,
90+
data_description: str,
91+
runtime: ToolRuntime,
92+
) -> Command:
93+
"""
94+
Save a generated UI as a reusable template.
95+
Call this when the user wants to save a widget/visualization they liked for reuse later.
96+
97+
Args:
98+
name: Short name for the template (e.g. "Invoice", "Dashboard")
99+
description: What the template displays or does
100+
html: The raw HTML string of the widget to save as a template
101+
data_description: Description of the data shape this template expects
102+
"""
103+
templates = list(runtime.state.get("templates", []))
104+
105+
template: UITemplate = {
106+
"id": str(uuid.uuid4()),
107+
"name": name,
108+
"description": description,
109+
"html": html,
110+
"data_description": data_description,
111+
"created_at": datetime.now().isoformat(),
112+
"version": 1,
113+
}
114+
templates.append(template)
115+
116+
return Command(update={
117+
"templates": templates,
118+
"messages": [
119+
ToolMessage(
120+
content=f"Template '{name}' saved successfully (id: {template['id']})",
121+
tool_call_id=runtime.tool_call_id,
122+
)
123+
],
124+
})
125+
126+
127+
@tool
128+
def list_templates(runtime: ToolRuntime):
129+
"""
130+
List all saved UI templates, including built-in seed templates.
131+
Returns template summaries (id, name, description, data_description).
132+
"""
133+
state_templates = runtime.state.get("templates", [])
134+
state_ids = {t["id"] for t in state_templates}
135+
templates = [*state_templates, *(s for s in SEED_TEMPLATES if s["id"] not in state_ids)]
136+
return [
137+
{
138+
"id": t["id"],
139+
"name": t["name"],
140+
"description": t["description"],
141+
"data_description": t["data_description"],
142+
"version": t["version"],
143+
}
144+
for t in templates
145+
]
146+
147+
148+
@tool
149+
def apply_template(runtime: ToolRuntime, name: str = "", template_id: str = ""):
150+
"""
151+
Retrieve a saved template's HTML so you can adapt it with new data.
152+
After calling this, generate a NEW widget in the same style and render via widgetRenderer.
153+
154+
This tool automatically checks for a pending_template in state (set by the
155+
frontend when the user picks a template from the library). If pending_template
156+
is present, it takes priority over name/template_id arguments.
157+
158+
Also searches built-in seed templates, so users can apply them by name in chat
159+
even if the frontend hasn't pushed them into agent state yet.
160+
161+
Args:
162+
name: The name of the template to apply (fallback if no pending_template)
163+
template_id: The ID of the template to apply (fallback if no pending_template)
164+
"""
165+
state_templates = runtime.state.get("templates", [])
166+
state_ids = {t["id"] for t in state_templates}
167+
templates = [*state_templates, *(s for s in SEED_TEMPLATES if s["id"] not in state_ids)]
168+
169+
# Check pending_template from frontend first — this is the most reliable source
170+
pending = runtime.state.get("pending_template")
171+
if pending and pending.get("id"):
172+
template_id = pending["id"]
173+
174+
# Look up by ID first
175+
if template_id:
176+
for t in templates:
177+
if t["id"] == template_id:
178+
return {
179+
"name": t["name"],
180+
"description": t["description"],
181+
"html": t["html"],
182+
"data_description": t.get("data_description", ""),
183+
}
184+
return {"error": f"Template with id '{template_id}' not found"}
185+
186+
# Look up by name (most recent match)
187+
if name:
188+
matches = [t for t in templates if t["name"].lower() == name.lower()]
189+
if matches:
190+
t = max(matches, key=lambda x: x.get("created_at", ""))
191+
return {
192+
"name": t["name"],
193+
"description": t["description"],
194+
"html": t["html"],
195+
"data_description": t.get("data_description", ""),
196+
}
197+
return {"error": f"No template named '{name}' found"}
198+
199+
return {"error": "Provide either a name or template_id"}
200+
201+
202+
@tool
203+
def delete_template(template_id: str, runtime: ToolRuntime) -> Command:
204+
"""
205+
Delete a saved UI template.
206+
207+
Args:
208+
template_id: The ID of the template to delete
209+
"""
210+
templates = list(runtime.state.get("templates", []))
211+
original_len = len(templates)
212+
templates = [t for t in templates if t["id"] != template_id]
213+
214+
if len(templates) == original_len:
215+
return Command(update={
216+
"messages": [
217+
ToolMessage(
218+
content=f"Template with id '{template_id}' not found",
219+
tool_call_id=runtime.tool_call_id,
220+
)
221+
],
222+
})
223+
224+
return Command(update={
225+
"templates": templates,
226+
"messages": [
227+
ToolMessage(
228+
content=f"Template deleted successfully",
229+
tool_call_id=runtime.tool_call_id,
230+
)
231+
],
232+
})
233+
234+
235+
@tool
236+
def clear_pending_template(runtime: ToolRuntime) -> Command:
237+
"""
238+
Clear the pending_template from state after applying it.
239+
Call this after you have finished applying a template.
240+
"""
241+
return Command(update={
242+
"pending_template": None,
243+
"messages": [
244+
ToolMessage(
245+
content="Pending template cleared",
246+
tool_call_id=runtime.tool_call_id,
247+
)
248+
],
249+
})
250+
251+
252+
template_tools = [
253+
save_template,
254+
list_templates,
255+
apply_template,
256+
delete_template,
257+
clear_pending_template,
258+
]

apps/agent/src/todos.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,26 @@
22
from langchain.tools import ToolRuntime, tool
33
from langchain.messages import ToolMessage
44
from langgraph.types import Command
5-
from typing import TypedDict, Literal
5+
from typing import Optional, TypedDict, Literal
66
import uuid
77

8+
from src.templates import UITemplate
9+
810
class Todo(TypedDict):
911
id: str
1012
title: str
1113
description: str
1214
emoji: str
1315
status: Literal["pending", "completed"]
1416

17+
class PendingTemplate(TypedDict, total=False):
18+
id: str
19+
name: str
20+
1521
class AgentState(BaseAgentState):
1622
todos: list[Todo]
23+
templates: list[UITemplate]
24+
pending_template: Optional[PendingTemplate]
1725

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

apps/app/src/app/favicon.ico

-25.3 KB
Binary file not shown.

apps/app/src/app/globals.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,3 +621,24 @@ 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+
}
640+
641+
@keyframes chipIn {
642+
from { transform: scale(0.9); opacity: 0; }
643+
to { transform: scale(1); opacity: 1; }
644+
}

apps/app/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export default function RootLayout({children}: Readonly<{ children: React.ReactN
1010
return (
1111
<html lang="en">
1212
<head>
13+
<title>Open Generative UI</title>
14+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🪁</text></svg>" />
1315
<link rel="preconnect" href="https://fonts.googleapis.com" />
1416
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
1517
<link

0 commit comments

Comments
 (0)