@jupyterlite/ai 0.16.0 → 0.17.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.
package/lib/index.js CHANGED
@@ -213,6 +213,22 @@ const chatModelHandler = {
213
213
  });
214
214
  }
215
215
  };
216
+ /**
217
+ * The active cell manager plugin, to allow copying code from chat to notebook.
218
+ */
219
+ const activeCellManager = {
220
+ id: '@jupyterlite/ai:activeCellManager',
221
+ description: 'Add the active cell manager to the model handler',
222
+ autoStart: true,
223
+ requires: [IChatModelHandler, INotebookTracker],
224
+ activate: (app, modelHandler, notebookTracker) => {
225
+ const activeCellManager = new ActiveCellManager({
226
+ tracker: notebookTracker,
227
+ shell: app.shell
228
+ });
229
+ modelHandler.activeCellManager = activeCellManager;
230
+ }
231
+ };
216
232
  /**
217
233
  * Initialization data for the extension.
218
234
  */
@@ -232,13 +248,12 @@ const plugin = {
232
248
  IThemeManager,
233
249
  ILayoutRestorer,
234
250
  ILabShell,
235
- INotebookTracker,
236
251
  ITranslator,
237
252
  IComponentsRendererFactory,
238
253
  ICommandPalette,
239
254
  IDocumentManager
240
255
  ],
241
- activate: (app, rmRegistry, inputToolbarFactory, modelHandler, settingsModel, chatCommandRegistry, themeManager, restorer, labShell, notebookTracker, translator, chatComponentsFactory, palette, documentManager) => {
256
+ activate: (app, rmRegistry, inputToolbarFactory, modelHandler, settingsModel, chatCommandRegistry, themeManager, restorer, labShell, translator, chatComponentsFactory, palette, documentManager) => {
242
257
  const trans = (translator ?? nullTranslator).load('jupyterlite_ai');
243
258
  // Create attachment opener registry to handle file attachments
244
259
  const attachmentOpenerRegistry = new AttachmentOpenerRegistry();
@@ -253,16 +268,6 @@ const plugin = {
253
268
  void app.commands.execute(CommandIds.openSettings);
254
269
  }
255
270
  };
256
- // Create ActiveCellManager if notebook tracker is available, and add it to the
257
- // model registry.
258
- let activeCellManager;
259
- if (notebookTracker) {
260
- activeCellManager = new ActiveCellManager({
261
- tracker: notebookTracker,
262
- shell: app.shell
263
- });
264
- }
265
- modelHandler.activeCellManager = activeCellManager;
266
271
  // Creating the tracker for the chat widgets
267
272
  const namespace = 'ai-chat';
268
273
  const tracker = new WidgetTracker({ namespace });
@@ -346,6 +351,16 @@ const plugin = {
346
351
  function saveTracker() {
347
352
  tracker.save(widget);
348
353
  }
354
+ function updateToolbarTitleOverlay() {
355
+ const titleNode = chatPanel.current?.toolbar.node
356
+ .getElementsByClassName('jp-chat-sidepanel-widget-title')
357
+ .item(0);
358
+ if (titleNode) {
359
+ titleNode.setAttribute('title', model.title ?? model.name);
360
+ }
361
+ }
362
+ model.titleChanged.connect(updateToolbarTitleOverlay);
363
+ updateToolbarTitleOverlay();
349
364
  // Update the tracker if the model name changed.
350
365
  model.nameChanged.connect(saveTracker);
351
366
  // Update the tracker if the active provider changed.
@@ -386,6 +401,7 @@ const plugin = {
386
401
  chatPanel: widget
387
402
  });
388
403
  widget.disposed.connect(() => {
404
+ model.titleChanged.disconnect(updateToolbarTitleOverlay);
389
405
  model.nameChanged.disconnect(saveTracker);
390
406
  model.agentManager.activeProviderChanged.disconnect(saveTracker);
391
407
  model.writersChanged?.disconnect(writersChanged);
@@ -514,6 +530,49 @@ function registerCommands(app, rmRegistry, chatPanel, attachmentOpenerRegistry,
514
530
  model.nameChanged.disconnect(saveTracker);
515
531
  model.agentManager.activeProviderChanged.disconnect(saveTracker);
516
532
  });
533
+ return widget;
534
+ };
535
+ const focusOnChat = (area, widget) => {
536
+ if (area === 'main' && widget) {
537
+ app.shell.activateById(widget.id);
538
+ }
539
+ else {
540
+ app.shell.activateById(chatPanel.id);
541
+ }
542
+ };
543
+ const applyInputArgs = (model, args) => {
544
+ const input = typeof args.input === 'string' ? args.input : undefined;
545
+ const autoSend = args.autoSend === true;
546
+ const shouldFocus = args.focus !== false;
547
+ if (input !== undefined) {
548
+ model.input.value = input;
549
+ }
550
+ if (autoSend && input !== undefined) {
551
+ model.input.send(model.input.value);
552
+ }
553
+ if (shouldFocus) {
554
+ model.input.focus();
555
+ }
556
+ };
557
+ const findChatWidget = (name, provider) => {
558
+ if (!name && !provider) {
559
+ return;
560
+ }
561
+ return tracker.find(widget => {
562
+ const model = widget.model;
563
+ return ((!name || widget.model.name === name) &&
564
+ (!provider || model.agentManager.activeProvider === provider));
565
+ });
566
+ };
567
+ const disposeSideChatModel = (model) => {
568
+ const loadedName = chatPanel
569
+ .getLoadedModelNames()
570
+ .find(name => chatPanel.getLoadedModel(name) === model);
571
+ if (!loadedName) {
572
+ return false;
573
+ }
574
+ chatPanel.disposeLoadedModel(loadedName);
575
+ return true;
517
576
  };
518
577
  commands.addCommand(CommandIds.openChat, {
519
578
  label: trans.__('Open a chat'),
@@ -543,12 +602,18 @@ function registerCommands(app, rmRegistry, chatPanel, attachmentOpenerRegistry,
543
602
  if (!model) {
544
603
  return false;
545
604
  }
605
+ const shouldFocus = args.focus === true;
606
+ let widget;
546
607
  if (area === 'main') {
547
- openInMain(model);
608
+ widget = openInMain(model);
548
609
  }
549
610
  else {
550
- chatPanel.open({ model });
611
+ widget = chatPanel.open({ model });
612
+ }
613
+ if (shouldFocus) {
614
+ focusOnChat(area, widget);
551
615
  }
616
+ applyInputArgs(model, { ...args, focus: shouldFocus });
552
617
  return true;
553
618
  },
554
619
  describedBy: {
@@ -567,6 +632,116 @@ function registerCommands(app, rmRegistry, chatPanel, attachmentOpenerRegistry,
567
632
  provider: {
568
633
  type: 'string',
569
634
  description: trans.__('The provider/model to use with this chat')
635
+ },
636
+ input: {
637
+ type: 'string',
638
+ description: trans.__('The input text to prefill in the chat')
639
+ },
640
+ focus: {
641
+ type: 'boolean',
642
+ description: trans.__('Whether to focus the chat input after opening it')
643
+ },
644
+ autoSend: {
645
+ type: 'boolean',
646
+ description: trans.__('Whether to auto-send the provided input after opening the chat')
647
+ }
648
+ }
649
+ }
650
+ }
651
+ });
652
+ commands.addCommand(CommandIds.openOrRevealChat, {
653
+ label: trans.__('Open or reveal the chat panel'),
654
+ execute: async (args) => {
655
+ const area = args.area === 'main' ? 'main' : 'side';
656
+ const provider = args.provider ?? undefined;
657
+ const name = args.name ?? undefined;
658
+ const shouldFocus = args.focus === true;
659
+ let existingWidget = findChatWidget(name, provider);
660
+ if (!existingWidget && !name) {
661
+ const providerConfig = provider
662
+ ? settingsModel.getProvider(provider)
663
+ : settingsModel.getDefaultProvider();
664
+ existingWidget = findChatWidget(undefined, providerConfig?.id);
665
+ }
666
+ // If the side chat model is loaded but not currently displayed, reveal it first.
667
+ if (!existingWidget && name) {
668
+ const loadedModel = chatPanel.getLoadedModel(name);
669
+ if (loadedModel) {
670
+ existingWidget = chatPanel.open({ model: loadedModel });
671
+ }
672
+ }
673
+ if (!existingWidget) {
674
+ return commands.execute(CommandIds.openChat, {
675
+ ...args,
676
+ focus: shouldFocus
677
+ });
678
+ }
679
+ const currentArea = existingWidget instanceof MainAreaChat ? 'main' : 'side';
680
+ if (currentArea !== area) {
681
+ const targetName = existingWidget.model.name;
682
+ const moved = (await commands.execute(CommandIds.moveChat, {
683
+ name: targetName,
684
+ area
685
+ }));
686
+ if (!moved) {
687
+ return false;
688
+ }
689
+ const movedWidget = findChatWidget(targetName);
690
+ if (!movedWidget) {
691
+ return false;
692
+ }
693
+ if (area === 'side') {
694
+ chatPanel.open({ model: movedWidget.model });
695
+ }
696
+ if (shouldFocus) {
697
+ focusOnChat(area, movedWidget);
698
+ }
699
+ applyInputArgs(movedWidget.model, {
700
+ ...args,
701
+ focus: shouldFocus
702
+ });
703
+ return true;
704
+ }
705
+ if (area === 'side') {
706
+ chatPanel.open({ model: existingWidget.model });
707
+ }
708
+ if (shouldFocus) {
709
+ focusOnChat(area, existingWidget);
710
+ }
711
+ applyInputArgs(existingWidget.model, {
712
+ ...args,
713
+ focus: shouldFocus
714
+ });
715
+ return true;
716
+ },
717
+ describedBy: {
718
+ args: {
719
+ type: 'object',
720
+ properties: {
721
+ area: {
722
+ type: 'string',
723
+ enum: ['main', 'side'],
724
+ description: trans.__('The name of the area to open or reveal the chat in')
725
+ },
726
+ name: {
727
+ type: 'string',
728
+ description: trans.__('The name of the chat')
729
+ },
730
+ provider: {
731
+ type: 'string',
732
+ description: trans.__('The provider/model to use with this chat')
733
+ },
734
+ input: {
735
+ type: 'string',
736
+ description: trans.__('The input text to prefill in the chat')
737
+ },
738
+ focus: {
739
+ type: 'boolean',
740
+ description: trans.__('Whether to focus the chat input after opening it')
741
+ },
742
+ autoSend: {
743
+ type: 'boolean',
744
+ description: trans.__('Whether to auto-send the provided input after opening the chat')
570
745
  }
571
746
  }
572
747
  }
@@ -615,7 +790,8 @@ function registerCommands(app, rmRegistry, chatPanel, attachmentOpenerRegistry,
615
790
  activeProvider: previousModel.agentManager.activeProvider,
616
791
  tokenUsage: previousModel.agentManager.tokenUsage,
617
792
  messages: previousModel.messages,
618
- autosave: previousModel.autosave
793
+ autosave: previousModel.autosave,
794
+ title: previousModel.title
619
795
  });
620
796
  // Wait (with timeout) for the tracker to have updated the previous widget.
621
797
  const status = await Promise.any([
@@ -630,6 +806,14 @@ function registerCommands(app, rmRegistry, chatPanel, attachmentOpenerRegistry,
630
806
  }
631
807
  if (area === 'main') {
632
808
  openInMain(model);
809
+ if (previousWidget instanceof ChatWidget) {
810
+ // Clean up the side-panel model entry before disposing the previous
811
+ // widget/model state.
812
+ if (!disposeSideChatModel(previousModel)) {
813
+ previousWidget.dispose();
814
+ previousModel.dispose();
815
+ }
816
+ }
633
817
  }
634
818
  else {
635
819
  previousWidget?.dispose();
@@ -1118,6 +1302,7 @@ export default [
1118
1302
  skillRegistryPlugin,
1119
1303
  skillsCommandPlugin,
1120
1304
  chatModelHandler,
1305
+ activeCellManager,
1121
1306
  plugin,
1122
1307
  toolRegistry,
1123
1308
  agentManagerFactory,
package/lib/tokens.d.ts CHANGED
@@ -4,7 +4,7 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
4
4
  import { Token } from '@lumino/coreutils';
5
5
  import type { IDisposable } from '@lumino/disposable';
6
6
  import { ISignal } from '@lumino/signaling';
7
- import type { Tool, LanguageModel } from 'ai';
7
+ import type { Tool, LanguageModel, UserContent, ModelMessage } from 'ai';
8
8
  import { ISecretsManager } from 'jupyter-secrets-manager';
9
9
  import type { IModelOptions } from './providers/models';
10
10
  import { AIChatModel } from './chat-model';
@@ -17,6 +17,7 @@ export declare namespace CommandIds {
17
17
  const openSettings = "@jupyterlite/ai:open-settings";
18
18
  const reposition = "@jupyterlite/ai:reposition";
19
19
  const openChat = "@jupyterlite/ai:open-chat";
20
+ const openOrRevealChat = "@jupyterlite/ai:open-or-reveal-chat";
20
21
  const moveChat = "@jupyterlite/ai:move-chat";
21
22
  const refreshSkills = "@jupyterlite/ai:refresh-skills";
22
23
  const saveChat = "@jupyterlite/ai:save-chat";
@@ -465,7 +466,7 @@ export interface IAgentManager {
465
466
  /**
466
467
  * Clears conversation history and resets agent state.
467
468
  */
468
- clearHistory(): void;
469
+ clearHistory(): Promise<void>;
469
470
  /**
470
471
  * Sets the conversation history with a list of messages from the chat.
471
472
  * @param messages The chat messages to set as history
@@ -492,7 +493,12 @@ export interface IAgentManager {
492
493
  * Handles the complete execution cycle including tool calls.
493
494
  * @param message The user message to respond to (may include processed attachment content)
494
495
  */
495
- generateResponse(message: string): Promise<void>;
496
+ generateResponse(message: UserContent): Promise<void>;
497
+ /**
498
+ * Create a transient language model to request a text response, which won't be added to history.
499
+ * @param messages - the messages sequence to send to the model.
500
+ */
501
+ textResponse(messages: ModelMessage[]): Promise<string>;
496
502
  /**
497
503
  * Initializes the AI agent with current settings and tools.
498
504
  * Sets up the agent with model configuration, tools, and MCP tools.
@@ -561,6 +567,10 @@ export interface ICreateChatOptions {
561
567
  * Whether the chat is autosaved or not.
562
568
  */
563
569
  autosave?: boolean;
570
+ /**
571
+ * An optional title to the chat.
572
+ */
573
+ title?: string | null;
564
574
  }
565
575
  /**
566
576
  * Token for the chat model handler.
package/lib/tokens.js CHANGED
@@ -7,6 +7,7 @@ export var CommandIds;
7
7
  CommandIds.openSettings = '@jupyterlite/ai:open-settings';
8
8
  CommandIds.reposition = '@jupyterlite/ai:reposition';
9
9
  CommandIds.openChat = '@jupyterlite/ai:open-chat';
10
+ CommandIds.openOrRevealChat = '@jupyterlite/ai:open-or-reveal-chat';
10
11
  CommandIds.moveChat = '@jupyterlite/ai:move-chat';
11
12
  CommandIds.refreshSkills = '@jupyterlite/ai:refresh-skills';
12
13
  CommandIds.saveChat = '@jupyterlite/ai:save-chat';
@@ -26,5 +26,6 @@ export declare class MainAreaChat extends MainAreaWidget<ChatWidget> {
26
26
  */
27
27
  get area(): string | undefined;
28
28
  private _writersChanged;
29
+ private _titleChanged;
29
30
  private _outputAreaCompat;
30
31
  }
@@ -10,7 +10,8 @@ import { CommandIds } from '../tokens';
10
10
  export class MainAreaChat extends MainAreaWidget {
11
11
  constructor(options) {
12
12
  super(options);
13
- this.title.label = this.content.model.name;
13
+ this.title.label = this.model.name;
14
+ this.title.caption = this.model.title ?? this.model.name;
14
15
  const { trans } = options;
15
16
  // Move to side button.
16
17
  this.toolbar.addItem('moveToSide', new CommandToolbarButton({
@@ -43,12 +44,14 @@ export class MainAreaChat extends MainAreaWidget {
43
44
  chatPanel: this.content
44
45
  });
45
46
  this.model.writersChanged.connect(this._writersChanged);
47
+ this.model.titleChanged.connect(this._titleChanged);
46
48
  }
47
49
  dispose() {
48
50
  super.dispose();
49
51
  // Dispose of the approval buttons widget when the chat is disposed.
50
52
  this._outputAreaCompat.dispose();
51
53
  this.model.writersChanged.disconnect(this._writersChanged);
54
+ this.model.titleChanged.disconnect(this._titleChanged);
52
55
  }
53
56
  /**
54
57
  * Get the model of the chat.
@@ -74,5 +77,8 @@ export class MainAreaChat extends MainAreaWidget {
74
77
  this.content.inputToolbarRegistry?.show('send');
75
78
  }
76
79
  };
80
+ _titleChanged = () => {
81
+ this.title.caption = this.model.title ?? this.model.name;
82
+ };
77
83
  _outputAreaCompat;
78
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyterlite/ai",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "AI code completions and chat for JupyterLite",
5
5
  "keywords": [
6
6
  "jupyter",
package/src/agent.ts CHANGED
@@ -4,6 +4,7 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
4
4
  import { PromiseDelegate } from '@lumino/coreutils';
5
5
  import { ISignal, Signal } from '@lumino/signaling';
6
6
  import {
7
+ generateText,
7
8
  ToolLoopAgent,
8
9
  type ModelMessage,
9
10
  type LanguageModel,
@@ -13,7 +14,9 @@ import {
13
14
  type TypedToolError,
14
15
  type TypedToolOutputDenied,
15
16
  type TypedToolResult,
16
- type AssistantModelMessage
17
+ type UserContent,
18
+ type AssistantModelMessage,
19
+ APICallError
17
20
  } from 'ai';
18
21
  import { ISecretsManager } from 'jupyter-secrets-manager';
19
22
 
@@ -504,12 +507,13 @@ export class AgentManager implements IAgentManager {
504
507
  this._pendingApprovals.clear();
505
508
 
506
509
  // Convert chat messages to model messages
507
- const modelMessages = messages.map(msg => {
508
- const isAIMessage = msg.sender.username === 'ai-assistant';
510
+ const modelMessages: ModelMessage[] = messages.map(msg => {
511
+ const role =
512
+ msg.sender.username === 'ai-assistant' ? 'assistant' : 'user';
509
513
  return {
510
- role: isAIMessage ? 'assistant' : 'user',
514
+ role,
511
515
  content: msg.body
512
- } as ModelMessage;
516
+ };
513
517
  });
514
518
  this._history = Private.sanitizeModelMessages(modelMessages);
515
519
  }
@@ -571,10 +575,17 @@ export class AgentManager implements IAgentManager {
571
575
  * Handles the complete execution cycle including tool calls.
572
576
  * @param message The user message to respond to (may include processed attachment content)
573
577
  */
574
- async generateResponse(message: string): Promise<void> {
578
+ async generateResponse(message: UserContent): Promise<void> {
575
579
  this._streaming = new PromiseDelegate();
576
580
  this._controller = new AbortController();
577
581
  const responseHistory: ModelMessage[] = [];
582
+
583
+ // Add user message to history
584
+ responseHistory.push({
585
+ role: 'user',
586
+ content: message
587
+ });
588
+
578
589
  try {
579
590
  // Ensure we have an agent
580
591
  if (!this._agent) {
@@ -585,12 +596,6 @@ export class AgentManager implements IAgentManager {
585
596
  throw new Error('Failed to initialize agent');
586
597
  }
587
598
 
588
- // Add user message to history
589
- responseHistory.push({
590
- role: 'user',
591
- content: message
592
- });
593
-
594
599
  let continueLoop = true;
595
600
  while (continueLoop) {
596
601
  const result = await this._agent.stream({
@@ -647,9 +652,41 @@ export class AgentManager implements IAgentManager {
647
652
  this._history.push(...Private.sanitizeModelMessages(responseHistory));
648
653
  } catch (error) {
649
654
  if ((error as Error).name !== 'AbortError') {
655
+ let helpMessage = `${(error as Error).message}`;
656
+
657
+ // Remove attachments from history on payload rejection errors
658
+ if (
659
+ APICallError.isInstance(error) &&
660
+ (error.statusCode === 400 ||
661
+ error.statusCode === 404 ||
662
+ error.statusCode === 413 ||
663
+ error.statusCode === 415 ||
664
+ error.statusCode === 422)
665
+ ) {
666
+ for (const msg of [...this._history, ...responseHistory]) {
667
+ if (msg.role === 'user' && Array.isArray(msg.content)) {
668
+ const hasMedia = msg.content.some(p => p.type !== 'text');
669
+ if (hasMedia) {
670
+ const textContent = msg.content
671
+ .filter(p => p.type === 'text')
672
+ .map(p => (p as { text: string }).text)
673
+ .join('\n');
674
+ msg.content =
675
+ textContent || '_Attachment removed due to error_';
676
+ }
677
+ }
678
+ }
679
+ helpMessage +=
680
+ '\n\nAttachments have been removed from history. Please send your prompt again.';
681
+ }
650
682
  this._agentEvent.emit({
651
683
  type: 'error',
652
- data: { error: error as Error }
684
+ data: { error: new Error(helpMessage) }
685
+ });
686
+ this._history.push(...Private.sanitizeModelMessages(responseHistory));
687
+ this._history.push({
688
+ role: 'assistant',
689
+ content: helpMessage
653
690
  });
654
691
  }
655
692
  } finally {
@@ -658,6 +695,24 @@ export class AgentManager implements IAgentManager {
658
695
  }
659
696
  }
660
697
 
698
+ /**
699
+ * Create a transient language model to request a text response which won't be added to history.
700
+ * @param messages - the messages sequence to send to the model.
701
+ */
702
+ async textResponse(messages: ModelMessage[]): Promise<string> {
703
+ try {
704
+ const model = await this._createModel();
705
+ const result = await generateText({
706
+ model,
707
+ messages
708
+ });
709
+ this._updateTokenUsage(result.totalUsage, result.totalUsage.inputTokens);
710
+ return result.text;
711
+ } catch (e) {
712
+ throw `Error while getting the topic of the chat\n${e}`;
713
+ }
714
+ }
715
+
661
716
  /**
662
717
  * Updates cumulative token usage statistics from a completed model step.
663
718
  */
@@ -932,6 +987,9 @@ ${richOutputWorkflowInstruction}`;
932
987
  await this._handleApprovalRequest(part, processResult);
933
988
  break;
934
989
 
990
+ case 'error':
991
+ throw part.error;
992
+
935
993
  case 'finish-step':
936
994
  this._updateTokenUsage(part.usage, part.usage.inputTokens);
937
995
  break;
@@ -940,7 +998,7 @@ ${richOutputWorkflowInstruction}`;
940
998
  processResult.aborted = true;
941
999
  break;
942
1000
 
943
- // Ignore: text-start, text-end, finish, error, and others
1001
+ // Ignore: text-start, text-end, finish, and others
944
1002
  default:
945
1003
  break;
946
1004
  }
@@ -29,7 +29,7 @@ export class ClearCommandProvider implements IChatCommandProvider {
29
29
  const context = inputModel.chatContext as
30
30
  | AIChatModel.IAIChatContext
31
31
  | undefined;
32
- context?.clearMessages?.();
32
+ await context?.clearMessages?.();
33
33
 
34
34
  inputModel.value = '';
35
35
  inputModel.clearAttachments();
@@ -29,7 +29,8 @@ export class ChatModelHandler implements IChatModelHandler {
29
29
  }
30
30
 
31
31
  createModel(options: ICreateChatOptions): AIChatModel {
32
- const { name, activeProvider, tokenUsage, messages, autosave } = options;
32
+ const { name, activeProvider, tokenUsage, messages, autosave, title } =
33
+ options;
33
34
 
34
35
  // Create Agent Manager first so it can be shared
35
36
  const agentManager = this._agentManagerFactory.createAgent({
@@ -58,6 +59,10 @@ export class ChatModelHandler implements IChatModelHandler {
58
59
 
59
60
  model.name = name;
60
61
 
62
+ if (title) {
63
+ model.title = title;
64
+ }
65
+
61
66
  return model;
62
67
  }
63
68