@@ -23,17 +23,24 @@ import { IMouseEvent } from '@theia/monaco-editor-core';
23
23
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor' ;
24
24
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider' ;
25
25
import { CHAT_VIEW_LANGUAGE_EXTENSION } from './chat-view-language-contribution' ;
26
- import { AIVariableResolutionRequest } from '@theia/ai-core' ;
26
+ import { AIVariableResolutionRequest , LLMImageData } from '@theia/ai-core' ;
27
27
import { FrontendVariableService } from '@theia/ai-core/lib/browser' ;
28
28
import { ContextVariablePicker } from './context-variable-picker' ;
29
29
import { ChangeSetActionRenderer , ChangeSetActionService } from './change-set-actions/change-set-action-service' ;
30
+ import { ImagePreview , PastedImage } from './ImagePreview' ;
30
31
31
32
type Query = ( query : string ) => Promise < void > ;
32
33
type Unpin = ( ) => void ;
33
34
type Cancel = ( requestModel : ChatRequestModel ) => void ;
34
35
type DeleteChangeSet = ( requestModel : ChatRequestModel ) => void ;
35
36
type DeleteChangeSetElement = ( requestModel : ChatRequestModel , index : number ) => void ;
36
37
38
+ // Interface for the payload submitted to the AI
39
+ // interface ChatPayload {
40
+ // text: string;
41
+ // images?: PastedImage[];
42
+ // }
43
+
37
44
export const AIChatInputConfiguration = Symbol ( 'AIChatInputConfiguration' ) ;
38
45
export interface AIChatInputConfiguration {
39
46
showContext ?: boolean ;
@@ -114,13 +121,54 @@ export class AIChatInputWidget extends ReactWidget {
114
121
this . update ( ) ;
115
122
}
116
123
124
+ // State for pasted images
125
+ private _pastedImages : PastedImage [ ] = [ ] ;
126
+ public get pastedImages ( ) : PastedImage [ ] {
127
+ return this . _pastedImages ;
128
+ }
129
+
117
130
@postConstruct ( )
118
131
protected init ( ) : void {
119
132
this . id = AIChatInputWidget . ID ;
120
133
this . title . closable = false ;
121
134
this . update ( ) ;
122
135
}
123
136
137
+ // Process a file blob into an image
138
+ private processImageFromClipboard ( blob : File ) : void {
139
+ const reader = new FileReader ( ) ;
140
+ reader . onload = e => {
141
+ if ( ! e . target ?. result ) { return ; }
142
+
143
+ const imageId = `img-${ Date . now ( ) } ` ;
144
+ const dataUrl = e . target . result as string ;
145
+
146
+ // Extract the base64 data by removing the data URL prefix
147
+ // Format is like: data:image/png;base64,BASE64DATA
148
+ const imageData = dataUrl . substring ( dataUrl . indexOf ( ',' ) + 1 ) ;
149
+
150
+ // Add image to state
151
+ const newImage : PastedImage = {
152
+ id : imageId ,
153
+ data : imageData , // Store just the base64 data without the prefix
154
+ name : blob . name || `pasted-image-${ Date . now ( ) } .png` ,
155
+ type : blob . type as PastedImage [ 'type' ]
156
+ } ;
157
+
158
+ this . _pastedImages = [ ...this . _pastedImages , newImage ] ;
159
+
160
+ this . update ( ) ;
161
+ } ;
162
+
163
+ reader . readAsDataURL ( blob ) ;
164
+ }
165
+
166
+ // Remove an image by id
167
+ public removeImage ( id : string ) : void {
168
+ this . _pastedImages = this . _pastedImages . filter ( img => img . id !== id ) ;
169
+ this . update ( ) ;
170
+ }
171
+
124
172
protected override onActivateRequest ( msg : Message ) : void {
125
173
super . onActivateRequest ( msg ) ;
126
174
this . editorReady . promise . then ( ( ) => {
@@ -157,6 +205,9 @@ export class AIChatInputWidget extends ReactWidget {
157
205
showPinnedAgent = { this . configuration ?. showPinnedAgent }
158
206
labelProvider = { this . labelProvider }
159
207
actionService = { this . changeSetActionService }
208
+ pastedImages = { this . _pastedImages }
209
+ onRemoveImage = { this . removeImage . bind ( this ) }
210
+ onImagePasted = { this . processImageFromClipboard . bind ( this ) }
160
211
/>
161
212
) ;
162
213
}
@@ -229,7 +280,7 @@ export class AIChatInputWidget extends ReactWidget {
229
280
230
281
interface ChatInputProperties {
231
282
onCancel : ( requestModel : ChatRequestModel ) => void ;
232
- onQuery : ( query : string ) => void ;
283
+ onQuery : ( query ? : string , images ?: LLMImageData [ ] ) => void ;
233
284
onUnpin : ( ) => void ;
234
285
onDragOver : ( event : React . DragEvent ) => void ;
235
286
onDrop : ( event : React . DragEvent ) => void ;
@@ -249,6 +300,9 @@ interface ChatInputProperties {
249
300
showPinnedAgent ?: boolean ;
250
301
labelProvider : LabelProvider ;
251
302
actionService : ChangeSetActionService ;
303
+ pastedImages : PastedImage [ ] ;
304
+ onRemoveImage : ( id : string ) => void ;
305
+ onImagePasted : ( blob : File ) => void ;
252
306
}
253
307
254
308
const ChatInput : React . FunctionComponent < ChatInputProperties > = ( props : ChatInputProperties ) => {
@@ -274,6 +328,38 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
274
328
// eslint-disable-next-line no-null/no-null
275
329
const placeholderRef = React . useRef < HTMLDivElement | null > ( null ) ;
276
330
const editorRef = React . useRef < MonacoEditor | undefined > ( undefined ) ;
331
+ // eslint-disable-next-line no-null/no-null
332
+ const containerRef = React . useRef < HTMLDivElement > ( null ) ;
333
+
334
+ // Handle paste events on the container
335
+ const handlePaste = React . useCallback ( ( e : ClipboardEvent ) => {
336
+ if ( ! e . clipboardData ?. items ) { return ; }
337
+
338
+ for ( const item of e . clipboardData . items ) {
339
+ if ( item . type . startsWith ( 'image/' ) ) {
340
+ const blob = item . getAsFile ( ) ;
341
+ if ( blob ) {
342
+ e . preventDefault ( ) ;
343
+ e . stopPropagation ( ) ;
344
+ props . onImagePasted ( blob ) ;
345
+ break ;
346
+ }
347
+ }
348
+ }
349
+ } , [ props . onImagePasted ] ) ;
350
+
351
+ // Set up paste handler on the container div
352
+ React . useEffect ( ( ) => {
353
+ const container = containerRef . current ;
354
+ if ( container ) {
355
+ container . addEventListener ( 'paste' , handlePaste , true ) ;
356
+
357
+ return ( ) => {
358
+ container . removeEventListener ( 'paste' , handlePaste , true ) ;
359
+ } ;
360
+ }
361
+ return undefined ;
362
+ } , [ handlePaste ] ) ;
277
363
278
364
React . useEffect ( ( ) => {
279
365
const uri = new URI ( `ai-chat:/input.${ CHAT_VIEW_LANGUAGE_EXTENSION } ` ) ;
@@ -397,7 +483,7 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
397
483
responseListenerRef . current ?. dispose ( ) ;
398
484
responseListenerRef . current = undefined ;
399
485
} ;
400
- } , [ props . chatModel ] ) ;
486
+ } , [ props . chatModel , props . actionService , props . labelProvider ] ) ;
401
487
402
488
React . useEffect ( ( ) => {
403
489
const disposable = props . actionService . onDidChange ( ( ) => {
@@ -406,18 +492,28 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
406
492
setChangeSetUI ( current => ! current ? current : { ...current , actions : newActions } ) ;
407
493
} ) ;
408
494
return ( ) => disposable . dispose ( ) ;
409
- } ) ;
495
+ } , [ props . actionService , props . chatModel . changeSet ] ) ;
496
+
497
+ // // Extract image references from text
498
+ // const extractImageReferences = (text: string): string[] => {
499
+ // const regex = /!\[.*?\]\((img-\d+)\)/g;
500
+ // const matches = [...text.matchAll(regex)];
501
+ // return matches.map(match => match[1]);
502
+ // };
410
503
411
504
const submit = React . useCallback ( function submit ( value : string ) : void {
412
- if ( ! value || value . trim ( ) . length === 0 ) {
505
+ if ( ( ! value || value . trim ( ) . length === 0 ) && props . pastedImages . length === 0 ) {
413
506
return ;
414
507
}
508
+
415
509
setInProgress ( true ) ;
416
- props . onQuery ( value ) ;
510
+ props . onQuery ( value , props . pastedImages . map ( p => ( { imageData : p . data , mediaType : p . type } ) ) ) ;
511
+
417
512
if ( editorRef . current ) {
418
513
editorRef . current . document . textEditorModel . setValue ( '' ) ;
419
- }
420
- } , [ props . context , props . onQuery , editorRef ] ) ;
514
+ } // Clear pasted images after submission
515
+ props . pastedImages . forEach ( image => props . onRemoveImage ( image . id ) ) ;
516
+ } , [ props . onQuery , props . pastedImages ] ) ;
421
517
422
518
const onKeyDown = React . useCallback ( ( event : React . KeyboardEvent ) => {
423
519
if ( ! props . isEnabled ) {
@@ -517,20 +613,30 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
517
613
518
614
const contextUI = buildContextUI ( props . context , props . labelProvider , props . onDeleteContextElement ) ;
519
615
520
- return < div className = 'theia-ChatInput' onDragOver = { props . onDragOver } onDrop = { props . onDrop } >
521
- { changeSetUI ?. elements &&
522
- < ChangeSetBox changeSet = { changeSetUI } />
523
- }
524
- < div className = 'theia-ChatInput-Editor-Box' >
525
- < div className = 'theia-ChatInput-Editor' ref = { editorContainerRef } onKeyDown = { onKeyDown } onFocus = { handleInputFocus } onBlur = { handleInputBlur } >
526
- < div ref = { placeholderRef } className = 'theia-ChatInput-Editor-Placeholder' > { nls . localizeByDefault ( 'Ask a question' ) } </ div >
527
- </ div >
528
- { props . context && props . context . length > 0 &&
529
- < ChatContext context = { contextUI . context } />
616
+ return (
617
+ < div
618
+ className = 'theia-ChatInput'
619
+ onDragOver = { props . onDragOver }
620
+ onDrop = { props . onDrop }
621
+ ref = { containerRef }
622
+ >
623
+ { changeSetUI ?. elements &&
624
+ < ChangeSetBox changeSet = { changeSetUI } />
530
625
}
531
- < ChatInputOptions leftOptions = { leftOptions } rightOptions = { rightOptions } />
626
+ < div className = 'theia-ChatInput-Editor-Box' >
627
+ < div className = 'theia-ChatInput-Editor' ref = { editorContainerRef } onKeyDown = { onKeyDown } onFocus = { handleInputFocus } onBlur = { handleInputBlur } >
628
+ < div ref = { placeholderRef } className = 'theia-ChatInput-Editor-Placeholder' > { nls . localizeByDefault ( 'Ask a question' ) } </ div >
629
+ </ div >
630
+ { props . pastedImages . length > 0 &&
631
+ < ImagePreview images = { props . pastedImages } onRemove = { props . onRemoveImage } />
632
+ }
633
+ { props . context && props . context . length > 0 &&
634
+ < ChatContext context = { contextUI . context } />
635
+ }
636
+ < ChatInputOptions leftOptions = { leftOptions } rightOptions = { rightOptions } />
637
+ </ div >
532
638
</ div >
533
- </ div > ;
639
+ ) ;
534
640
} ;
535
641
536
642
const noPropagation = ( handler : ( ) => void ) => ( e : React . MouseEvent ) => {
0 commit comments