forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgenerate_a2ui.py
More file actions
200 lines (171 loc) · 7.33 KB
/
Copy pathgenerate_a2ui.py
File metadata and controls
200 lines (171 loc) · 7.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""Dynamic A2UI tool: LLM-generated UI from conversation context.
This module provides the data preparation for a secondary LLM call that
generates v0.9 A2UI components. The actual LLM call is made by the
framework-specific wrapper (LangGraph, CrewAI, etc.) since each framework
has its own way of invoking LLMs.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
_logger = logging.getLogger(__name__)
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
# The render_a2ui tool schema that the secondary LLM is bound to.
RENDER_A2UI_TOOL_SCHEMA = {
"name": "render_a2ui",
"description": (
"Render a dynamic A2UI v0.9 surface.\n\n"
"Args:\n"
" surfaceId: Unique surface identifier.\n"
' catalogId: The catalog ID (use "copilotkit://app-dashboard-catalog").\n'
" components: A2UI v0.9 component array (flat format). "
'The root component must have id "root".\n'
" data: Optional initial data model for the surface."
),
"parameters": {
"type": "object",
"properties": {
"surfaceId": {
"type": "string",
"description": "Unique surface identifier.",
},
"catalogId": {"type": "string", "description": "The catalog ID."},
"components": {
"type": "array",
"items": {"type": "object"},
"description": "A2UI v0.9 component array (flat format).",
},
"data": {
"type": "object",
"description": "Optional initial data model for the surface.",
},
},
"required": ["surfaceId", "catalogId", "components"],
},
}
def generate_a2ui_impl(
messages: list[dict[str, Any]],
context_entries: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Prepare inputs for a secondary LLM call that generates A2UI components.
Returns a dict with:
- system_prompt: The system prompt for the secondary LLM (built from context)
- tool_schema: The render_a2ui tool schema to bind to the LLM
- tool_choice: The tool name to force
- messages: The conversation messages to pass through
- catalog_id: The default catalog ID
The framework wrapper should:
1. Make an LLM call with these inputs
2. Extract the tool call args (surfaceId, catalogId, components, data)
3. Build a2ui_operations from the args and return them
"""
context_text = ""
if context_entries:
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
return {
"system_prompt": context_text,
"tool_schema": RENDER_A2UI_TOOL_SCHEMA,
"tool_choice": "render_a2ui",
"messages": messages,
"catalog_id": CUSTOM_CATALOG_ID,
}
def _unstringify_json_fields(component: dict[str, Any]) -> dict[str, Any]:
"""Parse JSON-string fields back to Python values where the schema
expects structured data.
Gemini's structured-output sometimes emits `"data": "[{...}]"` (a JSON
string) instead of `"data": [...]` (the actual array) for fields
declared with an "any" type in the schema. The React A2UI renderer
expects real arrays/objects on data props — strings render as
"No data available" on charts. We round-trip those known structured
fields through json.loads so the renderer sees the right type.
Returns a new dict (does not mutate the input).
"""
out = dict(component)
for field in ("data", "value", "children"):
v = out.get(field)
if isinstance(v, str) and v.strip().startswith(("[", "{")):
try:
out[field] = json.loads(v)
except (ValueError, TypeError):
# Leave the raw string in place if it doesn't parse — the
# renderer will still receive a defined value rather than
# nothing, and downstream code can decide what to do.
pass
return out
def _sanitize_a2ui_components(raw: Any) -> list[dict[str, Any]]:
"""Drop entries that aren't dicts or are missing `id`/`component`,
then unstringify any JSON-as-string fields the model emitted.
Mirrors `langgraph-python/src/agents/_a2ui_utils.py:sanitize_a2ui_components`
with an added pass for Gemini's stringified `data` quirk.
"""
if not isinstance(raw, list):
return []
return [
_unstringify_json_fields(c)
for c in raw
if isinstance(c, dict) and c.get("id") and c.get("component")
]
def _has_root_component(components: list[dict[str, Any]]) -> bool:
"""True iff `components` contains an entry with `id == "root"`.
Mirrors `langgraph-python/src/agents/_a2ui_utils.py:has_root_component`.
"""
return any(c.get("id") == "root" for c in components)
def build_a2ui_operations_from_tool_call(args: dict[str, Any]) -> dict[str, Any]:
"""Build a2ui_operations dict from the secondary LLM's tool call args.
Call this after the framework wrapper extracts the tool call arguments.
Emits the v0.9 NESTED operation shape that
`@ag-ui/a2ui-middleware`'s `getOperationSurfaceId` and the React
A2UI renderer recognize:
{ "version": "v0.9", "createSurface": { surfaceId, catalogId } }
{ "version": "v0.9", "updateComponents": { surfaceId, components } }
{ "version": "v0.9", "updateDataModel": { surfaceId, path, value } }
The legacy flat shape (`{type: "create_surface", surfaceId, ...}`)
looked plausible but the middleware's matcher only walks the nested
`createSurface` / `updateComponents` / `updateDataModel` keys; when
those were absent it grouped every op under the fallback `"default"`
surface and the renderer never received the schema. Mirrors
`copilotkit.a2ui.create_surface` / `update_components` /
`update_data_model` from the langgraph-python north-star.
"""
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
# Drop empty/malformed component entries before forwarding. Without
# this, the renderer errors on the first `undefined` id.
components = _sanitize_a2ui_components(args.get("components", []))
if not components:
_logger.warning(
"build_a2ui_operations_from_tool_call: all components were "
"dropped by sanitization (LLM emitted empty {} entries)"
)
elif not _has_root_component(components):
_logger.warning(
"build_a2ui_operations_from_tool_call: no component with id "
"'root' — the renderer will error with 'no root component'"
)
data = args.get("data")
ops: list[dict[str, Any]] = [
{
"version": "v0.9",
"createSurface": {"surfaceId": surface_id, "catalogId": catalog_id},
},
{
"version": "v0.9",
"updateComponents": {"surfaceId": surface_id, "components": components},
},
]
if data:
ops.append(
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": surface_id,
"path": "/",
"value": data,
},
}
)
return {"a2ui_operations": ops}