Skip to content

Commit 91c93ee

Browse files
committed
Add WidgetTemplate and DynamicWidgetRoot/Component types
1 parent b925d14 commit 91c93ee

13 files changed

+910
-34
lines changed

chatkit/server.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
is_streaming_req,
7070
)
7171
from .version import __version__
72-
from .widgets import Markdown, Text, WidgetComponent, WidgetComponentBase, WidgetRoot
72+
from .widgets import WidgetComponent, WidgetComponentBase, WidgetRoot
7373

7474
DEFAULT_PAGE_SIZE = 20
7575
DEFAULT_ERROR_MESSAGE = "An error occurred when generating a response."
@@ -82,6 +82,11 @@ def diff_widget(
8282
Compare two WidgetRoots and return a list of deltas.
8383
"""
8484

85+
def is_streaming_text(component: WidgetComponentBase) -> bool:
86+
return getattr(component, "type", None) in {"Markdown", "Text"} and isinstance(
87+
getattr(component, "value", None), str
88+
)
89+
8590
def full_replace(before: WidgetComponentBase, after: WidgetComponentBase) -> bool:
8691
if (
8792
before.type != after.type
@@ -108,10 +113,10 @@ def full_replace_value(before_value: Any, after_value: Any) -> bool:
108113

109114
for field in before.model_fields_set.union(after.model_fields_set):
110115
if (
111-
isinstance(before, (Markdown, Text))
112-
and isinstance(after, (Markdown, Text))
116+
is_streaming_text(before)
117+
and is_streaming_text(after)
113118
and field == "value"
114-
and after.value.startswith(before.value)
119+
and getattr(after, "value", "").startswith(getattr(before, "value", ""))
115120
):
116121
# Appends to the value prop of Markdown or Text do not trigger a full replace
117122
continue
@@ -129,11 +134,11 @@ def full_replace_value(before_value: Any, after_value: Any) -> bool:
129134

130135
def find_all_streaming_text_components(
131136
component: WidgetComponent | WidgetRoot,
132-
) -> dict[str, Markdown | Text]:
137+
) -> dict[str, WidgetComponentBase]:
133138
components = {}
134139

135140
def recurse(component: WidgetComponent | WidgetRoot):
136-
if isinstance(component, (Markdown, Text)) and component.id:
141+
if is_streaming_text(component) and component.id:
137142
components[component.id] = component
138143

139144
if hasattr(component, "children"):
@@ -154,16 +159,19 @@ def recurse(component: WidgetComponent | WidgetRoot):
154159
f"Node {id} was not present when the widget was initially rendered. All nodes with ID must persist across all widget updates."
155160
)
156161

157-
if before_node.value != after_node.value:
158-
if not after_node.value.startswith(before_node.value):
162+
before_value = str(getattr(before_node, "value", None))
163+
after_value = str(getattr(after_node, "value", None))
164+
165+
if before_value != after_value:
166+
if not after_value.startswith(before_value):
159167
raise ValueError(
160168
f"Node {id} was updated with a new value that is not a prefix of the initial value. All widget updates must be cumulative."
161169
)
162-
done = not after_node.streaming
170+
done = not getattr(after_node, "streaming", False)
163171
deltas.append(
164172
WidgetStreamingTextValueDelta(
165173
component_id=id,
166-
delta=after_node.value[len(before_node.value) :],
174+
delta=after_value[len(before_value) :],
167175
done=done,
168176
)
169177
)

chatkit/widgets.py

Lines changed: 115 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
from __future__ import annotations
22

3+
import inspect
4+
import json
35
from datetime import datetime
6+
from pathlib import Path
47
from typing import (
58
Annotated,
9+
Any,
610
Literal,
711
)
812

13+
from jinja2 import Environment, StrictUndefined, Template
914
from pydantic import (
1015
BaseModel,
1116
ConfigDict,
1217
Field,
18+
TypeAdapter,
1319
model_serializer,
1420
)
1521
from typing_extensions import NotRequired, TypedDict
1622

1723
from .actions import ActionConfig
1824
from .icons import IconName
1925

26+
env = Environment(undefined=StrictUndefined)
27+
2028

2129
class ThemeColor(TypedDict):
2230
"""Color values for light and dark themes."""
@@ -1006,32 +1014,23 @@ class LineSeries(BaseModel):
10061014
"""Union of all supported chart series types."""
10071015

10081016

1009-
class BasicRoot(WidgetComponentBase):
1010-
"""Layout root capable of nesting components or other roots."""
1011-
1012-
type: Literal["Basic"] = Field(default="Basic", frozen=True) # pyright: ignore
1013-
children: list[WidgetComponent | WidgetRoot]
1014-
"""Children to render inside this root. Can include widget components or nested roots."""
1015-
theme: Literal["light", "dark"] | None = None
1016-
"""Force light or dark theme for this subtree."""
1017-
direction: Literal["row", "col"] | None = None
1018-
"""Flex direction for laying out direct children."""
1019-
gap: int | str | None = None
1020-
"""Gap between direct children; spacing unit or CSS string."""
1021-
padding: float | str | Spacing | None = None
1022-
"""Inner padding; spacing unit, CSS string, or padding object."""
1023-
align: Alignment | None = None
1024-
"""Cross-axis alignment of children."""
1025-
justify: Justification | None = None
1026-
"""Main-axis distribution of children."""
1027-
1028-
10291017
WidgetRoot = Annotated[
10301018
Card | ListView,
10311019
Field(discriminator="type"),
10321020
]
10331021

1034-
WidgetComponent = Annotated[
1022+
1023+
class DynamicWidgetComponent(WidgetComponentBase):
1024+
"""
1025+
A widget component with a statically defined base shape but dynamically
1026+
defined additional fields loaded from a widget template or JSON schema.
1027+
"""
1028+
1029+
model_config = ConfigDict(extra="allow")
1030+
children: list["DynamicWidgetComponent"] | None = None
1031+
1032+
1033+
StrictWidgetComponent = Annotated[
10351034
Text
10361035
| Title
10371036
| Caption
@@ -1058,8 +1057,103 @@ class BasicRoot(WidgetComponentBase):
10581057
| Transition,
10591058
Field(discriminator="type"),
10601059
]
1060+
1061+
1062+
StrictWidgetRoot = Annotated[
1063+
Card | ListView,
1064+
Field(discriminator="type"),
1065+
]
1066+
1067+
1068+
class DynamicWidgetRoot(DynamicWidgetComponent):
1069+
"""Dynamic root widget restricted to root types."""
1070+
1071+
type: Literal["Card", "ListView"] # pyright: ignore
1072+
1073+
1074+
class BasicRoot(WidgetComponentBase):
1075+
"""Layout root capable of nesting components or other roots."""
1076+
1077+
type: Literal["Basic"] = Field(default="Basic", frozen=True) # pyright: ignore
1078+
children: list[WidgetComponent | WidgetRoot]
1079+
"""Children to render inside this root. Can include widget components or nested roots."""
1080+
theme: Literal["light", "dark"] | None = None
1081+
"""Force light or dark theme for this subtree."""
1082+
direction: Literal["row", "col"] | None = None
1083+
"""Flex direction for laying out direct children."""
1084+
gap: int | str | None = None
1085+
"""Gap between direct children; spacing unit or CSS string."""
1086+
padding: float | str | Spacing | None = None
1087+
"""Inner padding; spacing unit, CSS string, or padding object."""
1088+
align: Alignment | None = None
1089+
"""Cross-axis alignment of children."""
1090+
justify: Justification | None = None
1091+
"""Main-axis distribution of children."""
1092+
1093+
1094+
WidgetComponent = StrictWidgetComponent | DynamicWidgetComponent
10611095
"""Union of all renderable widget components."""
10621096

1097+
WidgetRoot = StrictWidgetRoot | DynamicWidgetRoot
1098+
"""Union of all renderable top-level widgets."""
1099+
10631100

10641101
WidgetIcon = IconName
10651102
"""Icon names accepted by widgets that render icons."""
1103+
1104+
1105+
class WidgetTemplate:
1106+
"""
1107+
Utility for loading and building widgets from a .widget file.
1108+
1109+
Example using .widget file on disc:
1110+
```python
1111+
template = WidgetTemplate.from_file("path/to/my_widget.widget")
1112+
widget = template.build({"name": "Harry Potter"})
1113+
```
1114+
1115+
Example using already parsed widget definition:
1116+
```python
1117+
template = WidgetTemplate(definition={"version": "1.0", "name": "...", "template": Template(...), "jsonSchema": {...}})
1118+
widget = template.build({"name": "Harry Potter"})
1119+
```
1120+
"""
1121+
1122+
adapter: TypeAdapter[DynamicWidgetRoot] = TypeAdapter(DynamicWidgetRoot)
1123+
1124+
def __init__(self, definition: dict[str, Any]):
1125+
self.version = definition["version"]
1126+
if self.version != "1.0":
1127+
raise ValueError(f"Unsupported widget spec version: {self.version}")
1128+
1129+
self.name = definition["name"]
1130+
template = definition["template"]
1131+
if isinstance(template, Template):
1132+
self.template = template
1133+
else:
1134+
self.template = env.from_string(template)
1135+
self.data_schema = definition.get("jsonSchema", {})
1136+
1137+
@classmethod
1138+
def from_file(cls, file_path: str) -> "WidgetTemplate":
1139+
path = Path(file_path)
1140+
if not path.is_absolute():
1141+
caller_frame = inspect.stack()[1]
1142+
caller_path = Path(caller_frame.filename).resolve()
1143+
path = caller_path.parent / path
1144+
1145+
with path.open("r", encoding="utf-8") as file:
1146+
payload = json.load(file)
1147+
1148+
return cls(payload)
1149+
1150+
def build(
1151+
self, data: dict[str, Any] | BaseModel | None = None
1152+
) -> DynamicWidgetRoot:
1153+
if data is None:
1154+
data = {}
1155+
if isinstance(data, BaseModel):
1156+
data = data.model_dump()
1157+
rendered = self.template.render(**data)
1158+
widget_dict = json.loads(rendered)
1159+
return self.adapter.validate_python(widget_dict)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "1.3.1"
44
description = "A ChatKit backend SDK."
55
readme = "README.md"
66
requires-python = ">=3.10"
7-
dependencies = ["pydantic", "uvicorn", "openai", "openai-agents>=0.3.2"]
7+
dependencies = ["pydantic", "uvicorn", "openai", "openai-agents>=0.3.2", "jinja2"]
88

99
[dependency-groups]
1010
dev = [
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"type": "Card",
3+
"children": [
4+
{
5+
"type": "Col",
6+
"align": "center",
7+
"gap": 4,
8+
"padding": 4,
9+
"children": [
10+
{
11+
"type": "Box",
12+
"background": "green-400",
13+
"radius": "full",
14+
"padding": 3,
15+
"children": [
16+
{
17+
"type": "Icon",
18+
"name": "check",
19+
"size": "3xl",
20+
"color": "white"
21+
}
22+
]
23+
},
24+
{
25+
"type": "Col",
26+
"align": "center",
27+
"gap": 1,
28+
"children": [
29+
{
30+
"type": "Title",
31+
"value": "Enable notification"
32+
},
33+
{
34+
"type": "Text",
35+
"value": "Notify me when this item ships",
36+
"color": "secondary"
37+
}
38+
]
39+
}
40+
]
41+
},
42+
{
43+
"type": "Row",
44+
"children": [
45+
{
46+
"type": "Button",
47+
"label": "Yes",
48+
"block": true,
49+
"onClickAction": {
50+
"type": "notification.settings",
51+
"payload": {
52+
"enable": true
53+
}
54+
}
55+
},
56+
{
57+
"type": "Button",
58+
"label": "No",
59+
"block": true,
60+
"variant": "outline",
61+
"onClickAction": {
62+
"type": "notification.settings",
63+
"payload": {
64+
"enable": true
65+
}
66+
}
67+
}
68+
]
69+
}
70+
]
71+
}

0 commit comments

Comments
 (0)