Skip to content

Commit c05abd8

Browse files
committed
feat(ui): add streaming support to canvas artifacts
Signed-off-by: Petr Kadlec <petr@puradesign.cz>
1 parent 38a2d79 commit c05abd8

File tree

16 files changed

+1321
-1214
lines changed

16 files changed

+1321
-1214
lines changed

apps/agentstack-sdk-py/examples/artifacts_test_agent.py renamed to apps/agentstack-sdk-py/examples/canvas_ui_test_agent.py

Lines changed: 73 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import asyncio
45
import os
56
import random
67
import re
@@ -29,6 +30,28 @@
2930
]
3031

3132

33+
def generate_recipe_response(recipe_title: str, description: str, closing_message: str) -> str:
34+
"""Generate a recipe response with the given title, description, and closing message."""
35+
return f"""\
36+
{description}
37+
38+
```recipe
39+
# {recipe_title}
40+
41+
## Ingredients
42+
- bread (1 slice)
43+
- butter (1 slice)
44+
45+
## Instructions
46+
1. Cut a slice of bread.
47+
2. Cut a slice of butter.
48+
3. Spread the slice of butter on the slice of bread.
49+
```
50+
51+
{closing_message}
52+
"""
53+
54+
3255
@server.agent(
3356
name="Canvas example agent",
3457
)
@@ -49,81 +72,85 @@ async def artifacts_agent(
4972
print(f"Canvas Edit Request: {canvas_edit_request}")
5073

5174
if canvas_edit_request:
52-
recipe_title = "Canvas Recipe EDITED"
53-
5475
original_recipe = (
5576
canvas_edit_request.artifact.parts[0].root.text
5677
if isinstance(canvas_edit_request.artifact.parts[0].root, TextPart)
5778
else ""
5879
)
5980
edited_part = original_recipe[canvas_edit_request.start_index : canvas_edit_request.end_index]
60-
description = f"You requested to edit this part:\n\n*{edited_part}*\n\n"
61-
62-
response = f"""\
63-
{description}
64-
65-
```recipe
66-
# Canvas Recipe EDITED
67-
68-
## Ingredients
69-
- bread (1 slice)
70-
- butter (1 slice)
71-
72-
## Instructions
73-
1. Cut a slice of bread.
74-
2. Cut a slice of butter.
75-
3. Spread the slice of butter on the slice of bread.
76-
```
77-
78-
Enjoy your edited meal!
79-
"""
81+
description = f"You requested to edit this part:\n\n{edited_part}\n\n"
82+
recipe_title = "Canvas Recipe EDITED"
83+
closing_message = "Enjoy your edited meal!"
8084
else:
8185
recipe_title = random.choice(RECIPE_TITLES)
8286
description = "Here's your recipe:"
87+
closing_message = "Enjoy your meal!"
8388

84-
response = f"""\
85-
{description}
86-
87-
```recipe
88-
# {recipe_title}
89-
90-
## Ingredients
91-
- bread (1 slice)
92-
- butter (1 slice)
93-
94-
## Instructions
95-
1. Cut a slice of bread.
96-
2. Cut a slice of butter.
97-
3. Spread the slice of butter on the slice of bread.
98-
```
99-
100-
Enjoy your meal!
101-
"""
89+
response = generate_recipe_response(recipe_title, description, closing_message)
10290

10391
match = re.compile(r"```recipe\n(.*?)\n```", re.DOTALL).search(response)
104-
print(
105-
f"Match: {match}\npre_text: {response[: match.start() if match else 'N/A']}\npost_text: {response[match.end() if match else 'N/A' :]}"
106-
)
10792

10893
if not match:
10994
yield response
11095
return
11196

97+
await asyncio.sleep(1)
98+
11299
if pre_text := response[: match.start()].strip():
113100
message = AgentMessage(text=pre_text)
114101
yield message
115102
await context.store(message)
116103

104+
await asyncio.sleep(1)
105+
117106
recipe_content = match.group(1).strip()
118107
first_line = recipe_content.split("\n", 1)[0]
108+
109+
# Extract the title and remove it from content if it's a heading
110+
if first_line.startswith("#"):
111+
artifact_name = first_line.lstrip("# ").strip()
112+
# Remove the first line from recipe_content if there's more content
113+
recipe_content = recipe_content.split("\n", 1)[1].strip() if "\n" in recipe_content else recipe_content
114+
else:
115+
artifact_name = "Recipe"
116+
117+
# Split recipe content into x chunks for streaming
118+
num_chunks = 8
119+
content_length = len(recipe_content)
120+
chunk_size = content_length // num_chunks
121+
chunks = []
122+
123+
for i in range(num_chunks):
124+
start = i * chunk_size
125+
# Last chunk gets any remaining characters
126+
end = content_length if i == num_chunks - 1 else (i + 1) * chunk_size
127+
chunks.append(recipe_content[start:end])
128+
119129
artifact = AgentArtifact(
120-
# artifact_id='recipe-artifact-1',
121-
name=first_line.lstrip("# ").strip() if first_line.startswith("#") else "Recipe",
130+
name=artifact_name,
122131
parts=[TextPart(text=recipe_content)],
123132
)
124-
yield artifact
125133
await context.store(artifact)
126134

135+
# Send first chunk with artifact_id to establish the artifact
136+
first_artifact = AgentArtifact(
137+
artifact_id=artifact.artifact_id,
138+
name=artifact_name,
139+
parts=[TextPart(text=chunks[0])],
140+
)
141+
yield first_artifact
142+
143+
# Send remaining chunks using the same artifact_id
144+
for chunk in chunks[1:]:
145+
chunk_artifact = AgentArtifact(
146+
artifact_id=artifact.artifact_id,
147+
name=artifact_name,
148+
parts=[TextPart(text=chunk)],
149+
)
150+
yield chunk_artifact
151+
await context.store(chunk_artifact)
152+
await asyncio.sleep(0.3)
153+
127154
if post_text := response[match.end() :].strip():
128155
message = AgentMessage(text=post_text)
129156
yield message

0 commit comments

Comments
 (0)