14
14
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
// *****************************************************************************
16
16
import { ChangeSet , ChatAgent , ChatChangeEvent , ChatModel , ChatRequestModel , ChatService , ChatSuggestion } from '@theia/ai-chat' ;
17
+ import { ChangeSetDecoratorService } from '@theia/ai-chat/lib/browser/change-set-decorator-service' ;
18
+ import { AIVariableResolutionRequest , LLMImageData } from '@theia/ai-core' ;
19
+ import { FrontendVariableService } from '@theia/ai-core/lib/browser' ;
17
20
import { Disposable , DisposableCollection , InMemoryResources , URI , nls } from '@theia/core' ;
18
21
import { ContextMenuRenderer , LabelProvider , Message , OpenerService , ReactWidget } from '@theia/core/lib/browser' ;
19
22
import { Deferred } from '@theia/core/lib/common/promise-util' ;
20
23
import { inject , injectable , optional , postConstruct } from '@theia/core/shared/inversify' ;
21
24
import * as React from '@theia/core/shared/react' ;
22
25
import { IMouseEvent } from '@theia/monaco-editor-core' ;
23
- import { SimpleMonacoEditor } from '@theia/monaco/lib/browser/simple-monaco-editor' ;
24
26
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider' ;
25
- import { CHAT_VIEW_LANGUAGE_EXTENSION } from './chat-view-language-contribution' ;
26
- import { AIVariableResolutionRequest } from '@theia/ai-core' ;
27
- import { FrontendVariableService } from '@theia/ai-core/lib/browser' ;
28
- import { ContextVariablePicker } from './context-variable-picker' ;
27
+ import { SimpleMonacoEditor } from '@theia/monaco/lib/browser/simple-monaco-editor' ;
28
+ import { ImagePreview , PastedImage } from './ImagePreview' ;
29
29
import { ChangeSetActionRenderer , ChangeSetActionService } from './change-set-actions/change-set-action-service' ;
30
- import { ChangeSetDecoratorService } from '@theia/ai-chat/lib/browser/change-set-decorator-service' ;
31
30
import { ChatInputAgentSuggestions } from './chat-input-agent-suggestions' ;
31
+ import { CHAT_VIEW_LANGUAGE_EXTENSION } from './chat-view-language-contribution' ;
32
+ import { ContextVariablePicker } from './context-variable-picker' ;
32
33
33
34
type Query = ( query : string ) => Promise < void > ;
34
35
type Unpin = ( ) => void ;
@@ -37,6 +38,12 @@ type DeleteChangeSet = (requestModel: ChatRequestModel) => void;
37
38
type DeleteChangeSetElement = ( requestModel : ChatRequestModel , index : number ) => void ;
38
39
type OpenContextElement = ( request : AIVariableResolutionRequest ) => unknown ;
39
40
41
+ // Interface for the payload submitted to the AI
42
+ // interface ChatPayload {
43
+ // text: string;
44
+ // images?: PastedImage[];
45
+ // }
46
+
40
47
export const AIChatInputConfiguration = Symbol ( 'AIChatInputConfiguration' ) ;
41
48
export interface AIChatInputConfiguration {
42
49
showContext ?: boolean ;
@@ -132,13 +139,54 @@ export class AIChatInputWidget extends ReactWidget {
132
139
this . update ( ) ;
133
140
}
134
141
142
+ // State for pasted images
143
+ private _pastedImages : PastedImage [ ] = [ ] ;
144
+ public get pastedImages ( ) : PastedImage [ ] {
145
+ return this . _pastedImages ;
146
+ }
147
+
135
148
@postConstruct ( )
136
149
protected init ( ) : void {
137
150
this . id = AIChatInputWidget . ID ;
138
151
this . title . closable = false ;
139
152
this . update ( ) ;
140
153
}
141
154
155
+ // Process a file blob into an image
156
+ private processImageFromClipboard ( blob : File ) : void {
157
+ const reader = new FileReader ( ) ;
158
+ reader . onload = e => {
159
+ if ( ! e . target ?. result ) { return ; }
160
+
161
+ const imageId = `img-${ Date . now ( ) } ` ;
162
+ const dataUrl = e . target . result as string ;
163
+
164
+ // Extract the base64 data by removing the data URL prefix
165
+ // Format is like: data:image/png;base64,BASE64DATA
166
+ const imageData = dataUrl . substring ( dataUrl . indexOf ( ',' ) + 1 ) ;
167
+
168
+ // Add image to state
169
+ const newImage : PastedImage = {
170
+ id : imageId ,
171
+ data : imageData , // Store just the base64 data without the prefix
172
+ name : blob . name || `pasted-image-${ Date . now ( ) } .png` ,
173
+ type : blob . type as PastedImage [ 'type' ]
174
+ } ;
175
+
176
+ this . _pastedImages = [ ...this . _pastedImages , newImage ] ;
177
+
178
+ this . update ( ) ;
179
+ } ;
180
+
181
+ reader . readAsDataURL ( blob ) ;
182
+ }
183
+
184
+ // Remove an image by id
185
+ public removeImage ( id : string ) : void {
186
+ this . _pastedImages = this . _pastedImages . filter ( img => img . id !== id ) ;
187
+ this . update ( ) ;
188
+ }
189
+
142
190
protected override onActivateRequest ( msg : Message ) : void {
143
191
super . onActivateRequest ( msg ) ;
144
192
this . editorReady . promise . then ( ( ) => {
@@ -185,6 +233,9 @@ export class AIChatInputWidget extends ReactWidget {
185
233
decoratorService = { this . changeSetDecoratorService }
186
234
initialValue = { this . _initialValue }
187
235
openerService = { this . openerService }
236
+ pastedImages = { this . _pastedImages }
237
+ onRemoveImage = { this . removeImage . bind ( this ) }
238
+ onImagePasted = { this . processImageFromClipboard . bind ( this ) }
188
239
suggestions = { this . _chatModel . suggestions }
189
240
/>
190
241
) ;
@@ -268,7 +319,7 @@ export class AIChatInputWidget extends ReactWidget {
268
319
269
320
interface ChatInputProperties {
270
321
onCancel : ( requestModel : ChatRequestModel ) => void ;
271
- onQuery : ( query : string ) => void ;
322
+ onQuery : ( query ? : string , images ?: LLMImageData [ ] ) => void ;
272
323
onUnpin : ( ) => void ;
273
324
onDragOver : ( event : React . DragEvent ) => void ;
274
325
onDrop : ( event : React . DragEvent ) => void ;
@@ -294,6 +345,9 @@ interface ChatInputProperties {
294
345
decoratorService : ChangeSetDecoratorService ;
295
346
initialValue ?: string ;
296
347
openerService : OpenerService ;
348
+ pastedImages : PastedImage [ ] ;
349
+ onRemoveImage : ( id : string ) => void ;
350
+ onImagePasted : ( blob : File ) => void ;
297
351
suggestions : readonly ChatSuggestion [ ]
298
352
}
299
353
@@ -321,6 +375,38 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
321
375
// eslint-disable-next-line no-null/no-null
322
376
const placeholderRef = React . useRef < HTMLDivElement | null > ( null ) ;
323
377
const editorRef = React . useRef < SimpleMonacoEditor | undefined > ( undefined ) ;
378
+ // eslint-disable-next-line no-null/no-null
379
+ const containerRef = React . useRef < HTMLDivElement > ( null ) ;
380
+
381
+ // Handle paste events on the container
382
+ const handlePaste = React . useCallback ( ( e : ClipboardEvent ) => {
383
+ if ( ! e . clipboardData ?. items ) { return ; }
384
+
385
+ for ( const item of e . clipboardData . items ) {
386
+ if ( item . type . startsWith ( 'image/' ) ) {
387
+ const blob = item . getAsFile ( ) ;
388
+ if ( blob ) {
389
+ e . preventDefault ( ) ;
390
+ e . stopPropagation ( ) ;
391
+ props . onImagePasted ( blob ) ;
392
+ break ;
393
+ }
394
+ }
395
+ }
396
+ } , [ props . onImagePasted ] ) ;
397
+
398
+ // Set up paste handler on the container div
399
+ React . useEffect ( ( ) => {
400
+ const container = containerRef . current ;
401
+ if ( container ) {
402
+ container . addEventListener ( 'paste' , handlePaste , true ) ;
403
+
404
+ return ( ) => {
405
+ container . removeEventListener ( 'paste' , handlePaste , true ) ;
406
+ } ;
407
+ }
408
+ return undefined ;
409
+ } , [ handlePaste ] ) ;
324
410
325
411
React . useEffect ( ( ) => {
326
412
const uri = props . resourceUriProvider ( ) ;
@@ -451,7 +537,7 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
451
537
responseListenerRef . current ?. dispose ( ) ;
452
538
responseListenerRef . current = undefined ;
453
539
} ;
454
- } , [ props . chatModel ] ) ;
540
+ } , [ props . chatModel , props . actionService , props . labelProvider ] ) ;
455
541
456
542
React . useEffect ( ( ) => {
457
543
const disposable = props . actionService . onDidChange ( ( ) => {
@@ -460,7 +546,14 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
460
546
setChangeSetUI ( current => ! current ? current : { ...current , actions : newActions } ) ;
461
547
} ) ;
462
548
return ( ) => disposable . dispose ( ) ;
463
- } ) ;
549
+ } , [ props . actionService , props . chatModel . changeSet ] ) ;
550
+
551
+ // // Extract image references from text
552
+ // const extractImageReferences = (text: string): string[] => {
553
+ // const regex = /!\[.*?\]\((img-\d+)\)/g;
554
+ // const matches = [...text.matchAll(regex)];
555
+ // return matches.map(match => match[1]);
556
+ // };
464
557
465
558
React . useEffect ( ( ) => {
466
559
const disposable = props . decoratorService . onDidChangeDecorations ( ( ) => {
@@ -486,13 +579,19 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
486
579
} , [ editorRef ] ) ;
487
580
488
581
const submit = React . useCallback ( function submit ( value : string ) : void {
489
- if ( ! value || value . trim ( ) . length === 0 ) {
582
+ if ( ( ! value || value . trim ( ) . length === 0 ) && props . pastedImages . length === 0 ) {
490
583
return ;
491
584
}
585
+
492
586
setInProgress ( true ) ;
493
- props . onQuery ( value ) ;
587
+ props . onQuery ( value , props . pastedImages . map ( p => ( { imageData : p . data , mediaType : p . type } ) ) ) ;
494
588
setValue ( '' ) ;
495
- } , [ props . context , props . onQuery , setValue ] ) ;
589
+
590
+ if ( editorRef . current ) {
591
+ editorRef . current . document . textEditorModel . setValue ( '' ) ;
592
+ } // Clear pasted images after submission
593
+ props . pastedImages . forEach ( image => props . onRemoveImage ( image . id ) ) ;
594
+ } , [ props . context , props . onQuery , setValue , props . pastedImages ] ) ;
496
595
497
596
const onKeyDown = React . useCallback ( ( event : React . KeyboardEvent ) => {
498
597
if ( ! props . isEnabled ) {
@@ -592,21 +691,31 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
592
691
593
692
const contextUI = buildContextUI ( props . context , props . labelProvider , props . onDeleteContextElement , props . onOpenContextElement ) ;
594
693
595
- return < div className = 'theia-ChatInput' onDragOver = { props . onDragOver } onDrop = { props . onDrop } >
596
- { < ChatInputAgentSuggestions suggestions = { props . suggestions } opener = { props . openerService } /> }
597
- { props . showChangeSet && changeSetUI ?. elements &&
598
- < ChangeSetBox changeSet = { changeSetUI } />
599
- }
600
- < div className = 'theia-ChatInput-Editor-Box' >
601
- < div className = 'theia-ChatInput-Editor' ref = { editorContainerRef } onKeyDown = { onKeyDown } onFocus = { handleInputFocus } onBlur = { handleInputBlur } >
602
- < div ref = { placeholderRef } className = 'theia-ChatInput-Editor-Placeholder' > { nls . localizeByDefault ( 'Ask a question' ) } </ div >
603
- </ div >
604
- { props . context && props . context . length > 0 &&
605
- < ChatContext context = { contextUI . context } />
694
+ return (
695
+ < div
696
+ className = 'theia-ChatInput'
697
+ onDragOver = { props . onDragOver }
698
+ onDrop = { props . onDrop }
699
+ ref = { containerRef }
700
+ >
701
+ { < ChatInputAgentSuggestions suggestions = { props . suggestions } opener = { props . openerService } /> }
702
+ { props . showChangeSet && changeSetUI ?. elements &&
703
+ < ChangeSetBox changeSet = { changeSetUI } />
606
704
}
607
- < ChatInputOptions leftOptions = { leftOptions } rightOptions = { rightOptions } />
705
+ < div className = 'theia-ChatInput-Editor-Box' >
706
+ < div className = 'theia-ChatInput-Editor' ref = { editorContainerRef } onKeyDown = { onKeyDown } onFocus = { handleInputFocus } onBlur = { handleInputBlur } >
707
+ < div ref = { placeholderRef } className = 'theia-ChatInput-Editor-Placeholder' > { nls . localizeByDefault ( 'Ask a question' ) } </ div >
708
+ </ div >
709
+ { props . pastedImages . length > 0 &&
710
+ < ImagePreview images = { props . pastedImages } onRemove = { props . onRemoveImage } />
711
+ }
712
+ { props . context && props . context . length > 0 &&
713
+ < ChatContext context = { contextUI . context } />
714
+ }
715
+ < ChatInputOptions leftOptions = { leftOptions } rightOptions = { rightOptions } />
716
+ </ div >
608
717
</ div >
609
- </ div > ;
718
+ ) ;
610
719
} ;
611
720
612
721
const noPropagation = ( handler : ( ) => void ) => ( e : React . MouseEvent ) => {
0 commit comments