Skip to content

Commit 5b6592c

Browse files
committed
feat(ui): improve selection mapping, fixes and code cleanup
Signed-off-by: Petr Kadlec <petr@puradesign.cz> feat(ui): finalize canvas, improve selection mapping Signed-off-by: Petr Kadlec <petr@puradesign.cz> Signed-off-by: Petr Kadlec <petr@puradesign.cz> Signed-off-by: Petr Kadlec <petr@puradesign.cz>
1 parent c05abd8 commit 5b6592c

File tree

19 files changed

+445
-149
lines changed

19 files changed

+445
-149
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,7 @@ mise.local.toml
218218
!.vscode/tasks.json
219219
!.vscode/launch.json
220220
!.vscode/extensions.json
221-
!.vscode/*.code-snippets
221+
!.vscode/*.code-snippets
222+
223+
# Github Copilots
224+
.github/copilot-instructions.md
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import asyncio
5+
import os
6+
import random
7+
import re
8+
from typing import Annotated
9+
10+
from a2a.types import Message, TextPart
11+
12+
from agentstack_sdk.a2a.extensions.ui.canvas import CanvasExtensionServer, CanvasExtensionSpec
13+
from agentstack_sdk.a2a.types import AgentArtifact, AgentMessage
14+
from agentstack_sdk.server import Server
15+
from agentstack_sdk.server.context import RunContext
16+
17+
server = Server()
18+
19+
CODE_TITLES = [
20+
"Fibonacci Generator",
21+
"Prime Number Checker",
22+
"Binary Search Implementation",
23+
"String Reverser",
24+
"Palindrome Checker",
25+
"Temperature Converter",
26+
"Factorial Calculator",
27+
"List Sorter",
28+
"FizzBuzz Solution",
29+
"Word Counter",
30+
]
31+
32+
def generate_code_response(code_title: str, description: str, closing_message: str) -> str:
33+
"""Generate a code response with the given title, description, and closing message."""
34+
return f"""\
35+
{description}
36+
37+
```python
38+
# {code_title}
39+
40+
def fibonacci(n):
41+
\"\"\"Generate Fibonacci sequence up to n terms.\"\"\"
42+
if n <= 0:
43+
return []
44+
elif n == 1:
45+
return [0]
46+
elif n == 2:
47+
return [0, 1]
48+
49+
sequence = [0, 1]
50+
for i in range(2, n):
51+
sequence.append(sequence[i-1] + sequence[i-2])
52+
return sequence
53+
54+
# Example usage
55+
if __name__ == "__main__":
56+
result = fibonacci(10)
57+
print(f"First 10 Fibonacci numbers: {{result}}")
58+
```
59+
60+
{closing_message}
61+
"""
62+
63+
64+
@server.agent(
65+
name="Canvas coding test agent",
66+
)
67+
async def artifacts_agent(
68+
input: Message,
69+
context: RunContext,
70+
canvas: Annotated[
71+
CanvasExtensionServer,
72+
CanvasExtensionSpec(),
73+
],
74+
):
75+
"""Works with artifacts"""
76+
77+
await context.store(input)
78+
79+
canvas_edit_request = await canvas.parse_canvas_edit_request(message=input)
80+
81+
if canvas_edit_request:
82+
print("Canvas Edit Request:")
83+
print(f"Start Index: {canvas_edit_request.start_index}")
84+
print(f"End Index: {canvas_edit_request.end_index}")
85+
print(f"Artifact ID: {canvas_edit_request.artifact.artifact_id}")
86+
print(f"Selected Text: {canvas_edit_request.content}")
87+
88+
if canvas_edit_request:
89+
original_code = (
90+
canvas_edit_request.artifact.parts[0].root.text
91+
if isinstance(canvas_edit_request.artifact.parts[0].root, TextPart)
92+
else ""
93+
)
94+
edited_part = original_code[canvas_edit_request.start_index : canvas_edit_request.end_index]
95+
description = f"You requested to edit this part:\n\n{edited_part}\n\n"
96+
code_title = "Edited Code"
97+
closing_message = "Your code has been updated!"
98+
else:
99+
code_title = random.choice(CODE_TITLES)
100+
description = "Here's your code:"
101+
closing_message = "Happy coding!"
102+
103+
response = generate_code_response(code_title, description, closing_message)
104+
105+
match = re.compile(r"```python\n(.*?)\n```", re.DOTALL).search(response)
106+
107+
if not match:
108+
yield response
109+
return
110+
111+
await asyncio.sleep(1)
112+
113+
if pre_text := response[: match.start()].strip():
114+
message = AgentMessage(text=pre_text)
115+
yield message
116+
await context.store(message)
117+
118+
await asyncio.sleep(1)
119+
120+
# Keep the full match including the code block formatting
121+
code_content = match.group(0).strip()
122+
123+
# Extract artifact name from the comment line if present
124+
first_line = match.group(1).strip().split("\n", 1)[0]
125+
if first_line.startswith("#"):
126+
artifact_name = first_line.lstrip("# ").strip()
127+
else:
128+
artifact_name = "Python Script"
129+
130+
# Split code content into x chunks for streaming
131+
num_chunks = 8
132+
content_length = len(code_content)
133+
chunk_size = content_length // num_chunks
134+
chunks = []
135+
136+
for i in range(num_chunks):
137+
start = i * chunk_size
138+
# Last chunk gets any remaining characters
139+
end = content_length if i == num_chunks - 1 else (i + 1) * chunk_size
140+
chunks.append(code_content[start:end])
141+
142+
artifact = AgentArtifact(
143+
name=artifact_name,
144+
parts=[TextPart(text=code_content)],
145+
)
146+
await context.store(artifact)
147+
148+
# Send first chunk with artifact_id to establish the artifact
149+
first_artifact = AgentArtifact(
150+
artifact_id=artifact.artifact_id,
151+
name=artifact_name,
152+
parts=[TextPart(text=chunks[0])],
153+
)
154+
yield first_artifact
155+
156+
# Send remaining chunks using the same artifact_id
157+
for chunk in chunks[1:]:
158+
chunk_artifact = AgentArtifact(
159+
artifact_id=artifact.artifact_id,
160+
name=artifact_name,
161+
parts=[TextPart(text=chunk)],
162+
)
163+
yield chunk_artifact
164+
await context.store(chunk_artifact)
165+
await asyncio.sleep(0.3)
166+
167+
if post_text := response[match.end() :].strip():
168+
message = AgentMessage(text=post_text)
169+
yield message
170+
await context.store(message)
171+
172+
173+
if __name__ == "__main__":
174+
server.run(host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", 8008)))

apps/agentstack-sdk-py/examples/canvas_ui_test_agent.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ def generate_recipe_response(recipe_title: str, description: str, closing_messag
5151
{closing_message}
5252
"""
5353

54-
5554
@server.agent(
5655
name="Canvas example agent",
5756
)
@@ -69,7 +68,12 @@ async def artifacts_agent(
6968

7069
canvas_edit_request = await canvas.parse_canvas_edit_request(message=input)
7170

72-
print(f"Canvas Edit Request: {canvas_edit_request}")
71+
if canvas_edit_request:
72+
print("Canvas Edit Request:")
73+
print(f"Start Index: {canvas_edit_request.start_index}")
74+
print(f"End Index: {canvas_edit_request.end_index}")
75+
print(f"Artifact ID: {canvas_edit_request.artifact.artifact_id}")
76+
print(f"Selected Text: {canvas_edit_request.content}")
7377

7478
if canvas_edit_request:
7579
original_recipe = (

apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-agent-card.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export interface Fulfillments {
3030
llm: (demand: LLMDemands) => Promise<LLMFulfillments>;
3131
embedding: (demand: EmbeddingDemands) => Promise<EmbeddingFulfillments>;
3232
mcp: (demand: MCPDemands) => Promise<MCPFulfillments>;
33-
oauth: (demand: OAuthDemands) => OAuthFulfillments;
33+
oauth: (demand: OAuthDemands) => Promise<OAuthFulfillments>;
3434
settings: (demand: SettingsDemands) => Promise<SettingsFulfillments>;
3535
secrets: (demand: SecretDemands) => Promise<SecretFulfillments>;
3636
form: (demand: FormDemands) => Promise<FormFulfillments>;

apps/agentstack-ui/src/api/a2a/client.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { defaultIfEmpty, filter, lastValueFrom, Subject } from 'rxjs';
1010
import { match } from 'ts-pattern';
1111

1212
import { UnauthenticatedError } from '#api/errors.ts';
13+
import type { UITextPart } from '#modules/messages/types.ts';
1314
import { type UIMessagePart, UIMessagePartKind } from '#modules/messages/types.ts';
1415
import type { TaskId } from '#modules/tasks/api/types.ts';
1516
import { getBaseUrl } from '#utils/api/getBaseUrl.ts';
@@ -51,8 +52,18 @@ function handleArtifactUpdate(event: TaskArtifactUpdateEvent, isCanvas: boolean)
5152

5253
if (isCanvas) {
5354
const { artifactId, description, name } = artifact;
54-
55-
return [{ kind: UIMessagePartKind.Artifact, artifactId, description, name, parts: contentParts }];
55+
const { textParts, otherParts } = contentParts.reduce<{ textParts: UITextPart[]; otherParts: UIMessagePart[] }>(
56+
(acc, part) => {
57+
if (part.kind === UIMessagePartKind.Text) {
58+
acc.textParts.push(part);
59+
} else {
60+
acc.otherParts.push(part);
61+
}
62+
return acc;
63+
},
64+
{ textParts: [], otherParts: [] },
65+
);
66+
return [{ kind: UIMessagePartKind.Artifact, artifactId, description, name, parts: textParts }, ...otherParts];
5667
} else {
5768
return contentParts;
5869
}
@@ -99,8 +110,6 @@ export const buildA2AClient = async <UIGenericPart = never>({
99110
);
100111

101112
for await (const event of stream) {
102-
console.log(event);
103-
104113
match(event)
105114
.with({ kind: 'task' }, (task) => {
106115
taskId = task.id;

apps/agentstack-ui/src/components/MarkdownContent/rehype/rehypeSourcePosition.ts

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,38 +8,22 @@ import { visit } from 'unist-util-visit';
88

99
export function rehypeSourcePosition() {
1010
return (tree: Root) => {
11-
visit(tree, 'text', (node, _index, parent) => {
12-
if (!node.position?.start?.offset || !node.position?.end?.offset) {
13-
return;
14-
}
15-
16-
if (parent && parent.type === 'element') {
17-
// Store the text node's position on its parent element
18-
// If parent already has position data, extend the range
19-
const existingStart = Number(parent.properties[MD_POSITION_START_ATTR]);
20-
const existingEnd = Number(parent.properties[MD_POSITION_END_ATTR]);
21-
parent.properties[MD_POSITION_START_ATTR] =
22-
existingStart !== undefined
23-
? Math.min(existingStart, node.position.start.offset)
24-
: node.position.start.offset;
25-
26-
parent.properties[MD_POSITION_END_ATTR] =
27-
existingEnd !== undefined ? Math.max(existingEnd, node.position.end.offset) : node.position.end.offset;
28-
}
29-
});
30-
31-
// Also process element nodes that have position but no text children
3211
visit(tree, 'element', (node) => {
3312
const element = node;
34-
if (node.position?.start?.offset && node.position?.end?.offset) {
35-
if (!element.properties[MD_POSITION_START_ATTR]) {
36-
element.properties[MD_POSITION_START_ATTR] = node.position.start.offset;
37-
element.properties[MD_POSITION_END_ATTR] = node.position.end.offset;
38-
}
13+
if (node.position?.start?.offset !== undefined && node.position?.end?.offset !== undefined) {
14+
const existingStart = element.properties[MD_START_INDEX_ATTR];
15+
const existingEnd = element.properties[MD_END_INDEX_ATTR];
16+
17+
element.properties[MD_START_INDEX_ATTR] = Math.min(
18+
Number(existingStart ?? Infinity),
19+
node.position.start.offset,
20+
);
21+
22+
element.properties[MD_END_INDEX_ATTR] = Math.max(Number(existingEnd ?? 0), node.position.end.offset);
3923
}
4024
});
4125
};
4226
}
4327

44-
export const MD_POSITION_START_ATTR = 'data-md-start';
45-
export const MD_POSITION_END_ATTR = 'data-md-end';
28+
export const MD_START_INDEX_ATTR = 'data-md-start';
29+
export const MD_END_INDEX_ATTR = 'data-md-end';

apps/agentstack-ui/src/modules/canvas/components/Canvas.module.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
padding-block: $spacing-07;
1818
padding-inline: $spacing-07 $spacing-09;
1919
background-color: $background;
20+
21+
.codeBlock & {
22+
padding: 0;
23+
--cds-layer-02: $background;
24+
}
2025
}
2126

2227
.header {

apps/agentstack-ui/src/modules/canvas/components/Canvas.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import clsx from 'clsx';
67
import { useMemo, useRef } from 'react';
78

89
import { CopyButton } from '#components/CopyButton/CopyButton.tsx';
@@ -21,20 +22,27 @@ export function Canvas() {
2122
[activeArtifact],
2223
);
2324

25+
const isCode = useMemo(() => {
26+
const containsCodeBlockRegex = /.+```.+/;
27+
return Boolean(content && content.startsWith('```') && !containsCodeBlockRegex.test(content));
28+
}, [content]);
29+
2430
if (!activeArtifact) {
2531
return null;
2632
}
2733

2834
return (
29-
<div className={classes.root}>
35+
<div className={clsx(classes.root, { [classes.codeBlock]: isCode })}>
3036
<div className={classes.container}>
31-
<header className={classes.header}>
32-
{activeArtifact.name && <h2 className={classes.heading}>{activeArtifact.name}</h2>}
33-
34-
<div className={classes.actions}>
35-
<CopyButton contentRef={contentRef} />
36-
</div>
37-
</header>
37+
{!isCode && (
38+
<header className={classes.header}>
39+
{activeArtifact.name && <h2 className={classes.heading}>{activeArtifact.name}</h2>}
40+
41+
<div className={classes.actions}>
42+
<CopyButton contentRef={contentRef} />
43+
</div>
44+
</header>
45+
)}
3846

3947
<div className={classes.body} ref={contentRef}>
4048
<CanvasMarkdownContent className={classes.content} artifactId={activeArtifact.artifactId}>

apps/agentstack-ui/src/modules/canvas/components/CanvasCard.module.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
font-weight: 600;
2121

2222
&:hover,
23-
&:focus {
23+
&:focus,
24+
&.active {
2425
border-color: $text-dark;
2526
background-color: $background;
2627
color: $text-primary;

apps/agentstack-ui/src/modules/canvas/components/CanvasCard.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ import classes from './CanvasCard.module.scss';
1313
interface Props {
1414
heading?: string;
1515
className?: string;
16+
isActive?: boolean;
1617
onClick: MouseEventHandler;
1718
}
1819

19-
export function CanvasCard({ heading, className, onClick }: Props) {
20+
export function CanvasCard({ heading, className, isActive, onClick }: Props) {
2021
return (
21-
<Button className={clsx(classes.root, className)} onClick={onClick}>
22+
<Button className={clsx(classes.root, className, { [classes.active]: isActive })} onClick={onClick}>
2223
<span className={classes.icon}>
2324
<License />
2425
</span>

0 commit comments

Comments
 (0)