@jupyterlite/ai 0.9.0-a3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +20 -89
  2. package/lib/agent.d.ts +10 -4
  3. package/lib/agent.js +30 -17
  4. package/lib/chat-model.d.ts +6 -0
  5. package/lib/chat-model.js +144 -17
  6. package/lib/completion/completion-provider.js +1 -13
  7. package/lib/components/completion-status.d.ts +20 -0
  8. package/lib/components/completion-status.js +51 -0
  9. package/lib/components/index.d.ts +1 -0
  10. package/lib/components/index.js +1 -0
  11. package/lib/components/model-select.js +1 -2
  12. package/lib/diff-manager.d.ts +25 -0
  13. package/lib/diff-manager.js +60 -0
  14. package/lib/icons.d.ts +0 -1
  15. package/lib/icons.js +2 -6
  16. package/lib/index.d.ts +2 -2
  17. package/lib/index.js +54 -23
  18. package/lib/models/settings-model.d.ts +4 -0
  19. package/lib/models/settings-model.js +24 -2
  20. package/lib/providers/built-in-providers.d.ts +0 -4
  21. package/lib/providers/built-in-providers.js +17 -23
  22. package/lib/tokens.d.ts +74 -0
  23. package/lib/tokens.js +4 -0
  24. package/lib/tools/commands.js +36 -35
  25. package/lib/tools/file.d.ts +10 -1
  26. package/lib/tools/file.js +235 -146
  27. package/lib/tools/notebook.d.ts +2 -3
  28. package/lib/tools/notebook.js +11 -11
  29. package/lib/widgets/ai-settings.js +78 -13
  30. package/lib/widgets/provider-config-dialog.js +15 -8
  31. package/package.json +5 -3
  32. package/schema/settings-model.json +25 -0
  33. package/src/agent.ts +35 -20
  34. package/src/chat-model.ts +182 -19
  35. package/src/completion/completion-provider.ts +1 -14
  36. package/src/components/completion-status.tsx +79 -0
  37. package/src/components/index.ts +1 -0
  38. package/src/components/model-select.tsx +0 -3
  39. package/src/diff-manager.ts +81 -0
  40. package/src/icons.ts +2 -7
  41. package/src/index.ts +74 -24
  42. package/src/models/settings-model.ts +28 -2
  43. package/src/providers/built-in-providers.ts +17 -24
  44. package/src/tokens.ts +78 -0
  45. package/src/tools/commands.ts +45 -40
  46. package/src/tools/file.ts +295 -164
  47. package/src/tools/notebook.ts +13 -14
  48. package/src/widgets/ai-settings.tsx +184 -35
  49. package/src/widgets/provider-config-dialog.tsx +43 -16
  50. package/style/base.css +14 -0
package/src/chat-model.ts CHANGED
@@ -24,6 +24,8 @@ import { AISettingsModel } from './models/settings-model';
24
24
 
25
25
  import { ITokenUsage } from './tokens';
26
26
 
27
+ import * as nbformat from '@jupyterlab/nbformat';
28
+
27
29
  /**
28
30
  * AI Chat Model implementation that provides chat functionality with OpenAI agents,
29
31
  * tool integration, and MCP server support.
@@ -138,7 +140,7 @@ export class AIChatModel extends AbstractChatModel {
138
140
  time: Date.now() / 1000,
139
141
  type: 'msg',
140
142
  raw_time: false,
141
- attachments: this.input.attachments
143
+ attachments: [...this.input.attachments]
142
144
  };
143
145
  this.messageAdded(userMessage);
144
146
 
@@ -163,6 +165,9 @@ export class AIChatModel extends AbstractChatModel {
163
165
  const attachmentContents = await this._processAttachments(
164
166
  this.input.attachments
165
167
  );
168
+ // Clear attachments right after processing
169
+ this.input.clearAttachments();
170
+
166
171
  if (attachmentContents.length > 0) {
167
172
  enhancedMessage +=
168
173
  '\n\n--- Attached Files ---\n' + attachmentContents.join('\n\n');
@@ -172,8 +177,6 @@ export class AIChatModel extends AbstractChatModel {
172
177
  this.updateWriters([{ user: this._getAIUser() }]);
173
178
 
174
179
  await this._agentManager.generateResponse(enhancedMessage);
175
- // Clear attachments after processing
176
- this.input.clearAttachments();
177
180
  } catch (error) {
178
181
  const errorMessage: IChatMessage = {
179
182
  body: `Error generating AI response: ${(error as Error).message}`,
@@ -279,10 +282,7 @@ export class AIChatModel extends AbstractChatModel {
279
282
  */
280
283
  private _onSettingsChanged(): void {
281
284
  const config = this._settingsModel.config;
282
- this.config = {
283
- ...config,
284
- enableCodeToolbar: true
285
- };
285
+ this.config = { ...config, enableCodeToolbar: true };
286
286
  // Agent manager handles agent recreation automatically via its own settings listener
287
287
  }
288
288
 
@@ -383,7 +383,7 @@ export class AIChatModel extends AbstractChatModel {
383
383
  <div class="jp-ai-tool-body">
384
384
  <div class="jp-ai-tool-section">
385
385
  <div class="jp-ai-tool-label">Input</div>
386
- <pre class="jp-ai-tool-code"><code>${JSON.stringify(event.data.input, null, 2)}</code></pre>
386
+ <pre class="jp-ai-tool-code"><code>${event.data.input}</code></pre>
387
387
  </div>
388
388
  </div>
389
389
  </details>`,
@@ -481,7 +481,7 @@ export class AIChatModel extends AbstractChatModel {
481
481
  <div class="jp-ai-tool-body">
482
482
  <div class="jp-ai-tool-section">
483
483
  <div class="jp-ai-tool-label">${assistantName} wants to execute this tool. Do you approve?</div>
484
- <pre class="jp-ai-tool-code"><code>${JSON.stringify(event.data.toolInput, null, 2)}</code></pre>
484
+ <pre class="jp-ai-tool-code"><code>${event.data.toolInput}</code></pre>
485
485
  </div>
486
486
  [APPROVAL_BUTTONS:${event.data.interruptionId}]
487
487
  </div>
@@ -504,7 +504,9 @@ export class AIChatModel extends AbstractChatModel {
504
504
 
505
505
  ${assistantName} wants to execute this tool. Do you approve?
506
506
 
507
- ${JSON.stringify(event.data.toolInput, null, 2)}
507
+ \`\`\`json
508
+ ${event.data.toolInput}
509
+ \`\`\`
508
510
 
509
511
  [APPROVAL_BUTTONS:${event.data.interruptionId}]`,
510
512
  sender: this._getAIUser(),
@@ -531,7 +533,7 @@ ${JSON.stringify(event.data.toolInput, null, 2)}
531
533
  const toolsList = event.data.approvals
532
534
  .map(
533
535
  (info, index) =>
534
- `**${index + 1}. ${info.toolName}**\n${JSON.stringify(info.toolInput, null, 2)}\n`
536
+ `**${index + 1}. ${info.toolName}**\n\`\`\`json\n${info.toolInput}\n\`\`\`\n`
535
537
  )
536
538
  .join('\n\n');
537
539
 
@@ -582,14 +584,22 @@ ${toolsList}
582
584
 
583
585
  for (const attachment of attachments) {
584
586
  try {
585
- const fileContent = await this._readFileAttachment(attachment);
586
- if (fileContent) {
587
- // Get file extension for syntax highlighting
588
- const fileExtension = PathExt.extname(attachment.value).toLowerCase();
589
- const language = fileExtension === '.ipynb' ? 'json' : '';
590
- contents.push(
591
- `**File: ${attachment.value}**\n\`\`\`${language}\n${fileContent}\n\`\`\``
592
- );
587
+ if (attachment.type === 'notebook' && attachment.cells?.length) {
588
+ const cellContents = await this._readNotebookCells(attachment);
589
+ if (cellContents) {
590
+ contents.push(cellContents);
591
+ }
592
+ } else {
593
+ const fileContent = await this._readFileAttachment(attachment);
594
+ if (fileContent) {
595
+ const fileExtension = PathExt.extname(
596
+ attachment.value
597
+ ).toLowerCase();
598
+ const language = fileExtension === '.ipynb' ? 'json' : '';
599
+ contents.push(
600
+ `**File: ${attachment.value}**\n\`\`\`${language}\n${fileContent}\n\`\`\``
601
+ );
602
+ }
593
603
  }
594
604
  } catch (error) {
595
605
  console.warn(`Failed to read attachment ${attachment.value}:`, error);
@@ -600,6 +610,159 @@ ${toolsList}
600
610
  return contents;
601
611
  }
602
612
 
613
+ /**
614
+ * Reads the content of a notebook cell.
615
+ * @param attachment The notebook attachment to read
616
+ * @returns Cell content as string or null if unable to read
617
+ */
618
+ private async _readNotebookCells(
619
+ attachment: IAttachment
620
+ ): Promise<string | null> {
621
+ if (attachment.type !== 'notebook' || !attachment.cells) {
622
+ return null;
623
+ }
624
+
625
+ try {
626
+ const model = await this.input.documentManager?.services.contents.get(
627
+ attachment.value
628
+ );
629
+ if (!model || model.type !== 'notebook') {
630
+ return null;
631
+ }
632
+
633
+ const kernelLang =
634
+ model.content?.metadata?.language_info?.name ||
635
+ model.content?.metadata?.kernelspec?.language ||
636
+ 'text';
637
+
638
+ const selectedCells = attachment.cells
639
+ .map(cellInfo => {
640
+ const cell = model.content.cells.find(
641
+ (c: any) => c.id === cellInfo.id
642
+ );
643
+ if (!cell) {
644
+ return null;
645
+ }
646
+
647
+ const code = cell.source || '';
648
+ const cellType = cell.cell_type;
649
+ const lang = cellType === 'code' ? kernelLang : cellType;
650
+
651
+ const DISPLAY_PRIORITY = [
652
+ 'application/vnd.jupyter.widget-view+json',
653
+ 'application/javascript',
654
+ 'text/html',
655
+ 'image/svg+xml',
656
+ 'image/png',
657
+ 'image/jpeg',
658
+ 'text/markdown',
659
+ 'text/latex',
660
+ 'text/plain'
661
+ ];
662
+
663
+ function extractDisplay(data: any): string {
664
+ for (const mime of DISPLAY_PRIORITY) {
665
+ if (!(mime in data)) {
666
+ continue;
667
+ }
668
+
669
+ const value = data[mime];
670
+ if (!value) {
671
+ continue;
672
+ }
673
+
674
+ switch (mime) {
675
+ case 'application/vnd.jupyter.widget-view+json':
676
+ return `Widget: ${(value as any).model_id ?? 'unknown model'}`;
677
+
678
+ case 'image/png':
679
+ return `![image](data:image/png;base64,${value.slice(0, 100)}...)`;
680
+
681
+ case 'image/jpeg':
682
+ return `![image](data:image/jpeg;base64,${value.slice(0, 100)}...)`;
683
+
684
+ case 'image/svg+xml':
685
+ return String(value).slice(0, 500) + '...\n[svg truncated]';
686
+
687
+ case 'text/html':
688
+ return (
689
+ String(value).slice(0, 1000) +
690
+ (String(value).length > 1000 ? '\n...[truncated]' : '')
691
+ );
692
+
693
+ case 'text/markdown':
694
+ case 'text/latex':
695
+ case 'text/plain': {
696
+ let text = Array.isArray(value)
697
+ ? value.join('')
698
+ : String(value);
699
+ if (text.length > 2000) {
700
+ text = text.slice(0, 2000) + '\n...[truncated]';
701
+ }
702
+ return text;
703
+ }
704
+
705
+ default:
706
+ return JSON.stringify(value).slice(0, 2000);
707
+ }
708
+ }
709
+
710
+ return JSON.stringify(data).slice(0, 2000);
711
+ }
712
+
713
+ let outputs = '';
714
+ if (cellType === 'code' && Array.isArray(cell.outputs)) {
715
+ outputs = cell.outputs
716
+ .map((output: nbformat.IOutput) => {
717
+ if (output.output_type === 'stream') {
718
+ return (output as nbformat.IStream).text;
719
+ } else if (output.output_type === 'error') {
720
+ const err = output as nbformat.IError;
721
+ return `${err.ename}: ${err.evalue}\n${(err.traceback || []).join('\n')}`;
722
+ } else if (
723
+ output.output_type === 'execute_result' ||
724
+ output.output_type === 'display_data'
725
+ ) {
726
+ const data = (output as nbformat.IDisplayData).data;
727
+ if (!data) {
728
+ return '';
729
+ }
730
+ try {
731
+ return extractDisplay(data);
732
+ } catch (e) {
733
+ console.error('Cannot extract cell output', e);
734
+ return '';
735
+ }
736
+ }
737
+ return '';
738
+ })
739
+ .filter(Boolean)
740
+ .join('\n---\n');
741
+
742
+ if (outputs.length > 2000) {
743
+ outputs = outputs.slice(0, 2000) + '\n...[truncated]';
744
+ }
745
+ }
746
+
747
+ return (
748
+ `**Cell [${cellInfo.id}] (${cellType}):**\n` +
749
+ `\`\`\`${lang}\n${code}\n\`\`\`` +
750
+ (outputs ? `\n**Outputs:**\n\`\`\`text\n${outputs}\n\`\`\`` : '')
751
+ );
752
+ })
753
+ .filter(Boolean)
754
+ .join('\n\n');
755
+
756
+ return `**Notebook: ${attachment.value}**\n${selectedCells}`;
757
+ } catch (error) {
758
+ console.warn(
759
+ `Failed to read notebook cells from ${attachment.value}:`,
760
+ error
761
+ );
762
+ return null;
763
+ }
764
+ }
765
+
603
766
  /**
604
767
  * Reads the content of a file attachment.
605
768
  * @param attachment The file attachment to read
@@ -33,19 +33,6 @@ export interface IProviderCompletionConfig {
33
33
  useFilterText?: boolean;
34
34
  }
35
35
 
36
- /**
37
- * Default system prompt for code completion
38
- */
39
- const DEFAULT_COMPLETION_SYSTEM_PROMPT = `You are an AI code completion assistant. Complete the given code fragment with appropriate code.
40
- Rules:
41
- - Return only the completion text, no explanations or comments
42
- - Do not include code block markers (\`\`\` or similar)
43
- - Make completions contextually relevant to the surrounding code and notebook context
44
- - Follow the language-specific conventions and style guidelines for the detected programming language
45
- - Keep completions concise but functional
46
- - Do not repeat the existing code that comes before the cursor
47
- - Use variables, imports, functions, and other definitions from previous notebook cells when relevant`;
48
-
49
36
  /**
50
37
  * Default temperature for code completion (lower than chat for more deterministic results)
51
38
  */
@@ -86,7 +73,7 @@ export class AICompletionProvider implements IInlineCompletionProvider {
86
73
  * Get the system prompt for the completion.
87
74
  */
88
75
  get systemPrompt(): string {
89
- return DEFAULT_COMPLETION_SYSTEM_PROMPT;
76
+ return this._settingsModel.config.completionSystemPrompt;
90
77
  }
91
78
 
92
79
  /**
@@ -0,0 +1,79 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { AISettingsModel } from '../models/settings-model';
3
+ import { ReactWidget } from '@jupyterlab/ui-components';
4
+ import { jupyternautIcon } from '../icons';
5
+
6
+ const COMPLETION_STATUS_CLASS = 'jp-ai-completion-status';
7
+ const COMPLETION_DISABLED_CLASS = 'jp-ai-completion-disabled';
8
+
9
+ /**
10
+ * The completion status props.
11
+ */
12
+ interface ICompletionStatusProps {
13
+ /**
14
+ * The settings model.
15
+ */
16
+ settingsModel: AISettingsModel;
17
+ }
18
+
19
+ /**
20
+ * The completion status component.
21
+ */
22
+ function CompletionStatus(props: ICompletionStatusProps): JSX.Element {
23
+ const [disabled, setDisabled] = useState<boolean>(true);
24
+ const [title, setTitle] = useState<string>('');
25
+
26
+ /**
27
+ * Handle changes in the settings.
28
+ */
29
+ useEffect(() => {
30
+ const stateChanged = (model: AISettingsModel) => {
31
+ if (model.config.useSameProviderForChatAndCompleter) {
32
+ setDisabled(false);
33
+ setTitle(`Completion using ${model.getDefaultProvider()?.model}`);
34
+ } else if (model.config.activeCompleterProvider) {
35
+ setDisabled(false);
36
+ setTitle(
37
+ `Completion using ${model.getProvider(model.config.activeCompleterProvider)?.model}`
38
+ );
39
+ } else {
40
+ setDisabled(true);
41
+ setTitle('No completion');
42
+ }
43
+ };
44
+
45
+ props.settingsModel.stateChanged.connect(stateChanged);
46
+
47
+ stateChanged(props.settingsModel);
48
+ return () => {
49
+ props.settingsModel.stateChanged.disconnect(stateChanged);
50
+ };
51
+ }, [props.settingsModel]);
52
+
53
+ return (
54
+ <jupyternautIcon.react
55
+ className={disabled ? COMPLETION_DISABLED_CLASS : ''}
56
+ top={'2px'}
57
+ width={'16px'}
58
+ stylesheet={'statusBar'}
59
+ title={title}
60
+ />
61
+ );
62
+ }
63
+
64
+ /**
65
+ * The completion status widget that will be added to the status bar.
66
+ */
67
+ export class CompletionStatusWidget extends ReactWidget {
68
+ constructor(options: ICompletionStatusProps) {
69
+ super();
70
+ this.addClass(COMPLETION_STATUS_CLASS);
71
+ this._props = options;
72
+ }
73
+
74
+ render(): JSX.Element {
75
+ return <CompletionStatus {...this._props} />;
76
+ }
77
+
78
+ private _props: ICompletionStatusProps;
79
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './clear-button';
2
+ export * from './completion-status';
2
3
  export * from './model-select';
3
4
  export * from './stop-button';
4
5
  export * from './token-usage-display';
@@ -5,8 +5,6 @@ import React, { useCallback, useEffect, useState } from 'react';
5
5
  import { AIChatModel } from '../chat-model';
6
6
  import { AISettingsModel } from '../models/settings-model';
7
7
 
8
- const SELECT_ITEM_CLASS = 'labai-model-select-item';
9
-
10
8
  /**
11
9
  * Properties for the model select component.
12
10
  */
@@ -187,7 +185,6 @@ export function ModelSelect(props: IModelSelectProps): JSX.Element {
187
185
  {availableModels.map(({ provider, providerLabel, isSelected }) => (
188
186
  <MenuItem
189
187
  key={provider}
190
- className={SELECT_ITEM_CLASS}
191
188
  onClick={async e => {
192
189
  await selectModel(provider);
193
190
  // Prevent sending message on model selection
@@ -0,0 +1,81 @@
1
+ import { CommandRegistry } from '@lumino/commands';
2
+ import { AISettingsModel } from './models/settings-model';
3
+ import {
4
+ IDiffManager,
5
+ IShowCellDiffParams,
6
+ IShowFileDiffParams
7
+ } from './tokens';
8
+
9
+ /**
10
+ * Command IDs for unified cell diffs
11
+ */
12
+ const UNIFIED_DIFF_COMMAND_ID = 'jupyterlab-diff:unified-cell-diff';
13
+
14
+ /**
15
+ * Command IDs for split cell diffs
16
+ */
17
+ const SPLIT_DIFF_COMMAND_ID = 'jupyterlab-diff:split-cell-diff';
18
+
19
+ /**
20
+ * Command ID for unified file diffs
21
+ */
22
+ const UNIFIED_FILE_DIFF_COMMAND_ID = 'jupyterlab-diff:unified-file-diff';
23
+
24
+ /**
25
+ * Implementation of the diff manager
26
+ */
27
+ export class DiffManager implements IDiffManager {
28
+ /**
29
+ * Construct a new DiffManager
30
+ */
31
+ constructor(options: {
32
+ commands: CommandRegistry;
33
+ settingsModel: AISettingsModel;
34
+ }) {
35
+ this._commands = options.commands;
36
+ this._settingsModel = options.settingsModel;
37
+ }
38
+
39
+ /**
40
+ * Show diff between original and modified cell content
41
+ */
42
+ async showCellDiff(params: IShowCellDiffParams): Promise<void> {
43
+ if (!this._settingsModel.config.showCellDiff) {
44
+ return;
45
+ }
46
+
47
+ const showDiffCommandId =
48
+ this._settingsModel.config.diffDisplayMode === 'unified'
49
+ ? UNIFIED_DIFF_COMMAND_ID
50
+ : SPLIT_DIFF_COMMAND_ID;
51
+
52
+ await this._commands.execute(showDiffCommandId, {
53
+ originalSource: params.original,
54
+ newSource: params.modified,
55
+ cellId: params.cellId,
56
+ showActionButtons: params.showActionButtons ?? true,
57
+ openDiff: params.openDiff ?? true,
58
+ notebookPath: params.notebookPath
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Show diff between original and modified file content
64
+ */
65
+ async showFileDiff(params: IShowFileDiffParams): Promise<void> {
66
+ if (!this._settingsModel.config.showFileDiff) {
67
+ return;
68
+ }
69
+
70
+ // File diffs only support unified view
71
+ await this._commands.execute(UNIFIED_FILE_DIFF_COMMAND_ID, {
72
+ originalSource: params.original,
73
+ newSource: params.modified,
74
+ filePath: params.filePath,
75
+ showActionButtons: params.showActionButtons ?? true
76
+ });
77
+ }
78
+
79
+ private _commands: CommandRegistry;
80
+ private _settingsModel: AISettingsModel;
81
+ }
package/src/icons.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  import { LabIcon } from '@jupyterlab/ui-components';
2
2
 
3
- import labaiIconSvg from '../style/icons/jupyternaut-lite.svg';
4
-
5
- export const labaiIcon = new LabIcon({
6
- name: '@jupyterlite/ai:icon',
7
- svgstr: labaiIconSvg
8
- });
3
+ import jupyternautSvg from '../style/icons/jupyternaut-lite.svg';
9
4
 
10
5
  export const jupyternautIcon = new LabIcon({
11
6
  name: '@jupyterlite/ai:jupyternaut',
12
- svgstr: labaiIconSvg
7
+ svgstr: jupyternautSvg
13
8
  });
14
9
 
15
10
  const AI_AVATAR_BASE64 = btoa(jupyternautIcon.svgstr);