Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7e5fc35
feat: add UI template system for saving and reusing generated widgets
breadoncee Mar 20, 2026
50d00e2
fix: address code review feedback
breadoncee Mar 20, 2026
245b314
feat: progressive streaming rendering for widget previews
breadoncee Mar 20, 2026
1e9d11d
feat: improve template apply UX with data input step
breadoncee Mar 21, 2026
3e8114b
refactor(generative-ui): extract save-template overlay into shared co…
breadoncee Mar 23, 2026
fd91f3d
feat(template-library): add mini chart previews to template cards
breadoncee Mar 23, 2026
a3212ef
feat(templates): improve apply flow with ID-based lookup and chat sub…
breadoncee Mar 23, 2026
225b461
refactor(app): remove dead template save relay from page component
breadoncee Mar 23, 2026
c54cf1d
Fix PR review: security, lint errors, and schema alignment
GeneralJerel Mar 23, 2026
0e6d8cb
Restore apply_template runtime default (required by Python syntax)
GeneralJerel Mar 23, 2026
6b272d6
Fix widget not rendering when initial HTML has unclosed tags
GeneralJerel Mar 23, 2026
5185bbb
feat(templates): inline template chip in chat input and fix state loss
GeneralJerel Mar 24, 2026
c15ab64
refactor(templates): simplify apply flow to chip-only
GeneralJerel Mar 24, 2026
459e446
feat(templates): seed 3 built-in templates on first load
GeneralJerel Mar 24, 2026
3386854
fix(templates): reliable apply via pending_template and non-overlappi…
GeneralJerel Mar 24, 2026
f958184
feat(templates): replace calculator seed with dashboard template
GeneralJerel Mar 24, 2026
b24a7a9
fix(templates): resolve review findings — security comment, chip fall…
GeneralJerel Mar 24, 2026
917d72b
fix(templates): seed delete bug, backend seed lookup, and delete butt…
GeneralJerel Mar 24, 2026
2b78e17
feat(ui): use kite emoji as logo/favicon and set page title
GeneralJerel Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion apps/agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
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
_skills_text = load_all_skills()

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"""
Expand Down Expand Up @@ -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.
""",
)

Expand Down
258 changes: 258 additions & 0 deletions apps/agent/src/templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
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]]


# Built-in seed templates — must stay in sync with apps/app/src/components/template-library/seed-templates.ts
# Only id, name, description, html, and data_description are needed for apply_template lookups.
SEED_TEMPLATES: list[UITemplate] = [
{
"id": "seed-weather-001",
"name": "Weather",
"description": "Current weather conditions card with temperature, humidity, wind, and UV index",
"html": "", # Populated at module load from _SEED_HTML below
"data_description": "City name, date, temperature, condition, humidity, wind speed/direction, UV index",
"version": 1,
},
{
"id": "seed-invoice-001",
"name": "Invoice Card",
"description": "Compact invoice card with amount, client info, and action buttons",
"html": "",
"data_description": "Title, amount, description, client name, billing month, invoice number, due date",
"version": 1,
},
{
"id": "seed-dashboard-001",
"name": "Dashboard",
"description": "KPI dashboard with metrics cards and bar chart for quarterly performance",
"html": "",
"data_description": "Title, subtitle, KPI labels/values/changes, monthly bar chart data, legend items",
"version": 1,
},
]

# Load seed HTML from the frontend source so there's a single source of truth.
# If the file isn't available (e.g. in a standalone agent deploy), seeds will
# still be discoverable by name but with empty HTML — the agent can regenerate.
def _load_seed_html() -> None:
from pathlib import Path

seed_file = Path(__file__).resolve().parents[2] / "app" / "src" / "components" / "template-library" / "seed-templates.ts"
if not seed_file.exists():
return
text = seed_file.read_text()
# Map TS variable names to seed IDs
mapping = {
"weatherHtml": "seed-weather-001",
"invoiceHtml": "seed-invoice-001",
"dashboardHtml": "seed-dashboard-001",
}
for var_name, seed_id in mapping.items():
# Extract template literal content between first ` and last `
marker = f"const {var_name} = `"
start = text.find(marker)
if start == -1:
continue
start += len(marker)
end = text.find("`;", start)
if end == -1:
continue
html = text[start:end]
for seed in SEED_TEMPLATES:
if seed["id"] == seed_id:
seed["html"] = html
break

_load_seed_html()


@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, including built-in seed templates.
Returns template summaries (id, name, description, data_description).
"""
state_templates = runtime.state.get("templates", [])
state_ids = {t["id"] for t in state_templates}
templates = [*state_templates, *(s for s in SEED_TEMPLATES if s["id"] not in state_ids)]
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(runtime: ToolRuntime, name: str = "", template_id: str = ""):
"""
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.

Also searches built-in seed templates, so users can apply them by name in chat
even if the frontend hasn't pushed them into agent state yet.

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)
"""
state_templates = runtime.state.get("templates", [])
state_ids = {t["id"] for t in state_templates}
templates = [*state_templates, *(s for s in SEED_TEMPLATES if s["id"] not in state_ids)]

# 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,
]
10 changes: 9 additions & 1 deletion apps/agent/src/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,26 @@
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
description: str
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:
Expand Down
Binary file removed apps/app/src/app/favicon.ico
Binary file not shown.
21 changes: 21 additions & 0 deletions apps/app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
2 changes: 2 additions & 0 deletions apps/app/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export default function RootLayout({children}: Readonly<{ children: React.ReactN
return (
<html lang="en">
<head>
<title>Open Generative UI</title>
<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>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
Expand Down
Loading