Skip to content

Commit 21cf6f1

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

File tree

36 files changed

+1066
-518
lines changed

36 files changed

+1066
-518
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/demo-server/src/index.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
import { serve } from "@hono/node-server";
22
import { Hono } from "hono";
33
import { cors } from "hono/cors";
4-
import {
5-
CopilotRuntime,
6-
createCopilotEndpoint,
7-
InMemoryAgentRunner,
8-
} from "@copilotkitnext/runtime";
9-
import {
10-
OpenAIAgent,
11-
SlowToolCallStreamingAgent,
12-
} from "@copilotkitnext/demo-agents";
4+
import { CopilotRuntime, createCopilotEndpoint, InMemoryAgentRunner } from "@copilotkitnext/runtime";
5+
import { OpenAIAgent, SlowToolCallStreamingAgent } from "@copilotkitnext/demo-agents";
6+
import { HttpAgent } from "@ag-ui/client";
7+
8+
const multimodalAgent = new HttpAgent({
9+
url: "http://localhost:8000/agent/multimodal_messages",
10+
});
1311

1412
const runtime = new CopilotRuntime({
1513
agents: {
1614
// @ts-ignore
1715
default: new SlowToolCallStreamingAgent(),
16+
multimodal: multimodalAgent,
1817
},
1918
runner: new InMemoryAgentRunner(),
2019
});
@@ -32,7 +31,7 @@ app.use(
3231
exposeHeaders: ["Content-Type"],
3332
credentials: true,
3433
maxAge: 86400,
35-
})
34+
}),
3635
);
3736

3837
// Create the CopilotKit endpoint
@@ -46,6 +45,4 @@ app.route("/", copilotApp);
4645

4746
const port = Number(process.env.PORT || 3001);
4847
serve({ fetch: app.fetch, port });
49-
console.log(
50-
`CopilotKit runtime listening at http://localhost:${port}/api/copilotkit`
51-
);
48+
console.log(`CopilotKit runtime listening at http://localhost:${port}/api/copilotkit`);

apps/angular/demo/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
"@angular/platform-browser": "^18.2.0",
2121
"@angular/platform-browser-dynamic": "^18.2.0",
2222
"@copilotkitnext/angular": "workspace:*",
23+
"@copilotkitnext/shared": "workspace:*",
24+
"uuid": "^11.1.0",
25+
"@ag-ui/client": "0.0.40-alpha.7",
26+
"partial-json": "^0.1.7",
2327
"rxjs": "^7.8.1",
2428
"tslib": "^2.8.1",
2529
"zone.js": "^0.14.0"

apps/angular/demo/src/app/routes/headless/headless-chat.component.ts

Lines changed: 243 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1-
import { Component, ChangeDetectionStrategy, computed, inject } from "@angular/core";
1+
import {
2+
Component,
3+
ChangeDetectionStrategy,
4+
ElementRef,
5+
ViewChild,
6+
computed,
7+
inject,
8+
} from "@angular/core";
29
import { CommonModule } from "@angular/common";
310
import { FormsModule } from "@angular/forms";
411
import { CopilotKit, injectAgentStore } from "@copilotkitnext/angular";
512
import { RenderToolCalls } from "@copilotkitnext/angular";
13+
import type { BinaryInputContent, InputContent, Message, TextInputContent } from "@ag-ui/client";
14+
import {
15+
getUserMessageBinaryContents,
16+
getUserMessageTextContent,
17+
isUserMessageContentEmpty,
18+
} from "@copilotkitnext/shared";
619

720
@Component({
821
selector: "headless-chat",
@@ -16,7 +29,48 @@ import { RenderToolCalls } from "@copilotkitnext/angular";
1629
<div style="font-weight:600;color:#374151;">
1730
{{ m.role | titlecase }}
1831
</div>
19-
<div style="white-space:pre-wrap">{{ m.content }}</div>
32+
<div style="white-space:pre-wrap" *ngIf="messageText(m) as text">{{ text }}</div>
33+
<ng-container *ngIf="m.role === 'user'">
34+
<ng-container *ngIf="userAttachments(m) as attachments">
35+
<div
36+
*ngIf="attachments.length"
37+
style="margin-top:8px;display:flex;gap:12px;flex-wrap:wrap;"
38+
>
39+
<ng-container *ngFor="let attachment of attachments; trackBy: trackAttachment">
40+
<figure
41+
*ngIf="isImage(attachment); else fileAttachment"
42+
style="display:flex;flex-direction:column;gap:6px;max-width:160px;"
43+
>
44+
<img
45+
[src]="resolveSource(attachment)"
46+
[alt]="attachment.filename || attachment.id || attachment.mimeType"
47+
style="width:100%;border-radius:8px;border:1px solid #d1d5db;object-fit:contain;background:#fff;"
48+
/>
49+
<figcaption style="font-size:12px;color:#4b5563;">
50+
{{ attachment.filename || attachment.id || 'Attachment' }}
51+
</figcaption>
52+
</figure>
53+
<ng-template #fileAttachment>
54+
<div
55+
style="padding:10px 12px;border-radius:8px;border:1px dashed #cbd5f5;background:#f8fafc;color:#1f2937;font-size:12px;"
56+
>
57+
<div style="font-weight:600;">{{ attachment.filename || attachment.id || 'Attachment' }}</div>
58+
<div style="margin-top:4px;word-break:break-all;">{{ attachment.mimeType }}</div>
59+
<a
60+
*ngIf="resolveSource(attachment) as href"
61+
[href]="href"
62+
target="_blank"
63+
rel="noreferrer"
64+
style="display:inline-block;margin-top:6px;color:#2563eb;text-decoration:underline;"
65+
>
66+
Open
67+
</a>
68+
</div>
69+
</ng-template>
70+
</ng-container>
71+
</div>
72+
</ng-container>
73+
</ng-container>
2074
<ng-container *ngIf="m.role === 'assistant'">
2175
<copilot-render-tool-calls
2276
[message]="m"
@@ -30,8 +84,44 @@ import { RenderToolCalls } from "@copilotkitnext/angular";
3084
3185
<form
3286
(ngSubmit)="send()"
33-
style="display:flex;gap:8px;padding:12px;background:#ffffff;border-top:1px solid #e5e7eb;"
87+
style="display:flex;flex-direction:column;gap:12px;padding:12px;background:#ffffff;border-top:1px solid #e5e7eb;"
3488
>
89+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
90+
<input
91+
#fileInput
92+
type="file"
93+
multiple
94+
(change)="onFilesSelected($event)"
95+
[disabled]="isRunning()"
96+
style="padding:8px;border-radius:8px;border:1px dashed #cbd5f5;background:#f8fafc;color:#1e293b;"
97+
/>
98+
<button
99+
type="button"
100+
*ngIf="selectedFiles.length"
101+
(click)="clearSelectedFiles()"
102+
style="padding:8px 10px;border-radius:6px;border:1px solid #d1d5db;background:#f9fafb;color:#1f2937;cursor:pointer;"
103+
>
104+
Clear files
105+
</button>
106+
</div>
107+
108+
<div *ngIf="selectedFiles.length" style="display:flex;gap:8px;flex-wrap:wrap;">
109+
<span
110+
*ngFor="let file of selectedFiles; let i = index"
111+
style="display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:9999px;background:#e0f2fe;color:#1e3a8a;font-size:12px;"
112+
>
113+
{{ file.name }}
114+
<button
115+
type="button"
116+
(click)="removeFile(i)"
117+
style="border:none;background:transparent;color:#1e3a8a;font-weight:600;cursor:pointer;"
118+
aria-label="Remove file"
119+
>
120+
×
121+
</button>
122+
</span>
123+
</div>
124+
35125
<input
36126
name="message"
37127
[(ngModel)]="inputValue"
@@ -41,8 +131,8 @@ import { RenderToolCalls } from "@copilotkitnext/angular";
41131
/>
42132
<button
43133
type="submit"
44-
[disabled]="!inputValue.trim() || isRunning()"
45-
style="padding:10px 14px;border-radius:8px;border:1px solid #1d4ed8;background:#2563eb;color:#ffffff;cursor:pointer;"
134+
[disabled]="isSendButtonDisabled()"
135+
style="align-self:flex-end;padding:10px 14px;border-radius:8px;border:1px solid #1d4ed8;background:#2563eb;color:#ffffff;cursor:pointer;"
46136
>
47137
Send
48138
</button>
@@ -51,28 +141,173 @@ import { RenderToolCalls } from "@copilotkitnext/angular";
51141
`,
52142
})
53143
export class HeadlessChatComponent {
54-
readonly agentStore = injectAgentStore("default");
144+
readonly agentStore = injectAgentStore("multimodal");
55145
readonly agent = computed(() => this.agentStore()?.agent);
56146
readonly isRunning = computed(() => !!this.agentStore()?.isRunning());
57147
readonly messages = computed(() => this.agentStore()?.messages());
58148
readonly copilotkit = inject(CopilotKit);
59149

150+
@ViewChild("fileInput") fileInput?: ElementRef<HTMLInputElement>;
151+
60152
inputValue = "";
153+
selectedFiles: File[] = [];
154+
155+
onFilesSelected(event: Event) {
156+
const input = event.target as HTMLInputElement | null;
157+
const files = input?.files ? Array.from(input.files) : [];
158+
if (files.length === 0) {
159+
return;
160+
}
161+
162+
const existingKeys = new Set(this.selectedFiles.map((file) => this.#fileKey(file)));
163+
const merged: File[] = [...this.selectedFiles];
164+
165+
for (const file of files) {
166+
const key = this.#fileKey(file);
167+
if (!existingKeys.has(key)) {
168+
merged.push(file);
169+
existingKeys.add(key);
170+
}
171+
}
172+
173+
this.selectedFiles = merged;
174+
175+
if (input) {
176+
input.value = "";
177+
}
178+
}
179+
180+
removeFile(index: number) {
181+
if (index < 0 || index >= this.selectedFiles.length) {
182+
return;
183+
}
184+
this.selectedFiles = this.selectedFiles.filter((_, i) => i !== index);
185+
if (this.selectedFiles.length === 0 && this.fileInput?.nativeElement) {
186+
this.fileInput.nativeElement.value = "";
187+
}
188+
}
189+
190+
clearSelectedFiles() {
191+
this.selectedFiles = [];
192+
if (this.fileInput?.nativeElement) {
193+
this.fileInput.nativeElement.value = "";
194+
}
195+
}
196+
197+
isSendButtonDisabled(): boolean {
198+
if (this.isRunning()) {
199+
return true;
200+
}
201+
const hasText = this.inputValue.trim().length > 0;
202+
const hasFiles = this.selectedFiles.length > 0;
203+
return !hasText && !hasFiles;
204+
}
61205

62206
async send() {
63207
const content = this.inputValue.trim();
64208
const agent = this.agent();
65209
const isRunning = this.isRunning();
66210

67-
if (!agent || !content || isRunning) return;
211+
if (!agent || isRunning) return;
212+
213+
const attachments = await Promise.all(this.selectedFiles.map((file) => this.#fileToBinaryContent(file)));
214+
215+
const parts: InputContent[] = [];
216+
217+
if (content.length > 0) {
218+
parts.push({
219+
type: "text",
220+
text: content,
221+
} satisfies TextInputContent);
222+
}
223+
224+
parts.push(...attachments);
225+
226+
if (isUserMessageContentEmpty(parts)) {
227+
return;
228+
}
68229

69-
agent.addMessage({ id: crypto.randomUUID(), role: "user", content });
230+
const messageContent = attachments.length === 0 && parts.length === 1 && content.length > 0 ? content : parts;
231+
232+
agent.addMessage({ id: crypto.randomUUID(), role: "user", content: messageContent });
70233
this.inputValue = "";
234+
this.clearSelectedFiles();
71235

72236
try {
73237
await this.copilotkit.core.runAgent({ agent });
74238
} catch (e) {
75239
console.error("Agent run error", e);
76240
}
77241
}
242+
243+
messageText(message: Message): string | undefined {
244+
if (message.role === "user") {
245+
const text = getUserMessageTextContent(message.content ?? []);
246+
return text.trim().length > 0 ? text : undefined;
247+
}
248+
249+
if (typeof message.content === "string" && message.content.length > 0) {
250+
return message.content;
251+
}
252+
253+
return undefined;
254+
}
255+
256+
userAttachments(message: Message): BinaryInputContent[] {
257+
if (message.role !== "user") {
258+
return [];
259+
}
260+
const content = (message.content ?? []) as string | InputContent[];
261+
return getUserMessageBinaryContents(content);
262+
}
263+
264+
resolveSource(attachment: BinaryInputContent): string | null {
265+
if (attachment.url) {
266+
return attachment.url;
267+
}
268+
if (attachment.data) {
269+
return `data:${attachment.mimeType};base64,${attachment.data}`;
270+
}
271+
return null;
272+
}
273+
274+
isImage(attachment: BinaryInputContent): boolean {
275+
const source = this.resolveSource(attachment);
276+
return !!source && attachment.mimeType.startsWith("image/");
277+
}
278+
279+
trackAttachment(index: number, attachment: BinaryInputContent): string {
280+
return attachment.id ?? attachment.url ?? attachment.filename ?? `${index}`;
281+
}
282+
283+
async #fileToBinaryContent(file: File): Promise<BinaryInputContent> {
284+
const data = await this.#readFileAsBase64(file);
285+
return {
286+
type: "binary",
287+
mimeType: file.type || "application/octet-stream",
288+
filename: file.name,
289+
data,
290+
} satisfies BinaryInputContent;
291+
}
292+
293+
#fileKey(file: File): string {
294+
return `${file.name}:${file.size}:${file.lastModified}:${file.type}`;
295+
}
296+
297+
#readFileAsBase64(file: File): Promise<string> {
298+
return new Promise((resolve, reject) => {
299+
const reader = new FileReader();
300+
reader.onload = () => {
301+
const result = reader.result;
302+
if (typeof result === "string") {
303+
const commaIndex = result.indexOf(",");
304+
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
305+
} else {
306+
reject(new Error("Unexpected file reader result"));
307+
}
308+
};
309+
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
310+
reader.readAsDataURL(file);
311+
});
312+
}
78313
}

apps/angular/demo/tsconfig.json

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,16 @@
1313
"baseUrl": ".",
1414
"paths": {
1515
"@copilotkitnext/angular": [
16-
"../../packages/angular/dist/index.d.ts",
17-
"../../packages/angular/dist/fesm2022/copilotkit-angular.mjs"
18-
],
19-
"@copilotkitnext/core": [
20-
"../../packages/core/dist/index.d.ts",
21-
"../../packages/core/dist/index.mjs",
22-
"../../packages/core/src/index.ts"
16+
"./node_modules/@copilotkitnext/angular/dist/index.d.ts",
17+
"./node_modules/@copilotkitnext/angular/dist/fesm2022/copilotkit-angular.mjs"
2318
],
2419
"@copilotkitnext/shared": [
25-
"../../packages/shared/dist/index.d.ts",
26-
"../../packages/shared/dist/index.mjs",
27-
"../../packages/shared/src/index.ts"
20+
"./node_modules/@copilotkitnext/shared/dist/index.d.ts",
21+
"./node_modules/@copilotkitnext/shared/dist/index.mjs"
22+
],
23+
"@ag-ui/client": [
24+
"./node_modules/@ag-ui/client/dist/index.d.ts",
25+
"./node_modules/@ag-ui/client/dist/index.mjs"
2826
]
2927
}
3028
},

0 commit comments

Comments
 (0)