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" ;
29import { CommonModule } from "@angular/common" ;
310import { FormsModule } from "@angular/forms" ;
411import { CopilotKit , injectAgentStore } from "@copilotkitnext/angular" ;
512import { 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} )
53143export 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}
0 commit comments