@@ -27,6 +27,7 @@ import { DEFAULT_CHAT_SYSTEM_PROMPT } from './default-prompts';
27
27
import { jupyternautLiteIcon } from './icons' ;
28
28
import { IAIProviderRegistry } from './tokens' ;
29
29
import { AIChatModel } from './types/ai-model' ;
30
+ import { ContentsManager } from '@jupyterlab/services' ;
30
31
31
32
/**
32
33
* The base64 encoded SVG string of the jupyternaut lite icon.
@@ -39,12 +40,14 @@ export const welcomeMessage = (providers: string[]) => `
39
40
#### Ask JupyterLite AI
40
41
41
42
42
- The provider to use can be set in the <button data-commandLinker-command="settingeditor:open" data-commandLinker-args='{"query": "AI provider "}' href="#">settings editor</button>, by selecting it from
43
- the <img src="${ AI_AVATAR } " width="16" height="16"> _AI provider_ settings.
43
+ The provider to use can be set in the <button data-commandLinker-command="settingeditor:open" data-commandLinker-args='{"query": "AI providers "}' href="#">settings editor</button>, by selecting it from
44
+ the <img src="${ AI_AVATAR } " width="16" height="16"> _AI providers_ settings.
44
45
45
46
The current providers that are available are _${ providers . sort ( ) . join ( '_, _' ) } _.
46
47
47
- To clear the chat, you can use the \`/clear\` command from the chat input.
48
+ - To clear the chat, you can use the \`/clear\` command from the chat input.
49
+
50
+ - To insert file contents into the chat, use the \`/file\` command.
48
51
` ;
49
52
50
53
export type ConnectionMessage = {
@@ -252,6 +255,137 @@ export namespace ChatHandler {
252
255
}
253
256
}
254
257
258
+ export class FileCommandProvider implements IChatCommandProvider {
259
+ public id : string = '@jupyterlite/ai:file-commands' ;
260
+ private _contents = new ContentsManager ( ) ;
261
+
262
+ private _slash_commands : ChatCommand [ ] = [
263
+ {
264
+ name : '/file' ,
265
+ providerId : this . id ,
266
+ replaceWith : '/file' ,
267
+ description : 'Include contents of a selected file'
268
+ }
269
+ ] ;
270
+
271
+ async listCommandCompletions ( inputModel : IInputModel ) {
272
+ const match = inputModel . currentWord ?. match ( / ^ \/ \w * / ) ?. [ 0 ] ;
273
+ return match
274
+ ? this . _slash_commands . filter ( cmd => cmd . name . startsWith ( match ) )
275
+ : [ ] ;
276
+ }
277
+
278
+ async onSubmit ( inputModel : IInputModel ) : Promise < void > {
279
+ const inputText = inputModel . value ?. trim ( ) ?? '' ;
280
+
281
+ const fileMentioned = inputText . match ( / \/ f i l e \s + ` [ ^ ` ] + ` / ) ;
282
+ const hasFollowUp = inputText . replace ( fileMentioned ?. [ 0 ] || '' , '' ) . trim ( ) ;
283
+
284
+ if ( inputText . startsWith ( '/file' ) && ! fileMentioned ) {
285
+ await this . _showFileBrowser ( inputModel ) ;
286
+ } else {
287
+ return ;
288
+ }
289
+
290
+ if ( fileMentioned && hasFollowUp ) {
291
+ console . log ( inputText ) ;
292
+ } else {
293
+ console . log ( 'Waiting for follow-up text.' ) ;
294
+ throw new Error ( 'Incomplete /file command' ) ;
295
+ }
296
+ }
297
+
298
+ private async _showFileBrowser ( inputModel : IInputModel ) : Promise < void > {
299
+ return new Promise ( resolve => {
300
+ const modal = document . createElement ( 'div' ) ;
301
+ modal . className = 'file-browser-modal' ;
302
+ modal . innerHTML = `
303
+ <div class="file-browser-panel">
304
+ <h3>Select a File</h3>
305
+ <ul class="file-list"></ul>
306
+ <div class="button-row">
307
+ <button class="back-btn">Back</button>
308
+ <button class="close-btn">Close</button>
309
+ </div>
310
+ </div>
311
+ ` ;
312
+ document . body . appendChild ( modal ) ;
313
+
314
+ const fileList = modal . querySelector ( '.file-list' ) ! ;
315
+ const closeBtn = modal . querySelector ( '.close-btn' ) as HTMLButtonElement ;
316
+ const backBtn = modal . querySelector ( '.back-btn' ) as HTMLButtonElement ;
317
+ let currentPath = '' ;
318
+
319
+ const listDir = async ( path = '' ) => {
320
+ try {
321
+ const dir = await this . _contents . get ( path , { content : true } ) ;
322
+
323
+ fileList . innerHTML = '' ;
324
+
325
+ for ( const item of dir . content ) {
326
+ const li = document . createElement ( 'li' ) ;
327
+ if ( item . type === 'directory' ) {
328
+ li . textContent = `${ item . name } /` ;
329
+ li . className = 'directory' ;
330
+ } else if ( item . type === 'file' || item . type === 'notebook' ) {
331
+ li . textContent = item . name ;
332
+ li . className = 'file' ;
333
+ }
334
+
335
+ fileList . appendChild ( li ) ;
336
+
337
+ li . onclick = async ( ) => {
338
+ try {
339
+ if ( item . type === 'directory' ) {
340
+ currentPath = item . path ;
341
+ await listDir ( item . path ) ;
342
+ } else if ( item . type === 'file' || item . type === 'notebook' ) {
343
+ const existingText = inputModel . value ?. trim ( ) ;
344
+ const updatedText =
345
+ existingText === '/file'
346
+ ? `/file \`${ item . path } \` `
347
+ : `${ existingText } \`${ item . path } \`` ;
348
+
349
+ inputModel . value = updatedText . trim ( ) ;
350
+ li . style . backgroundColor = '#d2f8d2' ;
351
+
352
+ document . body . removeChild ( modal ) ;
353
+ resolve ( ) ;
354
+ }
355
+ } catch ( error ) {
356
+ console . error ( error ) ;
357
+ document . body . removeChild ( modal ) ;
358
+ resolve ( ) ;
359
+ }
360
+ } ;
361
+
362
+ fileList . appendChild ( li ) ;
363
+ }
364
+ } catch ( err ) {
365
+ console . error ( err ) ;
366
+ }
367
+ } ;
368
+
369
+ closeBtn . onclick = ( ) => {
370
+ document . body . removeChild ( modal ) ;
371
+ resolve ( ) ;
372
+ } ;
373
+ backBtn . onclick = ( ) => {
374
+ if ( ! currentPath || currentPath === '' ) {
375
+ return ;
376
+ }
377
+
378
+ const parts = currentPath . split ( '/' ) ;
379
+ parts . pop ( ) ;
380
+ currentPath = parts . join ( '/' ) ;
381
+ listDir ( currentPath ) ;
382
+ } ;
383
+
384
+ listDir ( ) ;
385
+ } ) ;
386
+ }
387
+ }
388
+
255
389
namespace Private {
256
390
/**
257
391
* Return the current timestamp in milliseconds.
0 commit comments