@jupyterlite/ai 0.16.0 → 0.18.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 (40) hide show
  1. package/lib/agent.d.ts +19 -10
  2. package/lib/agent.js +82 -46
  3. package/lib/chat-commands/clear.js +1 -1
  4. package/lib/chat-model-handler.d.ts +2 -3
  5. package/lib/chat-model-handler.js +6 -2
  6. package/lib/chat-model.d.ts +129 -26
  7. package/lib/chat-model.js +543 -160
  8. package/lib/components/clear-button.d.ts +1 -1
  9. package/lib/components/clear-button.js +1 -1
  10. package/lib/components/save-button.d.ts +2 -2
  11. package/lib/index.js +224 -59
  12. package/lib/models/settings-model.js +1 -0
  13. package/lib/providers/built-in-providers.js +1 -1
  14. package/lib/providers/{generated-context-windows.d.ts → generated-model-info.d.ts} +2 -2
  15. package/lib/providers/generated-model-info.js +502 -0
  16. package/lib/providers/model-info.d.ts +3 -0
  17. package/lib/providers/model-info.js +33 -0
  18. package/lib/tokens.d.ts +98 -15
  19. package/lib/tokens.js +1 -0
  20. package/lib/widgets/ai-settings.js +5 -0
  21. package/lib/widgets/main-area-chat.d.ts +3 -3
  22. package/lib/widgets/main-area-chat.js +9 -5
  23. package/package.json +3 -3
  24. package/schema/settings-model.json +6 -0
  25. package/src/agent.ts +100 -52
  26. package/src/chat-commands/clear.ts +1 -1
  27. package/src/chat-model-handler.ts +10 -3
  28. package/src/chat-model.ts +727 -210
  29. package/src/components/clear-button.tsx +3 -3
  30. package/src/components/save-button.tsx +3 -3
  31. package/src/index.ts +289 -83
  32. package/src/models/settings-model.ts +1 -0
  33. package/src/providers/built-in-providers.ts +1 -1
  34. package/src/providers/generated-model-info.ts +508 -0
  35. package/src/providers/model-info.ts +57 -0
  36. package/src/tokens.ts +100 -15
  37. package/src/widgets/ai-settings.tsx +26 -0
  38. package/src/widgets/main-area-chat.ts +14 -9
  39. package/lib/providers/generated-context-windows.js +0 -96
  40. package/src/providers/generated-context-windows.ts +0 -102
package/src/chat-model.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  IChatContext,
6
6
  IMessage,
7
7
  IMessageContent,
8
+ IMimeModelBody,
8
9
  INewMessage,
9
10
  IUser
10
11
  } from '@jupyter/chat';
@@ -31,9 +32,23 @@ import { Debouncer } from '@lumino/polling';
31
32
 
32
33
  import { ISignal, Signal } from '@lumino/signaling';
33
34
 
35
+ import type { UserContent, ImagePart, FilePart, ModelMessage } from 'ai';
36
+
34
37
  import { AI_AVATAR } from './icons';
35
38
 
36
- import type { IAgentManager, IAISettingsModel, ITokenUsage } from './tokens';
39
+ import type {
40
+ IAgentManager,
41
+ IAIChatModel,
42
+ IAISettingsModel,
43
+ IProviderRegistry,
44
+ ITokenUsage
45
+ } from './tokens';
46
+
47
+ import {
48
+ modelSupportsAudio,
49
+ modelSupportsImages,
50
+ modelSupportsPdf
51
+ } from './providers/model-info';
37
52
 
38
53
  /**
39
54
  * Tool call status types.
@@ -66,10 +81,6 @@ interface IToolExecutionContext {
66
81
  * The tool input (formatted).
67
82
  */
68
83
  input: string;
69
- /**
70
- * Optional approval ID if awaiting approval.
71
- */
72
- approvalId?: string;
73
84
  /**
74
85
  * Current status.
75
86
  */
@@ -88,7 +99,7 @@ interface IToolExecutionContext {
88
99
  * AI Chat Model implementation that provides chat functionality tool integration,
89
100
  * and MCP server support.
90
101
  */
91
- export class AIChatModel extends AbstractChatModel {
102
+ export class AIChatModel extends AbstractChatModel implements IAIChatModel {
92
103
  /**
93
104
  * Constructs a new AIChatModel instance.
94
105
  * @param options Configuration options for the chat model
@@ -106,6 +117,7 @@ export class AIChatModel extends AbstractChatModel {
106
117
  this._user = options.user;
107
118
  this._agentManager = options.agentManager;
108
119
  this._contentsManager = options.contentsManager;
120
+ this._providerRegistry = options.providerRegistry;
109
121
 
110
122
  // Listen for agent events
111
123
  this._agentManager.agentEvent.connect(this._onAgentEvent, this);
@@ -113,6 +125,13 @@ export class AIChatModel extends AbstractChatModel {
113
125
  // Listen for settings changes to update chat behavior
114
126
  this._settingsModel.stateChanged.connect(this._onSettingsChanged, this);
115
127
 
128
+ // Rebuild history when the model changes
129
+ this._agentManager.activeProviderChanged.connect(
130
+ this._onModelChanged,
131
+ this
132
+ );
133
+ this._settingsModel.stateChanged.connect(this._onModelChanged, this);
134
+
116
135
  this._autosaveDebouncer = new Debouncer(this.save, 3000);
117
136
  }
118
137
 
@@ -133,6 +152,34 @@ export class AIChatModel extends AbstractChatModel {
133
152
  this.setReady();
134
153
  }
135
154
 
155
+ /**
156
+ * A signal emitting when the chat name has changed.
157
+ */
158
+ get nameChanged(): ISignal<IAIChatModel, string> {
159
+ return this._nameChanged;
160
+ }
161
+
162
+ /**
163
+ * The title of the chat.
164
+ */
165
+ get title(): string | null {
166
+ return this._title;
167
+ }
168
+ set title(value: string | null) {
169
+ this._title = value;
170
+ if (this.autosave) {
171
+ this._autosaveDebouncer.invoke();
172
+ }
173
+ this._titleChanged.emit(this._title);
174
+ }
175
+
176
+ /**
177
+ * A signal emitting when the chat title has changed.
178
+ */
179
+ get titleChanged(): ISignal<IAIChatModel, string | null> {
180
+ return this._titleChanged;
181
+ }
182
+
136
183
  /**
137
184
  * Whether to save the chat automatically.
138
185
  */
@@ -140,6 +187,9 @@ export class AIChatModel extends AbstractChatModel {
140
187
  return this._autosave;
141
188
  }
142
189
  set autosave(value: boolean) {
190
+ if (value === this._autosave) {
191
+ return;
192
+ }
143
193
  this._autosave = value;
144
194
  this._autosaveChanged.emit(value);
145
195
  if (value) {
@@ -151,7 +201,6 @@ export class AIChatModel extends AbstractChatModel {
151
201
  this._autosaveDebouncer.invoke,
152
202
  this._autosaveDebouncer
153
203
  );
154
- this._autosaveDebouncer.invoke();
155
204
  } else {
156
205
  this.messagesUpdated.disconnect(
157
206
  this._autosaveDebouncer.invoke,
@@ -162,22 +211,16 @@ export class AIChatModel extends AbstractChatModel {
162
211
  this._autosaveDebouncer
163
212
  );
164
213
  }
214
+ this._autosaveDebouncer.invoke();
165
215
  }
166
216
 
167
217
  /**
168
218
  * A signal emitting when the autosave flag changed.
169
219
  */
170
- get autosaveChanged(): ISignal<AIChatModel, boolean> {
220
+ get autosaveChanged(): ISignal<IAIChatModel, boolean> {
171
221
  return this._autosaveChanged;
172
222
  }
173
223
 
174
- /**
175
- * A signal emitting when the chat name has changed.
176
- */
177
- get nameChanged(): ISignal<AIChatModel, string> {
178
- return this._nameChanged;
179
- }
180
-
181
224
  /**
182
225
  * Gets the current user information.
183
226
  */
@@ -193,7 +236,7 @@ export class AIChatModel extends AbstractChatModel {
193
236
  }
194
237
 
195
238
  /**
196
- * Get the agent manager associated to the model.
239
+ * The agent manager used in the model.
197
240
  */
198
241
  get agentManager(): IAgentManager {
199
242
  return this._agentManager;
@@ -210,6 +253,7 @@ export class AIChatModel extends AbstractChatModel {
210
253
  * Dispose of the model.
211
254
  */
212
255
  dispose(): void {
256
+ this.stopStreaming();
213
257
  this.messagesUpdated.disconnect(
214
258
  this._autosaveDebouncer.invoke,
215
259
  this._autosaveDebouncer
@@ -229,7 +273,7 @@ export class AIChatModel extends AbstractChatModel {
229
273
  stopStreaming: () => this.stopStreaming(),
230
274
  clearMessages: () => this.clearMessages(),
231
275
  agentManager: this._agentManager,
232
- addSystemMessage: (body: string) => this.addSystemMessage(body)
276
+ addSystemMessage: (body: string) => this._addSystemMessage(body)
233
277
  };
234
278
  }
235
279
 
@@ -243,16 +287,32 @@ export class AIChatModel extends AbstractChatModel {
243
287
  /**
244
288
  * Clears all messages from the chat and resets conversation state.
245
289
  */
246
- clearMessages = (): void => {
290
+ clearMessages = async (): Promise<void> => {
291
+ this.stopStreaming();
292
+ this._messageQueue = [];
293
+ this._isBusy = false;
294
+ this._queueMessageId = null;
295
+ this._currentStreamingMessage = null;
247
296
  this.messagesDeleted(0, this.messages.length);
297
+ this.title = null;
248
298
  this._toolContexts.clear();
249
- this._agentManager.clearHistory();
299
+ await this._agentManager.clearHistory();
250
300
  };
251
301
 
302
+ /**
303
+ * Overrides messageAdded to ensure queued messages stay at the bottom.
304
+ */
305
+ override messageAdded(message: IMessageContent): void {
306
+ super.messageAdded(message);
307
+ if (this._queueMessageId && message.id !== this._queueMessageId) {
308
+ this._updateQueueUI();
309
+ }
310
+ }
311
+
252
312
  /**
253
313
  * Adds a non-user message to the chat (used by chat commands).
254
314
  */
255
- addSystemMessage(body: string): void {
315
+ private _addSystemMessage(body: string): void {
256
316
  const message: IMessageContent = {
257
317
  body,
258
318
  sender: this._getAIUser(),
@@ -285,10 +345,10 @@ export class AIChatModel extends AbstractChatModel {
285
345
  raw_time: false,
286
346
  attachments: [...this.input.attachments]
287
347
  };
288
- this.messageAdded(userMessage);
289
348
 
290
349
  // Check if we have valid configuration
291
350
  if (!this._agentManager.hasValidConfig()) {
351
+ this.messageAdded(userMessage);
292
352
  const errorMessage: IMessageContent = {
293
353
  body: 'Please configure your AI settings first. Open the AI Settings to set your API key and model.',
294
354
  sender: this._getAIUser(),
@@ -301,27 +361,71 @@ export class AIChatModel extends AbstractChatModel {
301
361
  return;
302
362
  }
303
363
 
364
+ if (this._isBusy) {
365
+ this._messageQueue.push({
366
+ id: UUID.uuid4(),
367
+ body: message.body,
368
+ _originalMsg: userMessage
369
+ });
370
+ this.input.clearAttachments();
371
+ this._updateQueueUI();
372
+ return;
373
+ }
374
+
375
+ this._isBusy = true;
376
+ this.messageAdded(userMessage);
377
+ this.input.clearAttachments();
378
+
379
+ await this._processMessage(userMessage);
380
+ }
381
+
382
+ /**
383
+ * Internal method to process attachments and send the message to the agent.
384
+ */
385
+ private async _processMessage(userMessage: IMessageContent): Promise<void> {
304
386
  try {
305
- // Process attachments and add their content to the message
306
- let enhancedMessage = message.body;
307
- if (this.input.attachments.length > 0) {
308
- const attachmentContents = await this._processAttachments(
309
- this.input.attachments
387
+ this.updateWriters([{ user: this._getAIUser() }]);
388
+
389
+ let enhancedMessage: UserContent = userMessage.body;
390
+ if (userMessage.attachments && userMessage.attachments.length > 0) {
391
+ const providerConfig = this._settingsModel.getProvider(
392
+ this._agentManager.activeProvider
393
+ );
394
+ const supportsImages = modelSupportsImages(
395
+ providerConfig,
396
+ this._providerRegistry
397
+ );
398
+ const supportsPdf = modelSupportsPdf(
399
+ providerConfig,
400
+ this._providerRegistry
401
+ );
402
+ const supportsAudio = modelSupportsAudio(
403
+ providerConfig,
404
+ this._providerRegistry
310
405
  );
311
- this.input.clearAttachments();
312
406
 
313
- if (attachmentContents.length > 0) {
314
- enhancedMessage +=
315
- '\n\n--- Attached Files ---\n' + attachmentContents.join('\n\n');
316
- }
407
+ enhancedMessage = await Private.processAttachments(
408
+ userMessage.attachments,
409
+ this.input.documentManager,
410
+ userMessage.body,
411
+ supportsImages,
412
+ supportsPdf,
413
+ supportsAudio
414
+ );
317
415
  }
318
416
 
319
- this.updateWriters([{ user: this._getAIUser() }]);
320
-
321
417
  await this._agentManager.generateResponse(enhancedMessage);
322
418
  } catch (error) {
323
419
  const errorMessage: IMessageContent = {
324
- body: `Error generating AI response: ${(error as Error).message}`,
420
+ body: '',
421
+ mime_model: {
422
+ data: {
423
+ 'application/vnd.jupyter.chat.components': 'error'
424
+ },
425
+ metadata: {
426
+ errorMessage: `Error generating AI response: ${(error as Error).message}`
427
+ }
428
+ },
325
429
  sender: this._getAIUser(),
326
430
  id: UUID.uuid4(),
327
431
  time: Date.now() / 1000,
@@ -330,8 +434,102 @@ export class AIChatModel extends AbstractChatModel {
330
434
  };
331
435
  this.messageAdded(errorMessage);
332
436
  } finally {
437
+ this._drainQueue();
438
+
439
+ if (
440
+ this._settingsModel.config.autoTitle &&
441
+ (this.messages.length <= 5 || this.title === null)
442
+ ) {
443
+ try {
444
+ this.title = await this.requestTitle();
445
+ } catch (e) {
446
+ console.warn('Error while generating a title\n', e);
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Removes the message-queue chat component.
454
+ */
455
+ private _removeQueueUI(): void {
456
+ if (this._queueMessageId) {
457
+ const existingMsg = this.messages.find(
458
+ msg => msg.id === this._queueMessageId
459
+ );
460
+ if (existingMsg) {
461
+ const idx = this.messages.indexOf(existingMsg);
462
+ if (idx !== -1) {
463
+ this.messagesDeleted(idx, 1);
464
+ }
465
+ }
466
+ this._queueMessageId = null;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Creates or updates the message-queue chat component.
472
+ */
473
+ private _updateQueueUI(): void {
474
+ this._removeQueueUI();
475
+
476
+ if (this._messageQueue.length === 0) {
477
+ return;
478
+ }
479
+
480
+ const queueBody = {
481
+ data: {
482
+ 'application/vnd.jupyter.chat.components': 'message-queue'
483
+ },
484
+ metadata: {
485
+ messages: this._messageQueue.map(m => ({
486
+ id: m.id,
487
+ body: m.body,
488
+ attachments: m._originalMsg.attachments
489
+ })),
490
+ targetId: this.name
491
+ }
492
+ } as IMimeModelBody;
493
+
494
+ this._queueMessageId = UUID.uuid4();
495
+ const queueMessage: IMessageContent = {
496
+ body: '',
497
+ mime_model: queueBody,
498
+ sender: { username: 'system', display_name: '' },
499
+ id: this._queueMessageId,
500
+ time: Date.now() / 1000,
501
+ type: 'msg',
502
+ raw_time: false
503
+ };
504
+ this.messageAdded(queueMessage);
505
+ }
506
+
507
+ /**
508
+ * Processes the next message in the queue, or marks the agent as idle.
509
+ */
510
+ private async _drainQueue(): Promise<void> {
511
+ if (this._messageQueue.length === 0) {
512
+ this._isBusy = false;
333
513
  this.updateWriters([]);
514
+ this._removeQueueUI();
515
+ return;
334
516
  }
517
+
518
+ // Dequeue and push to chat
519
+ const next = this._messageQueue.shift()!;
520
+ next._originalMsg.time = Date.now() / 1000;
521
+ this.messageAdded(next._originalMsg);
522
+
523
+ await this._processMessage(next._originalMsg);
524
+ }
525
+
526
+ /**
527
+ * Removes a queued message by its ID.
528
+ * @param messageId The ID of the queued message to remove
529
+ */
530
+ removeQueuedMessage(messageId: string): void {
531
+ this._messageQueue = this._messageQueue.filter(msg => msg.id !== messageId);
532
+ this._updateQueueUI();
335
533
  }
336
534
 
337
535
  /**
@@ -418,13 +616,39 @@ export class AIChatModel extends AbstractChatModel {
418
616
  attachments
419
617
  };
420
618
  });
421
- this.clearMessages();
619
+ await this.clearMessages();
422
620
  this.messagesInserted(0, messages);
423
- this._agentManager.setHistory(messages);
621
+ await this._rebuildHistory();
424
622
  this.autosave = content.metadata?.autosave ?? false;
623
+ this.title = content.metadata?.title ?? null;
425
624
  return true;
426
625
  };
427
626
 
627
+ /**
628
+ * Request a title to this chat, regarding the message history.
629
+ */
630
+ async requestTitle(): Promise<string> {
631
+ const history = this.messages
632
+ .filter(msg => msg.body !== '')
633
+ .map(
634
+ msg =>
635
+ `${msg.sender.username === 'ai-assistant' ? 'assistant' : 'user'}: ${msg.body}`
636
+ )
637
+ .join('\n');
638
+ const messages: ModelMessage[] = [
639
+ {
640
+ role: 'system',
641
+ content:
642
+ "Generate a concise title (no more than 10 words) for the following conversation. Do not use formatting, quotes, or punctuation. Focus on the subject matter and specific content the user is working on, not on the actions taken (e.g. prefer 'Pandas DataFrame filtering' over 'Opening a notebook'). The title should be a noun phrase describing the topic."
643
+ },
644
+ {
645
+ role: 'user',
646
+ content: history
647
+ }
648
+ ];
649
+ return this.agentManager.textResponse(messages);
650
+ }
651
+
428
652
  /**
429
653
  * Serialize the model for backup
430
654
  */
@@ -436,6 +660,13 @@ export class AIChatModel extends AbstractChatModel {
436
660
  const attachmentsList: IAttachment[] = []; // Actual attachments
437
661
 
438
662
  this.messages.forEach(message => {
663
+ if (
664
+ message.content?.mime_model?.data?.[
665
+ 'application/vnd.jupyter.chat.components'
666
+ ] === 'message-queue'
667
+ ) {
668
+ return;
669
+ }
439
670
  let attachmentIndexes: string[] = [];
440
671
  if (message.attachments) {
441
672
  attachmentIndexes = message.attachments.map(attachment => {
@@ -474,7 +705,8 @@ export class AIChatModel extends AbstractChatModel {
474
705
  attachments,
475
706
  metadata: {
476
707
  provider,
477
- autosave: this.autosave
708
+ autosave: this.autosave,
709
+ ...(this.title ? { title: this.title } : {})
478
710
  }
479
711
  };
480
712
  }
@@ -500,6 +732,75 @@ export class AIChatModel extends AbstractChatModel {
500
732
  // Agent manager handles agent recreation automatically via its own settings listener
501
733
  }
502
734
 
735
+ /**
736
+ * Rebuild history when the active model changes.
737
+ */
738
+ private _onModelChanged(): void {
739
+ const providerConfig = this._settingsModel.getProvider(
740
+ this._agentManager.activeProvider
741
+ );
742
+ const modelKey = providerConfig
743
+ ? `${providerConfig.provider}:${providerConfig.model}`
744
+ : undefined;
745
+ if (modelKey && modelKey !== this._currentModelKey) {
746
+ this._currentModelKey = modelKey;
747
+ this._rebuildHistory().catch(e =>
748
+ console.warn('Failed to rebuild history on model change:', e)
749
+ );
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Rebuilds the agent history from the current messages.
755
+ * For vision-capable models, re-reads binary attachments from disk.
756
+ * For text-only models, uses message text only.
757
+ */
758
+ private async _rebuildHistory(): Promise<void> {
759
+ const providerConfig = this._settingsModel.getProvider(
760
+ this._agentManager.activeProvider
761
+ );
762
+ const supportsImages = modelSupportsImages(
763
+ providerConfig,
764
+ this._providerRegistry
765
+ );
766
+ const supportsPdf = modelSupportsPdf(
767
+ providerConfig,
768
+ this._providerRegistry
769
+ );
770
+ const supportsAudio = modelSupportsAudio(
771
+ providerConfig,
772
+ this._providerRegistry
773
+ );
774
+
775
+ const modelMessages: ModelMessage[] = [];
776
+ for (const msg of this.messages) {
777
+ const isAI = msg.sender.username === 'ai-assistant';
778
+ if (!isAI && msg.attachments?.length) {
779
+ const enhancedContent = await Private.processAttachments(
780
+ msg.attachments,
781
+ this.input.documentManager,
782
+ msg.body,
783
+ supportsImages,
784
+ supportsPdf,
785
+ supportsAudio
786
+ );
787
+
788
+ modelMessages.push({
789
+ role: 'user',
790
+ content: enhancedContent
791
+ } as ModelMessage);
792
+ } else if (msg.body) {
793
+ modelMessages.push({
794
+ role: isAI ? 'assistant' : 'user',
795
+ content: msg.body
796
+ } as ModelMessage);
797
+ }
798
+ // Skip messages with empty body like tool calls
799
+ }
800
+
801
+ this._agentManager.setHistory(modelMessages);
802
+ }
803
+
503
804
  /**
504
805
  * Handles events emitted by the agent manager.
505
806
  * @param event The event data containing type and payload
@@ -700,13 +1001,18 @@ export class AIChatModel extends AbstractChatModel {
700
1001
  body: '',
701
1002
  mime_model: {
702
1003
  data: {
703
- 'application/vnd.jupyter.chat.components': 'tool-call'
1004
+ 'application/vnd.jupyter.chat.components': 'grouped-tool-calls'
704
1005
  },
705
1006
  metadata: {
706
- toolName: context.toolName,
707
- input: context.input,
708
- status: context.status,
709
- summary: context.summary
1007
+ toolCalls: [
1008
+ {
1009
+ toolCallId: context.toolCallId,
1010
+ title: `${context.toolName}${context.summary ? ' : ' + context.summary : ''}`,
1011
+ kind: context.toolName,
1012
+ status: 'in_progress',
1013
+ rawInput: context.input
1014
+ }
1015
+ ]
710
1016
  }
711
1017
  },
712
1018
  sender: this._getAIUser(),
@@ -778,7 +1084,15 @@ export class AIChatModel extends AbstractChatModel {
778
1084
  */
779
1085
  private _handleErrorEvent(event: IAgentManager.IAgentEvent<'error'>): void {
780
1086
  this.messageAdded({
781
- body: `Error generating response: ${event.data.error.message}`,
1087
+ body: '',
1088
+ mime_model: {
1089
+ data: {
1090
+ 'application/vnd.jupyter.chat.components': 'error'
1091
+ },
1092
+ metadata: {
1093
+ errorMessage: `Error generating response: ${event.data.error.message}`
1094
+ }
1095
+ },
782
1096
  sender: this._getAIUser(),
783
1097
  id: UUID.uuid4(),
784
1098
  time: Date.now() / 1000,
@@ -797,7 +1111,6 @@ export class AIChatModel extends AbstractChatModel {
797
1111
  if (!context) {
798
1112
  return;
799
1113
  }
800
- context.approvalId = event.data.approvalId;
801
1114
  context.input = JSON.stringify(event.data.args, null, 2);
802
1115
  this._updateToolCallUI(event.data.toolCallId, 'awaiting_approval');
803
1116
  }
@@ -808,15 +1121,13 @@ export class AIChatModel extends AbstractChatModel {
808
1121
  private _handleToolApprovalResolved(
809
1122
  event: IAgentManager.IAgentEvent<'tool_approval_resolved'>
810
1123
  ): void {
811
- const context = Array.from(this._toolContexts.values()).find(
812
- ctx => ctx.approvalId === event.data.approvalId
813
- );
1124
+ const context = this._toolContexts.get(event.data.toolCallId);
814
1125
  if (!context) {
815
1126
  return;
816
1127
  }
817
1128
 
818
1129
  const status = event.data.approved ? 'approved' : 'rejected';
819
- this._updateToolCallUI(context.toolCallId, status);
1130
+ this._updateToolCallUI(event.data.toolCallId, status);
820
1131
 
821
1132
  if (!event.data.approved) {
822
1133
  this._toolContexts.delete(context.toolCallId);
@@ -847,76 +1158,390 @@ export class AIChatModel extends AbstractChatModel {
847
1158
  existingMessage.update({
848
1159
  mime_model: {
849
1160
  data: {
850
- 'application/vnd.jupyter.chat.components': 'tool-call'
1161
+ 'application/vnd.jupyter.chat.components': 'grouped-tool-calls'
851
1162
  },
852
1163
  metadata: {
853
- toolName: context.toolName,
854
- input: context.input,
855
- status: context.status,
856
- summary: context.summary,
857
- output,
858
- targetId: this.name,
859
- approvalId: context.approvalId
1164
+ toolCalls: [
1165
+ {
1166
+ toolCallId: context.toolCallId,
1167
+ title: `${context.toolName}${context.summary ? ' : ' + context.summary : ''}`,
1168
+ kind: context.toolName,
1169
+ status: context.status,
1170
+ rawInput: context.input,
1171
+ rawOutput: output,
1172
+ sessionId: this.name,
1173
+ permissionStatus:
1174
+ status === 'awaiting_approval' ? 'pending' : 'resolved',
1175
+ ...(status === 'awaiting_approval' && {
1176
+ permissionOptions: [
1177
+ { optionId: 'approve', name: 'Approve', kind: 'allow_once' },
1178
+ { optionId: 'reject', name: 'Reject', kind: 'reject_once' }
1179
+ ]
1180
+ })
1181
+ }
1182
+ ]
860
1183
  }
861
1184
  }
862
1185
  });
863
1186
  }
864
1187
 
865
1188
  /**
866
- * Processes file attachments and returns their content as formatted strings.
1189
+ * The current message queue
1190
+ */
1191
+ get messageQueue(): Private.IQueuedItem[] {
1192
+ return this._messageQueue;
1193
+ }
1194
+ set messageQueue(value: Private.IQueuedItem[]) {
1195
+ this._messageQueue = value;
1196
+ this._updateQueueUI();
1197
+ if (this._messageQueue.length > 0 && !this._isBusy) {
1198
+ this._drainQueue();
1199
+ }
1200
+ }
1201
+
1202
+ /**
1203
+ * Whether the chat is busy
1204
+ */
1205
+ get isBusy(): boolean {
1206
+ return this._isBusy;
1207
+ }
1208
+ set isBusy(value: boolean) {
1209
+ this._isBusy = value;
1210
+ }
1211
+
1212
+ // Private fields
1213
+ private _settingsModel: IAISettingsModel;
1214
+ private _user: IUser;
1215
+ private _toolContexts: Map<string, IToolExecutionContext> = new Map();
1216
+ private _agentManager: IAgentManager;
1217
+ private _providerRegistry?: IProviderRegistry;
1218
+ private _currentModelKey: string | undefined;
1219
+ private _currentStreamingMessage: IMessage | null = null;
1220
+ private _nameChanged = new Signal<IAIChatModel, string>(this);
1221
+ private _contentsManager?: Contents.IManager;
1222
+ private _autosave: boolean = false;
1223
+ private _autosaveChanged = new Signal<IAIChatModel, boolean>(this);
1224
+ private _autosaveDebouncer: Debouncer;
1225
+ private _messageQueue: Private.IQueuedItem[] = [];
1226
+ private _isBusy: boolean = false;
1227
+ private _queueMessageId: string | null = null;
1228
+ private _title: string | null = null;
1229
+ private _titleChanged = new Signal<IAIChatModel, string | null>(this);
1230
+ }
1231
+
1232
+ namespace Private {
1233
+ export interface IQueuedItem {
1234
+ id: string;
1235
+ body: string;
1236
+ _originalMsg: IMessageContent;
1237
+ }
1238
+
1239
+ type IDisplayOutput =
1240
+ | nbformat.IDisplayData
1241
+ | nbformat.IDisplayUpdate
1242
+ | nbformat.IExecuteResult;
1243
+
1244
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
1245
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1246
+ };
1247
+
1248
+ const isDisplayOutput = (value: unknown): value is IDisplayOutput => {
1249
+ if (!isPlainObject(value)) {
1250
+ return false;
1251
+ }
1252
+
1253
+ const output = value as nbformat.IOutput;
1254
+ return (
1255
+ nbformat.isDisplayData(output) ||
1256
+ nbformat.isDisplayUpdate(output) ||
1257
+ nbformat.isExecuteResult(output)
1258
+ );
1259
+ };
1260
+
1261
+ const toMimeBundle = (
1262
+ value: IDisplayOutput,
1263
+ trustedMimeTypes: ReadonlySet<string>
1264
+ ): IMimeModelBody | null => {
1265
+ const data = value.data;
1266
+ if (!isPlainObject(data) || Object.keys(data).length === 0) {
1267
+ return null;
1268
+ }
1269
+
1270
+ return {
1271
+ data: data as IRenderMime.IMimeModel['data'],
1272
+ ...(isPlainObject(value.metadata)
1273
+ ? { metadata: value.metadata as IRenderMime.IMimeModel['metadata'] }
1274
+ : {}),
1275
+ // MIME auto-rendering only runs for explicitly configured command IDs.
1276
+ // Trust handling is configurable to keep risky MIME execution opt-in.
1277
+ ...(Object.keys(data).some(m => trustedMimeTypes.has(m))
1278
+ ? { trusted: true }
1279
+ : {})
1280
+ };
1281
+ };
1282
+
1283
+ /**
1284
+ * Normalize arbitrary tool payloads into canonical display outputs.
1285
+ *
1286
+ * Tool outputs are not guaranteed to be raw Jupyter IOPub messages; they are
1287
+ * often wrapped objects (for example `{ success, result: { outputs: [...] } }`).
1288
+ */
1289
+ const toDisplayOutputs = (value: unknown): IDisplayOutput[] => {
1290
+ if (isDisplayOutput(value)) {
1291
+ return [value];
1292
+ }
1293
+
1294
+ if (Array.isArray(value)) {
1295
+ return value.filter(isDisplayOutput);
1296
+ }
1297
+
1298
+ if (!isPlainObject(value)) {
1299
+ return [];
1300
+ }
1301
+
1302
+ if (Array.isArray(value.outputs)) {
1303
+ return value.outputs.filter(isDisplayOutput);
1304
+ }
1305
+
1306
+ if ('result' in value) {
1307
+ return toDisplayOutputs(value.result);
1308
+ }
1309
+
1310
+ return [];
1311
+ };
1312
+
1313
+ /**
1314
+ * Extract rendermime-ready mime bundles from arbitrary tool results.
1315
+ */
1316
+ export function extractMimeBundlesFromUnknown(
1317
+ content: unknown,
1318
+ options: { trustedMimeTypes?: ReadonlyArray<string> } = {}
1319
+ ): IMimeModelBody[] {
1320
+ const bundles: IMimeModelBody[] = [];
1321
+ const outputs = toDisplayOutputs(content);
1322
+ const trustedMimeTypes = new Set(options.trustedMimeTypes ?? []);
1323
+ for (const output of outputs) {
1324
+ const bundle = toMimeBundle(output, trustedMimeTypes);
1325
+ if (bundle) {
1326
+ bundles.push(bundle);
1327
+ }
1328
+ }
1329
+ return bundles;
1330
+ }
1331
+
1332
+ export function formatToolOutput(outputData: unknown): string {
1333
+ if (typeof outputData === 'string') {
1334
+ return outputData;
1335
+ }
1336
+
1337
+ try {
1338
+ return JSON.stringify(outputData, null, 2);
1339
+ } catch {
1340
+ return '[Complex object - cannot serialize]';
1341
+ }
1342
+ }
1343
+
1344
+ /**
1345
+ * Processes file attachments and returns the message content with the attachments.
867
1346
  * @param attachments Array of file attachments to process
868
- * @returns Array of formatted attachment contents
1347
+ * @param documentManager Optional document manager for file operations
1348
+ * @param body The message body
1349
+ * @param supportsImages Whether the model supports images
1350
+ * @param supportsPdf Whether the model supports pdfs
1351
+ * @param supportsAudio Whether the model supports audio
1352
+ * @returns Enhanced message content
869
1353
  */
870
- private async _processAttachments(
871
- attachments: IAttachment[]
872
- ): Promise<string[]> {
873
- const contents: string[] = [];
1354
+ export async function processAttachments(
1355
+ attachments: IAttachment[],
1356
+ documentManager: IDocumentManager | null | undefined,
1357
+ body: string,
1358
+ supportsImages: boolean,
1359
+ supportsPdf: boolean,
1360
+ supportsAudio: boolean
1361
+ ): Promise<UserContent> {
1362
+ const textContents: string[] = [];
1363
+ const includedParts: Array<ImagePart | FilePart> = [];
1364
+ const omittedNames: string[] = [];
1365
+
1366
+ if (!documentManager) {
1367
+ return body;
1368
+ }
874
1369
 
875
1370
  for (const attachment of attachments) {
876
1371
  try {
877
1372
  if (attachment.type === 'notebook' && attachment.cells?.length) {
878
- const cellContents = await this._readNotebookCells(attachment);
1373
+ const cellContents = await readNotebookCells(
1374
+ attachment,
1375
+ documentManager
1376
+ );
879
1377
  if (cellContents) {
880
- contents.push(cellContents);
1378
+ textContents.push(cellContents);
881
1379
  }
882
1380
  } else {
883
- const fileContent = await this._readFileAttachment(attachment);
884
- if (fileContent) {
885
- const fileExtension = PathExt.extname(
886
- attachment.value
887
- ).toLowerCase();
888
- const language = fileExtension === '.ipynb' ? 'json' : '';
889
- contents.push(
890
- `**File: ${attachment.value}**\n\`\`\`${language}\n${fileContent}\n\`\`\``
1381
+ let mimetype = attachment.mimetype;
1382
+ const fileExtension = PathExt.extname(attachment.value).toLowerCase();
1383
+
1384
+ // Fetch mimetype from server metadata if not provided
1385
+ if (!mimetype) {
1386
+ try {
1387
+ const diskModel = await documentManager.services.contents.get(
1388
+ attachment.value,
1389
+ { content: false }
1390
+ );
1391
+ mimetype = diskModel?.mimetype;
1392
+ } catch (e) {
1393
+ console.warn(
1394
+ `Failed to fetch metadata for ${attachment.value}:`,
1395
+ e
1396
+ );
1397
+ }
1398
+ }
1399
+
1400
+ if (mimetype?.startsWith('image/')) {
1401
+ if (supportsImages) {
1402
+ const data = await readBinaryAttachment(
1403
+ attachment,
1404
+ documentManager
1405
+ );
1406
+ if (data) {
1407
+ includedParts.push({
1408
+ type: 'image',
1409
+ image: data,
1410
+ mediaType: mimetype
1411
+ });
1412
+ }
1413
+ } else {
1414
+ omittedNames.push(PathExt.basename(attachment.value));
1415
+ }
1416
+ } else if (mimetype === 'application/pdf') {
1417
+ if (supportsPdf) {
1418
+ const data = await readBinaryAttachment(
1419
+ attachment,
1420
+ documentManager
1421
+ );
1422
+ if (data) {
1423
+ includedParts.push({
1424
+ type: 'file',
1425
+ data,
1426
+ mediaType: mimetype,
1427
+ filename: PathExt.basename(attachment.value)
1428
+ });
1429
+ }
1430
+ } else {
1431
+ omittedNames.push(PathExt.basename(attachment.value));
1432
+ }
1433
+ } else if (mimetype?.startsWith('audio/')) {
1434
+ if (supportsAudio) {
1435
+ const data = await readBinaryAttachment(
1436
+ attachment,
1437
+ documentManager
1438
+ );
1439
+ if (data) {
1440
+ includedParts.push({
1441
+ type: 'file',
1442
+ data,
1443
+ mediaType: mimetype,
1444
+ filename: PathExt.basename(attachment.value)
1445
+ });
1446
+ }
1447
+ } else {
1448
+ omittedNames.push(PathExt.basename(attachment.value));
1449
+ }
1450
+ } else {
1451
+ const fileContent = await readFileAttachment(
1452
+ attachment,
1453
+ documentManager
891
1454
  );
1455
+ if (fileContent) {
1456
+ const language =
1457
+ fileExtension === '.ipynb' ||
1458
+ mimetype === 'application/x-ipynb+json'
1459
+ ? 'json'
1460
+ : '';
1461
+ textContents.push(
1462
+ `**File: ${attachment.value}**\n\`\`\`${language}\n${fileContent}\n\`\`\``
1463
+ );
1464
+ }
892
1465
  }
893
1466
  }
894
1467
  } catch (error) {
895
1468
  console.warn(`Failed to read attachment ${attachment.value}:`, error);
896
- contents.push(`**File: ${attachment.value}** (Could not read file)`);
1469
+ textContents.push(
1470
+ `**File: ${attachment.value}** (Could not read file)`
1471
+ );
897
1472
  }
898
1473
  }
899
1474
 
900
- return contents;
1475
+ let textPart = body;
1476
+ if (textContents.length > 0) {
1477
+ textPart += '\n\n--- Attached Files ---\n' + textContents.join('\n\n');
1478
+ }
1479
+
1480
+ if (omittedNames.length > 0) {
1481
+ textPart += `\n[Attachments omitted (not supported by this model): ${omittedNames.join(', ')}.]`;
1482
+ }
1483
+
1484
+ return includedParts.length > 0
1485
+ ? [{ type: 'text', text: textPart }, ...includedParts]
1486
+ : textPart;
1487
+ }
1488
+
1489
+ /**
1490
+ * Reads a binary attachment and returns its base64-encoded content.
1491
+ * @param attachment The attachment to read
1492
+ * @param documentManager Optional document manager for file operations
1493
+ * @returns Base64 string or null if unable to read
1494
+ */
1495
+ export async function readBinaryAttachment(
1496
+ attachment: IAttachment,
1497
+ documentManager: IDocumentManager | null | undefined
1498
+ ): Promise<string | null> {
1499
+ if (!documentManager) {
1500
+ return null;
1501
+ }
1502
+
1503
+ try {
1504
+ const diskModel = await documentManager.services.contents.get(
1505
+ attachment.value,
1506
+ { content: true }
1507
+ );
1508
+ if (diskModel?.content && diskModel.format === 'base64') {
1509
+ // Strip whitespace/newlines
1510
+ return (diskModel.content as string).replace(/\s/g, '');
1511
+ }
1512
+ return null;
1513
+ } catch (error) {
1514
+ console.warn(
1515
+ `Failed to read binary attachment ${attachment.value}:`,
1516
+ error
1517
+ );
1518
+ return null;
1519
+ }
901
1520
  }
902
1521
 
903
1522
  /**
904
1523
  * Reads the content of a notebook cell.
905
1524
  * @param attachment The notebook attachment to read
1525
+ * @param documentManager Optional document manager for file operations
906
1526
  * @returns Cell content as string or null if unable to read
907
1527
  */
908
- private async _readNotebookCells(
909
- attachment: IAttachment
1528
+ export async function readNotebookCells(
1529
+ attachment: IAttachment,
1530
+ documentManager: IDocumentManager | null | undefined
910
1531
  ): Promise<string | null> {
911
- if (attachment.type !== 'notebook' || !attachment.cells) {
1532
+ if (
1533
+ attachment.type !== 'notebook' ||
1534
+ !attachment.cells ||
1535
+ !documentManager
1536
+ ) {
912
1537
  return null;
913
1538
  }
914
1539
 
915
1540
  try {
916
1541
  // Try reading from live notebook if open
917
- const widget = this.input.documentManager?.findWidget(
918
- attachment.value
919
- ) as IDocumentWidget<Notebook, INotebookModel> | undefined;
1542
+ const widget = documentManager.findWidget(attachment.value) as
1543
+ | IDocumentWidget<Notebook, INotebookModel>
1544
+ | undefined;
920
1545
  let cellData: nbformat.ICell[];
921
1546
  let kernelLang = 'text';
922
1547
 
@@ -935,7 +1560,7 @@ export class AIChatModel extends AbstractChatModel {
935
1560
  kernelLang = String(lang);
936
1561
  } else {
937
1562
  // Fallback: reading from disk
938
- const model = await this.input.documentManager?.services.contents.get(
1563
+ const model = await documentManager.services.contents.get(
939
1564
  attachment.value
940
1565
  );
941
1566
  if (!model || model.type !== 'notebook') {
@@ -1079,21 +1704,26 @@ export class AIChatModel extends AbstractChatModel {
1079
1704
  /**
1080
1705
  * Reads the content of a file attachment.
1081
1706
  * @param attachment The file attachment to read
1707
+ * @param documentManager Optional document manager for file operations
1082
1708
  * @returns File content as string or null if unable to read
1083
1709
  */
1084
- private async _readFileAttachment(
1085
- attachment: IAttachment
1710
+ export async function readFileAttachment(
1711
+ attachment: IAttachment,
1712
+ documentManager: IDocumentManager | null | undefined
1086
1713
  ): Promise<string | null> {
1087
1714
  // Handle both 'file' and 'notebook' types since both have a 'value' path
1088
- if (attachment.type !== 'file' && attachment.type !== 'notebook') {
1715
+ if (
1716
+ (attachment.type !== 'file' && attachment.type !== 'notebook') ||
1717
+ !documentManager
1718
+ ) {
1089
1719
  return null;
1090
1720
  }
1091
1721
 
1092
1722
  try {
1093
1723
  // Try reading from an open widget first
1094
- const widget = this.input.documentManager?.findWidget(
1095
- attachment.value
1096
- ) as IDocumentWidget<Notebook, INotebookModel> | undefined;
1724
+ const widget = documentManager.findWidget(attachment.value) as
1725
+ | IDocumentWidget<Notebook, INotebookModel>
1726
+ | undefined;
1097
1727
 
1098
1728
  if (widget && widget.context && widget.context.model) {
1099
1729
  const model = widget.context.model;
@@ -1108,7 +1738,7 @@ export class AIChatModel extends AbstractChatModel {
1108
1738
  }
1109
1739
 
1110
1740
  // If not open, load from disk
1111
- const diskModel = await this.input.documentManager?.services.contents.get(
1741
+ const diskModel = await documentManager.services.contents.get(
1112
1742
  attachment.value
1113
1743
  );
1114
1744
 
@@ -1139,127 +1769,6 @@ export class AIChatModel extends AbstractChatModel {
1139
1769
  return null;
1140
1770
  }
1141
1771
  }
1142
-
1143
- // Private fields
1144
- private _settingsModel: IAISettingsModel;
1145
- private _user: IUser;
1146
- private _toolContexts: Map<string, IToolExecutionContext> = new Map();
1147
- private _agentManager: IAgentManager;
1148
- private _currentStreamingMessage: IMessage | null = null;
1149
- private _nameChanged = new Signal<AIChatModel, string>(this);
1150
- private _contentsManager?: Contents.IManager;
1151
- private _autosave: boolean = false;
1152
- private _autosaveChanged = new Signal<AIChatModel, boolean>(this);
1153
- private _autosaveDebouncer: Debouncer;
1154
- }
1155
-
1156
- namespace Private {
1157
- type IMimeBody = Partial<IRenderMime.IMimeModel> &
1158
- Pick<IRenderMime.IMimeModel, 'data'>;
1159
- type IDisplayOutput =
1160
- | nbformat.IDisplayData
1161
- | nbformat.IDisplayUpdate
1162
- | nbformat.IExecuteResult;
1163
-
1164
- const isPlainObject = (value: unknown): value is Record<string, unknown> => {
1165
- return typeof value === 'object' && value !== null && !Array.isArray(value);
1166
- };
1167
-
1168
- const isDisplayOutput = (value: unknown): value is IDisplayOutput => {
1169
- if (!isPlainObject(value)) {
1170
- return false;
1171
- }
1172
-
1173
- const output = value as nbformat.IOutput;
1174
- return (
1175
- nbformat.isDisplayData(output) ||
1176
- nbformat.isDisplayUpdate(output) ||
1177
- nbformat.isExecuteResult(output)
1178
- );
1179
- };
1180
-
1181
- const toMimeBundle = (
1182
- value: IDisplayOutput,
1183
- trustedMimeTypes: ReadonlySet<string>
1184
- ): IMimeBody | null => {
1185
- const data = value.data;
1186
- if (!isPlainObject(data) || Object.keys(data).length === 0) {
1187
- return null;
1188
- }
1189
-
1190
- return {
1191
- data: data as IRenderMime.IMimeModel['data'],
1192
- ...(isPlainObject(value.metadata)
1193
- ? { metadata: value.metadata as IRenderMime.IMimeModel['metadata'] }
1194
- : {}),
1195
- // MIME auto-rendering only runs for explicitly configured command IDs.
1196
- // Trust handling is configurable to keep risky MIME execution opt-in.
1197
- ...(Object.keys(data).some(m => trustedMimeTypes.has(m))
1198
- ? { trusted: true }
1199
- : {})
1200
- };
1201
- };
1202
-
1203
- /**
1204
- * Normalize arbitrary tool payloads into canonical display outputs.
1205
- *
1206
- * Tool outputs are not guaranteed to be raw Jupyter IOPub messages; they are
1207
- * often wrapped objects (for example `{ success, result: { outputs: [...] } }`).
1208
- */
1209
- const toDisplayOutputs = (value: unknown): IDisplayOutput[] => {
1210
- if (isDisplayOutput(value)) {
1211
- return [value];
1212
- }
1213
-
1214
- if (Array.isArray(value)) {
1215
- return value.filter(isDisplayOutput);
1216
- }
1217
-
1218
- if (!isPlainObject(value)) {
1219
- return [];
1220
- }
1221
-
1222
- if (Array.isArray(value.outputs)) {
1223
- return value.outputs.filter(isDisplayOutput);
1224
- }
1225
-
1226
- if ('result' in value) {
1227
- return toDisplayOutputs(value.result);
1228
- }
1229
-
1230
- return [];
1231
- };
1232
-
1233
- /**
1234
- * Extract rendermime-ready mime bundles from arbitrary tool results.
1235
- */
1236
- export function extractMimeBundlesFromUnknown(
1237
- content: unknown,
1238
- options: { trustedMimeTypes?: ReadonlyArray<string> } = {}
1239
- ): IMimeBody[] {
1240
- const bundles: IMimeBody[] = [];
1241
- const outputs = toDisplayOutputs(content);
1242
- const trustedMimeTypes = new Set(options.trustedMimeTypes ?? []);
1243
- for (const output of outputs) {
1244
- const bundle = toMimeBundle(output, trustedMimeTypes);
1245
- if (bundle) {
1246
- bundles.push(bundle);
1247
- }
1248
- }
1249
- return bundles;
1250
- }
1251
-
1252
- export function formatToolOutput(outputData: unknown): string {
1253
- if (typeof outputData === 'string') {
1254
- return outputData;
1255
- }
1256
-
1257
- try {
1258
- return JSON.stringify(outputData, null, 2);
1259
- } catch {
1260
- return '[Complex object - cannot serialize]';
1261
- }
1262
- }
1263
1772
  }
1264
1773
 
1265
1774
  /**
@@ -1294,6 +1803,10 @@ export namespace AIChatModel {
1294
1803
  * The contents manager.
1295
1804
  */
1296
1805
  contentsManager?: Contents.IManager;
1806
+ /**
1807
+ * Optional provider registry for model capability lookups.
1808
+ */
1809
+ providerRegistry?: IProviderRegistry;
1297
1810
  /**
1298
1811
  * Whether to restore or not the message (default to true)
1299
1812
  */
@@ -1311,7 +1824,7 @@ export namespace AIChatModel {
1311
1824
  /**
1312
1825
  * The clear messages callback.
1313
1826
  */
1314
- clearMessages: () => void;
1827
+ clearMessages: () => Promise<void>;
1315
1828
  /**
1316
1829
  * Adds an assistant/system message to the chat.
1317
1830
  */
@@ -1350,6 +1863,10 @@ export namespace AIChatModel {
1350
1863
  * Whether the chat is automatically saved.
1351
1864
  */
1352
1865
  autosave?: boolean;
1866
+ /**
1867
+ * An optional title of the chat.
1868
+ */
1869
+ title?: string;
1353
1870
  };
1354
1871
  };
1355
1872
  }