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..38e66c2 --- /dev/null +++ b/apps/agent/src/templates.py @@ -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, +] 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/favicon.ico b/apps/app/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/app/src/app/favicon.ico and /dev/null differ 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/layout.tsx b/apps/app/src/app/layout.tsx index 26a81a7..112234f 100644 --- a/apps/app/src/app/layout.tsx +++ b/apps/app/src/app/layout.tsx @@ -10,6 +10,8 @@ export default function RootLayout({children}: Readonly<{ children: React.ReactN return (
+Open Generative UI — powered by CopilotKit
{description}
-{description}
+- {description} -
-+ {description} +
+