Skip to content

Commit 3be69d9

Browse files
committed
feat: Add support for multi-modal messages
1 parent 0a3019c commit 3be69d9

File tree

21 files changed

+748
-470
lines changed

21 files changed

+748
-470
lines changed

apps/angular/demo-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"start": "node --env-file=.env --loader tsx src/index.ts"
99
},
1010
"dependencies": {
11-
"@ag-ui/client": "0.0.40-alpha.6",
11+
"@ag-ui/client": "0.0.40-alpha.7",
1212
"@ag-ui/langgraph": "^0.0.11",
1313
"@copilotkitnext/demo-agents": "workspace:^",
1414
"@copilotkitnext/runtime": "workspace:^",

apps/angular/storybook/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"storybook:build": "ng run storybook-angular:build-storybook"
1010
},
1111
"dependencies": {
12-
"@ag-ui/client": "0.0.40-alpha.6",
12+
"@ag-ui/client": "0.0.40-alpha.7",
1313
"@angular/animations": "^18.2.0",
1414
"@angular/common": "^18.2.0",
1515
"@angular/compiler": "^18.2.0",

apps/react/demo/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"lint": "next lint"
1010
},
1111
"dependencies": {
12-
"@ag-ui/client": "0.0.40-alpha.6",
12+
"@ag-ui/client": "0.0.40-alpha.7",
1313
"@copilotkitnext/agent": "workspace:*",
1414
"@copilotkitnext/core": "workspace:*",
1515
"@copilotkitnext/react": "workspace:*",

packages/agent/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"vitest": "^3.0.5"
3737
},
3838
"dependencies": {
39-
"@ag-ui/client": "0.0.40-alpha.6",
39+
"@ag-ui/client": "0.0.40-alpha.7",
4040
"@ai-sdk/anthropic": "^2.0.22",
4141
"@ai-sdk/google": "^2.0.17",
4242
"@ai-sdk/openai": "^2.0.42",

packages/agent/src/__tests__/utils.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,58 @@ describe("convertMessagesToVercelAISDKMessages", () => {
9090
]);
9191
});
9292

93+
it("should convert user messages with binary content", () => {
94+
const messages: Message[] = [
95+
{
96+
id: "1",
97+
role: "user",
98+
content: [
99+
{ type: "text", text: "Here is the design" },
100+
{
101+
type: "binary",
102+
mimeType: "image/png",
103+
url: "https://example.com/image.png",
104+
filename: "image.png",
105+
},
106+
],
107+
},
108+
];
109+
110+
const result = convertMessagesToVercelAISDKMessages(messages);
111+
const content = result[0].content;
112+
113+
expect(Array.isArray(content)).toBe(true);
114+
if (Array.isArray(content)) {
115+
expect(content[0]).toEqual({ type: "text", text: "Here is the design" });
116+
expect(content[1]).toMatchObject({
117+
type: "file",
118+
mediaType: "image/png",
119+
filename: "image.png",
120+
});
121+
}
122+
});
123+
124+
it("should fall back to placeholders when binary content has no data", () => {
125+
const messages: Message[] = [
126+
{
127+
id: "1",
128+
role: "user",
129+
content: [
130+
{
131+
type: "binary",
132+
mimeType: "application/octet-stream",
133+
id: "file-1",
134+
},
135+
],
136+
},
137+
];
138+
139+
const result = convertMessagesToVercelAISDKMessages(messages);
140+
expect(result[0].content).toEqual([
141+
{ type: "text", text: "[Attachment: file-1]" },
142+
]);
143+
});
144+
93145
it("should convert assistant messages with text content", () => {
94146
const messages: Message[] = [
95147
{

packages/agent/src/index.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
AbstractAgent,
33
BaseEvent,
4+
BinaryInputContent,
45
RunAgentInput,
56
EventType,
67
Message,
@@ -22,9 +23,11 @@ import {
2223
AssistantModelMessage,
2324
UserModelMessage,
2425
ToolModelMessage,
26+
FilePart,
2527
ToolCallPart,
2628
ToolResultPart,
2729
TextPart,
30+
UserContent,
2831
tool as createVercelAISDKTool,
2932
ToolChoice,
3033
ToolSet,
@@ -232,6 +235,79 @@ export function defineTool<TParameters extends z.ZodTypeAny>(config: {
232235
};
233236
}
234237

238+
function convertBinaryInputContentToFilePart(content: BinaryInputContent): FilePart | null {
239+
if (content.url) {
240+
try {
241+
return {
242+
type: "file",
243+
data: new URL(content.url),
244+
mediaType: content.mimeType,
245+
filename: content.filename,
246+
} satisfies FilePart;
247+
} catch {
248+
return {
249+
type: "file",
250+
data: content.url,
251+
mediaType: content.mimeType,
252+
filename: content.filename,
253+
} satisfies FilePart;
254+
}
255+
}
256+
257+
if (content.data) {
258+
return {
259+
type: "file",
260+
data: content.data,
261+
mediaType: content.mimeType,
262+
filename: content.filename,
263+
} satisfies FilePart;
264+
}
265+
266+
return null;
267+
}
268+
269+
function convertUserMessageContent(content: Message["content"]): UserContent {
270+
if (!content) {
271+
return "";
272+
}
273+
274+
if (typeof content === "string") {
275+
return content;
276+
}
277+
278+
if (content.every((part) => part.type === "text")) {
279+
return content.map((part) => part.text).join("\n\n");
280+
}
281+
282+
const parts: Array<TextPart | FilePart> = [];
283+
284+
for (const part of content) {
285+
if (part.type === "text") {
286+
if (part.text.length > 0) {
287+
parts.push({ type: "text", text: part.text });
288+
}
289+
continue;
290+
}
291+
292+
const filePart = convertBinaryInputContentToFilePart(part);
293+
if (filePart) {
294+
parts.push(filePart);
295+
} else {
296+
const label = part.filename ?? part.id ?? part.mimeType;
297+
parts.push({
298+
type: "text",
299+
text: `[Attachment: ${label}]`,
300+
});
301+
}
302+
}
303+
304+
if (parts.length === 0) {
305+
return "";
306+
}
307+
308+
return parts;
309+
}
310+
235311
/**
236312
* Converts AG-UI messages to Vercel AI SDK ModelMessage format
237313
*/
@@ -260,7 +336,7 @@ export function convertMessagesToVercelAISDKMessages(messages: Message[]): Model
260336
} else if (message.role === "user") {
261337
const userMsg: UserModelMessage = {
262338
role: "user",
263-
content: message.content || "",
339+
content: convertUserMessageContent(message.content),
264340
};
265341
result.push(userMsg);
266342
} else if (message.role === "tool") {

packages/angular/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
"test:watch": "vitest --watch"
3232
},
3333
"dependencies": {
34-
"@ag-ui/client": "0.0.40-alpha.6",
35-
"@ag-ui/core": "0.0.40-alpha.6",
34+
"@ag-ui/client": "0.0.40-alpha.7",
35+
"@ag-ui/core": "0.0.40-alpha.7",
3636
"@copilotkitnext/core": "workspace:*",
3737
"@copilotkitnext/shared": "workspace:*",
3838
"clsx": "^2.1.1",

packages/angular/src/lib/components/chat/copilot-chat-user-message-renderer.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import {
77
} from "@angular/core";
88
import { CommonModule } from "@angular/common";
99
import { cn } from "../../utils";
10+
import type { BinaryInputContent, InputContent } from "@ag-ui/client";
11+
import {
12+
getUserMessageBinaryContents,
13+
getUserMessageTextContent,
14+
} from "@copilotkitnext/shared";
1015

1116
@Component({
1217
selector: "copilot-chat-user-message-renderer",
@@ -17,10 +22,54 @@ import { cn } from "../../utils";
1722
host: {
1823
"[class]": "computedClass()",
1924
},
20-
template: `{{ content() }}`,
25+
template: `
26+
@if (textContent()) {
27+
<span>{{ textContent() }}</span>
28+
}
29+
@if (attachments().length) {
30+
<div [class]="attachmentsClass()">
31+
@for (attachment of attachments(); track trackAttachment(attachment, $index)) {
32+
<ng-container *ngIf="isImage(attachment); else fileTemplate">
33+
<figure class="flex flex-col gap-1">
34+
<img
35+
[src]="resolveSource(attachment)"
36+
[alt]="attachment.filename || attachment.id || attachment.mimeType"
37+
class="max-h-64 rounded-lg border border-border object-contain"
38+
/>
39+
@if (attachment.filename || attachment.id) {
40+
<figcaption class="text-xs text-muted-foreground">
41+
{{ attachment.filename || attachment.id }}
42+
</figcaption>
43+
}
44+
</figure>
45+
</ng-container>
46+
<ng-template #fileTemplate>
47+
<div class="rounded-md border border-dashed border-border bg-muted/70 px-3 py-2 text-xs text-muted-foreground">
48+
{{ attachment.filename || attachment.id || 'Attachment' }}
49+
<span class="block text-[10px] uppercase tracking-wide text-muted-foreground/70">
50+
{{ attachment.mimeType }}
51+
</span>
52+
@if (resolveSource(attachment) && !isImage(attachment)) {
53+
<a
54+
[href]="resolveSource(attachment)"
55+
target="_blank"
56+
rel="noreferrer"
57+
class="mt-1 block text-xs text-primary underline"
58+
>
59+
Open
60+
</a>
61+
}
62+
</div>
63+
</ng-template>
64+
}
65+
</div>
66+
}
67+
`,
2168
})
2269
export class CopilotChatUserMessageRenderer {
2370
readonly content = input<string>("");
71+
readonly contents = input<InputContent[]>([]);
72+
readonly attachments = input<BinaryInputContent[] | undefined>(undefined);
2473
readonly inputClass = input<string | undefined>();
2574

2675
readonly computedClass = computed(() => {
@@ -29,4 +78,44 @@ export class CopilotChatUserMessageRenderer {
2978
this.inputClass()
3079
);
3180
});
81+
82+
readonly textContent = computed(() => {
83+
const explicit = this.content();
84+
if (explicit && explicit.length > 0) {
85+
return explicit;
86+
}
87+
return getUserMessageTextContent(this.contents());
88+
});
89+
90+
readonly attachments = computed(() => {
91+
const provided = this.attachments() ?? [];
92+
if (provided.length > 0) {
93+
return provided;
94+
}
95+
return getUserMessageBinaryContents(this.contents());
96+
});
97+
98+
readonly attachmentsClass = computed(() =>
99+
this.textContent().trim().length > 0
100+
? "mt-3 flex flex-col gap-2"
101+
: "flex flex-col gap-2",
102+
);
103+
104+
resolveSource(attachment: BinaryInputContent): string | null {
105+
if (attachment.url) {
106+
return attachment.url;
107+
}
108+
if (attachment.data) {
109+
return `data:${attachment.mimeType};base64,${attachment.data}`;
110+
}
111+
return null;
112+
}
113+
114+
isImage(attachment: BinaryInputContent): boolean {
115+
return attachment.mimeType.startsWith("image/") && !!this.resolveSource(attachment);
116+
}
117+
118+
trackAttachment(attachment: BinaryInputContent, index: number): string {
119+
return attachment.id ?? attachment.url ?? attachment.filename ?? index.toString();
120+
}
32121
}

packages/angular/src/lib/components/chat/copilot-chat-user-message.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ import {
2929
import { CopilotChatUserMessageToolbar } from "./copilot-chat-user-message-toolbar";
3030
import { CopilotChatUserMessageBranchNavigation } from "./copilot-chat-user-message-branch-navigation";
3131
import { cn } from "../../utils";
32+
import {
33+
getUserMessageBinaryContents,
34+
getUserMessageTextContent,
35+
normalizeUserMessageContents,
36+
} from "@copilotkitnext/shared";
3237

3338
@Component({
3439
standalone: true,
@@ -46,17 +51,20 @@ import { cn } from "../../utils";
4651
encapsulation: ViewEncapsulation.None,
4752
template: `
4853
<div [class]="computedClass()" [attr.data-message-id]="message()?.id">
54+
@let messageCtx = messageRendererContext();
4955
<!-- Message Renderer -->
5056
@if (messageRendererTemplate || messageRendererComponent()) {
5157
<copilot-slot
5258
[slot]="messageRendererTemplate || messageRendererComponent()"
53-
[context]="messageRendererContext()"
59+
[context]="messageCtx"
5460
[defaultComponent]="CopilotChatUserMessageRenderer"
5561
>
5662
</copilot-slot>
5763
} @else {
5864
<copilot-chat-user-message-renderer
59-
[content]="message()?.content || ''"
65+
[content]="messageCtx.content"
66+
[contents]="messageCtx.contents"
67+
[attachments]="messageCtx.attachments"
6068
[inputClass]="messageRendererClass()"
6169
>
6270
</copilot-chat-user-message-renderer>
@@ -84,14 +92,14 @@ import { cn } from "../../utils";
8492
@if (copyButtonTemplate || copyButtonComponent()) {
8593
<copilot-slot
8694
[slot]="copyButtonTemplate || copyButtonComponent()"
87-
[context]="{ content: message()?.content || '' }"
95+
[context]="{ content: messageCtx.content }"
8896
[outputs]="copyButtonOutputs"
8997
[defaultComponent]="CopilotChatUserMessageCopyButton"
9098
>
9199
</copilot-slot>
92100
} @else {
93101
<copilot-chat-user-message-copy-button
94-
[content]="message()?.content"
102+
[content]="messageCtx.content"
95103
[inputClass]="copyButtonClass()"
96104
(clicked)="handleCopy()"
97105
>
@@ -211,9 +219,15 @@ export class CopilotChatUserMessage {
211219
);
212220

213221
// Context for slots (reactive via signals)
214-
messageRendererContext = computed<MessageRendererContext>(() => ({
215-
content: this.message()?.content || "",
216-
}));
222+
messageRendererContext = computed<MessageRendererContext>(() => {
223+
const message = this.message();
224+
const contents = normalizeUserMessageContents(message?.content);
225+
return {
226+
content: getUserMessageTextContent(contents),
227+
contents,
228+
attachments: getUserMessageBinaryContents(contents),
229+
};
230+
});
217231

218232
// Output maps for slots
219233
copyButtonOutputs = { clicked: () => this.handleCopy() };

0 commit comments

Comments
 (0)