@jupyterlite/ai 0.17.0 → 0.19.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 (114) hide show
  1. package/lib/chat-commands/clear.d.ts +1 -0
  2. package/lib/chat-commands/index.d.ts +1 -0
  3. package/lib/chat-commands/skills.d.ts +2 -1
  4. package/lib/chat-model-handler.d.ts +4 -3
  5. package/lib/chat-model-handler.js +2 -1
  6. package/lib/chat-model.d.ts +148 -8
  7. package/lib/chat-model.js +368 -79
  8. package/lib/completion/completion-provider.d.ts +3 -1
  9. package/lib/completion/completion-provider.js +1 -2
  10. package/lib/completion/index.d.ts +1 -0
  11. package/lib/components/clear-button.d.ts +1 -0
  12. package/lib/components/clear-button.js +3 -4
  13. package/lib/components/completion-status.d.ts +1 -0
  14. package/lib/components/completion-status.js +5 -4
  15. package/lib/components/index.d.ts +1 -0
  16. package/lib/components/model-select.d.ts +1 -0
  17. package/lib/components/model-select.js +62 -67
  18. package/lib/components/save-button.d.ts +3 -2
  19. package/lib/components/save-button.js +4 -5
  20. package/lib/components/stop-button.d.ts +1 -0
  21. package/lib/components/stop-button.js +3 -4
  22. package/lib/components/tool-select.d.ts +3 -1
  23. package/lib/components/tool-select.js +47 -60
  24. package/lib/components/usage-display.d.ts +4 -2
  25. package/lib/components/usage-display.js +50 -61
  26. package/lib/diff-manager.d.ts +3 -1
  27. package/lib/index.d.ts +3 -2
  28. package/lib/index.js +50 -59
  29. package/lib/models/settings-model.d.ts +3 -1
  30. package/lib/models/settings-model.js +1 -0
  31. package/lib/rendered-message-outputarea.d.ts +1 -0
  32. package/lib/tokens.d.ts +48 -597
  33. package/lib/tokens.js +2 -31
  34. package/lib/widgets/ai-settings.d.ts +3 -1
  35. package/lib/widgets/ai-settings.js +185 -344
  36. package/lib/widgets/main-area-chat.d.ts +3 -3
  37. package/lib/widgets/main-area-chat.js +2 -4
  38. package/lib/widgets/provider-config-dialog.d.ts +2 -1
  39. package/lib/widgets/provider-config-dialog.js +102 -167
  40. package/package.json +111 -258
  41. package/schema/settings-model.json +6 -0
  42. package/src/chat-commands/skills.ts +2 -2
  43. package/src/chat-model-handler.ts +10 -6
  44. package/src/chat-model.ts +488 -96
  45. package/src/completion/completion-provider.ts +6 -6
  46. package/src/components/clear-button.tsx +0 -2
  47. package/src/components/completion-status.tsx +2 -2
  48. package/src/components/model-select.tsx +1 -1
  49. package/src/components/save-button.tsx +3 -3
  50. package/src/components/stop-button.tsx +0 -2
  51. package/src/components/tool-select.tsx +10 -9
  52. package/src/components/usage-display.tsx +4 -2
  53. package/src/diff-manager.ts +4 -3
  54. package/src/index.ts +103 -107
  55. package/src/models/settings-model.ts +7 -6
  56. package/src/tokens.ts +54 -744
  57. package/src/widgets/ai-settings.tsx +40 -11
  58. package/src/widgets/main-area-chat.ts +5 -8
  59. package/src/widgets/provider-config-dialog.tsx +8 -8
  60. package/LICENSE +0 -30
  61. package/README.md +0 -49
  62. package/lib/agent.d.ts +0 -277
  63. package/lib/agent.js +0 -1116
  64. package/lib/icons.d.ts +0 -3
  65. package/lib/icons.js +0 -8
  66. package/lib/providers/built-in-providers.d.ts +0 -21
  67. package/lib/providers/built-in-providers.js +0 -233
  68. package/lib/providers/generated-context-windows.d.ts +0 -8
  69. package/lib/providers/generated-context-windows.js +0 -96
  70. package/lib/providers/model-info.d.ts +0 -3
  71. package/lib/providers/model-info.js +0 -58
  72. package/lib/providers/models.d.ts +0 -37
  73. package/lib/providers/models.js +0 -28
  74. package/lib/providers/provider-registry.d.ts +0 -49
  75. package/lib/providers/provider-registry.js +0 -72
  76. package/lib/providers/provider-tools.d.ts +0 -36
  77. package/lib/providers/provider-tools.js +0 -93
  78. package/lib/skills/index.d.ts +0 -4
  79. package/lib/skills/index.js +0 -7
  80. package/lib/skills/parse-skill.d.ts +0 -25
  81. package/lib/skills/parse-skill.js +0 -69
  82. package/lib/skills/skill-loader.d.ts +0 -25
  83. package/lib/skills/skill-loader.js +0 -133
  84. package/lib/skills/skill-registry.d.ts +0 -31
  85. package/lib/skills/skill-registry.js +0 -100
  86. package/lib/skills/types.d.ts +0 -29
  87. package/lib/skills/types.js +0 -5
  88. package/lib/tools/commands.d.ts +0 -11
  89. package/lib/tools/commands.js +0 -154
  90. package/lib/tools/skills.d.ts +0 -9
  91. package/lib/tools/skills.js +0 -73
  92. package/lib/tools/tool-registry.d.ts +0 -35
  93. package/lib/tools/tool-registry.js +0 -55
  94. package/lib/tools/web.d.ts +0 -8
  95. package/lib/tools/web.js +0 -196
  96. package/src/agent.ts +0 -1441
  97. package/src/icons.ts +0 -11
  98. package/src/providers/built-in-providers.ts +0 -241
  99. package/src/providers/generated-context-windows.ts +0 -102
  100. package/src/providers/model-info.ts +0 -88
  101. package/src/providers/models.ts +0 -76
  102. package/src/providers/provider-registry.ts +0 -88
  103. package/src/providers/provider-tools.ts +0 -179
  104. package/src/skills/index.ts +0 -14
  105. package/src/skills/parse-skill.ts +0 -91
  106. package/src/skills/skill-loader.ts +0 -175
  107. package/src/skills/skill-registry.ts +0 -137
  108. package/src/skills/types.ts +0 -37
  109. package/src/tools/commands.ts +0 -210
  110. package/src/tools/skills.ts +0 -84
  111. package/src/tools/tool-registry.ts +0 -63
  112. package/src/tools/web.ts +0 -238
  113. package/src/types.d.ts +0 -4
  114. package/style/icons/jupyternaut-lite.svg +0 -7
package/src/chat-model.ts CHANGED
@@ -26,6 +26,14 @@ import { IRenderMime } from '@jupyterlab/rendermime';
26
26
 
27
27
  import { Contents } from '@jupyterlab/services';
28
28
 
29
+ import { AI_AVATAR } from '@jupyternaut/agent';
30
+
31
+ import type {
32
+ IAgentManager,
33
+ IProviderRegistry,
34
+ ITokenUsage
35
+ } from '@jupyternaut/agent';
36
+
29
37
  import { UUID } from '@lumino/coreutils';
30
38
 
31
39
  import { Debouncer } from '@lumino/polling';
@@ -34,9 +42,13 @@ import { ISignal, Signal } from '@lumino/signaling';
34
42
 
35
43
  import type { UserContent, ImagePart, FilePart, ModelMessage } from 'ai';
36
44
 
37
- import { AI_AVATAR } from './icons';
45
+ import type { IAIChatModel, IAISettingsModel } from './tokens';
38
46
 
39
- import type { IAgentManager, IAISettingsModel, ITokenUsage } from './tokens';
47
+ import {
48
+ modelSupportsAudio,
49
+ modelSupportsImages,
50
+ modelSupportsPdf
51
+ } from '@jupyternaut/agent';
40
52
 
41
53
  /**
42
54
  * Tool call status types.
@@ -69,10 +81,6 @@ interface IToolExecutionContext {
69
81
  * The tool input (formatted).
70
82
  */
71
83
  input: string;
72
- /**
73
- * Optional approval ID if awaiting approval.
74
- */
75
- approvalId?: string;
76
84
  /**
77
85
  * Current status.
78
86
  */
@@ -91,7 +99,7 @@ interface IToolExecutionContext {
91
99
  * AI Chat Model implementation that provides chat functionality tool integration,
92
100
  * and MCP server support.
93
101
  */
94
- export class AIChatModel extends AbstractChatModel {
102
+ export class AIChatModel extends AbstractChatModel implements IAIChatModel {
95
103
  /**
96
104
  * Constructs a new AIChatModel instance.
97
105
  * @param options Configuration options for the chat model
@@ -109,6 +117,7 @@ export class AIChatModel extends AbstractChatModel {
109
117
  this._user = options.user;
110
118
  this._agentManager = options.agentManager;
111
119
  this._contentsManager = options.contentsManager;
120
+ this._providerRegistry = options.providerRegistry;
112
121
 
113
122
  // Listen for agent events
114
123
  this._agentManager.agentEvent.connect(this._onAgentEvent, this);
@@ -116,6 +125,13 @@ export class AIChatModel extends AbstractChatModel {
116
125
  // Listen for settings changes to update chat behavior
117
126
  this._settingsModel.stateChanged.connect(this._onSettingsChanged, this);
118
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
+
119
135
  this._autosaveDebouncer = new Debouncer(this.save, 3000);
120
136
  }
121
137
 
@@ -139,7 +155,7 @@ export class AIChatModel extends AbstractChatModel {
139
155
  /**
140
156
  * A signal emitting when the chat name has changed.
141
157
  */
142
- get nameChanged(): ISignal<AIChatModel, string> {
158
+ get nameChanged(): ISignal<IAIChatModel, string> {
143
159
  return this._nameChanged;
144
160
  }
145
161
 
@@ -160,7 +176,7 @@ export class AIChatModel extends AbstractChatModel {
160
176
  /**
161
177
  * A signal emitting when the chat title has changed.
162
178
  */
163
- get titleChanged(): ISignal<AIChatModel, string | null> {
179
+ get titleChanged(): ISignal<IAIChatModel, string | null> {
164
180
  return this._titleChanged;
165
181
  }
166
182
 
@@ -171,6 +187,9 @@ export class AIChatModel extends AbstractChatModel {
171
187
  return this._autosave;
172
188
  }
173
189
  set autosave(value: boolean) {
190
+ if (value === this._autosave) {
191
+ return;
192
+ }
174
193
  this._autosave = value;
175
194
  this._autosaveChanged.emit(value);
176
195
  if (value) {
@@ -182,7 +201,6 @@ export class AIChatModel extends AbstractChatModel {
182
201
  this._autosaveDebouncer.invoke,
183
202
  this._autosaveDebouncer
184
203
  );
185
- this._autosaveDebouncer.invoke();
186
204
  } else {
187
205
  this.messagesUpdated.disconnect(
188
206
  this._autosaveDebouncer.invoke,
@@ -193,12 +211,13 @@ export class AIChatModel extends AbstractChatModel {
193
211
  this._autosaveDebouncer
194
212
  );
195
213
  }
214
+ this._autosaveDebouncer.invoke();
196
215
  }
197
216
 
198
217
  /**
199
218
  * A signal emitting when the autosave flag changed.
200
219
  */
201
- get autosaveChanged(): ISignal<AIChatModel, boolean> {
220
+ get autosaveChanged(): ISignal<IAIChatModel, boolean> {
202
221
  return this._autosaveChanged;
203
222
  }
204
223
 
@@ -217,7 +236,7 @@ export class AIChatModel extends AbstractChatModel {
217
236
  }
218
237
 
219
238
  /**
220
- * Get the agent manager associated to the model.
239
+ * The agent manager used in the model.
221
240
  */
222
241
  get agentManager(): IAgentManager {
223
242
  return this._agentManager;
@@ -234,6 +253,7 @@ export class AIChatModel extends AbstractChatModel {
234
253
  * Dispose of the model.
235
254
  */
236
255
  dispose(): void {
256
+ this.stopStreaming();
237
257
  this.messagesUpdated.disconnect(
238
258
  this._autosaveDebouncer.invoke,
239
259
  this._autosaveDebouncer
@@ -253,7 +273,11 @@ export class AIChatModel extends AbstractChatModel {
253
273
  stopStreaming: () => this.stopStreaming(),
254
274
  clearMessages: () => this.clearMessages(),
255
275
  agentManager: this._agentManager,
256
- addSystemMessage: (body: string) => this.addSystemMessage(body)
276
+ addSystemMessage: (body: string) => this._addSystemMessage(body),
277
+ removeQueuedMessage: (id: string) => this.removeQueuedMessage(id),
278
+ reorderQueuedMessages: (ids: string[]) => this.reorderQueuedMessages(ids),
279
+ editQueuedMessage: (id: string, body: string) =>
280
+ this.editQueuedMessage(id, body)
257
281
  };
258
282
  }
259
283
 
@@ -268,15 +292,31 @@ export class AIChatModel extends AbstractChatModel {
268
292
  * Clears all messages from the chat and resets conversation state.
269
293
  */
270
294
  clearMessages = async (): Promise<void> => {
295
+ this.stopStreaming();
296
+ this._messageQueue = [];
297
+ this._isBusy = false;
298
+ this._queueMessageId = null;
299
+ this._currentStreamingMessage = null;
271
300
  this.messagesDeleted(0, this.messages.length);
301
+ this.title = null;
272
302
  this._toolContexts.clear();
273
303
  await this._agentManager.clearHistory();
274
304
  };
275
305
 
306
+ /**
307
+ * Overrides messageAdded to ensure queued messages stay at the bottom.
308
+ */
309
+ override messageAdded(message: IMessageContent): void {
310
+ super.messageAdded(message);
311
+ if (this._queueMessageId && message.id !== this._queueMessageId) {
312
+ this._updateQueueUI();
313
+ }
314
+ }
315
+
276
316
  /**
277
317
  * Adds a non-user message to the chat (used by chat commands).
278
318
  */
279
- addSystemMessage(body: string): void {
319
+ private _addSystemMessage(body: string): void {
280
320
  const message: IMessageContent = {
281
321
  body,
282
322
  sender: this._getAIUser(),
@@ -309,10 +349,10 @@ export class AIChatModel extends AbstractChatModel {
309
349
  raw_time: false,
310
350
  attachments: [...this.input.attachments]
311
351
  };
312
- this.messageAdded(userMessage);
313
352
 
314
353
  // Check if we have valid configuration
315
354
  if (!this._agentManager.hasValidConfig()) {
355
+ this.messageAdded(userMessage);
316
356
  const errorMessage: IMessageContent = {
317
357
  body: 'Please configure your AI settings first. Open the AI Settings to set your API key and model.',
318
358
  sender: this._getAIUser(),
@@ -325,35 +365,71 @@ export class AIChatModel extends AbstractChatModel {
325
365
  return;
326
366
  }
327
367
 
328
- try {
329
- // Process attachments and add their content to the message
330
- let enhancedMessage: UserContent = message.body;
331
- if (this.input.attachments.length > 0) {
332
- const { textContents, binaryParts } = await Private.processAttachments(
333
- this.input.attachments,
334
- this.input.documentManager
335
- );
336
- this.input.clearAttachments();
368
+ if (this._isBusy) {
369
+ this._messageQueue.push({
370
+ id: UUID.uuid4(),
371
+ body: message.body,
372
+ _originalMsg: userMessage
373
+ });
374
+ this.input.clearAttachments();
375
+ this._updateQueueUI();
376
+ return;
377
+ }
337
378
 
338
- let textPart = message.body;
339
- if (textContents.length > 0) {
340
- textPart +=
341
- '\n\n--- Attached Files ---\n' + textContents.join('\n\n');
342
- }
379
+ this._isBusy = true;
380
+ this.messageAdded(userMessage);
381
+ this.input.clearAttachments();
343
382
 
344
- if (binaryParts.length > 0) {
345
- enhancedMessage = [{ type: 'text', text: textPart }, ...binaryParts];
346
- } else {
347
- enhancedMessage = textPart;
348
- }
349
- }
383
+ await this._processMessage(userMessage);
384
+ }
350
385
 
386
+ /**
387
+ * Internal method to process attachments and send the message to the agent.
388
+ */
389
+ private async _processMessage(userMessage: IMessageContent): Promise<void> {
390
+ try {
351
391
  this.updateWriters([{ user: this._getAIUser() }]);
352
392
 
393
+ let enhancedMessage: UserContent = userMessage.body;
394
+ if (userMessage.attachments && userMessage.attachments.length > 0) {
395
+ const providerConfig = this._settingsModel.getProvider(
396
+ this._agentManager.activeProvider
397
+ );
398
+ const supportsImages = modelSupportsImages(
399
+ providerConfig,
400
+ this._providerRegistry
401
+ );
402
+ const supportsPdf = modelSupportsPdf(
403
+ providerConfig,
404
+ this._providerRegistry
405
+ );
406
+ const supportsAudio = modelSupportsAudio(
407
+ providerConfig,
408
+ this._providerRegistry
409
+ );
410
+
411
+ enhancedMessage = await Private.processAttachments(
412
+ userMessage.attachments,
413
+ this.input.documentManager,
414
+ userMessage.body,
415
+ supportsImages,
416
+ supportsPdf,
417
+ supportsAudio
418
+ );
419
+ }
420
+
353
421
  await this._agentManager.generateResponse(enhancedMessage);
354
422
  } catch (error) {
355
423
  const errorMessage: IMessageContent = {
356
- body: `Error generating AI response: ${(error as Error).message}`,
424
+ body: '',
425
+ mime_model: {
426
+ data: {
427
+ 'application/vnd.jupyter.chat.components': 'error'
428
+ },
429
+ metadata: {
430
+ errorMessage: `Error generating AI response: ${(error as Error).message}`
431
+ }
432
+ },
357
433
  sender: this._getAIUser(),
358
434
  id: UUID.uuid4(),
359
435
  time: Date.now() / 1000,
@@ -362,7 +438,127 @@ export class AIChatModel extends AbstractChatModel {
362
438
  };
363
439
  this.messageAdded(errorMessage);
364
440
  } finally {
441
+ this._drainQueue();
442
+
443
+ if (
444
+ this._settingsModel.config.autoTitle &&
445
+ (this.messages.filter(msg => msg.sender.username !== 'ai-assistant')
446
+ .length <= 5 ||
447
+ this.title === null)
448
+ ) {
449
+ try {
450
+ this.title = await this.requestTitle();
451
+ } catch (e) {
452
+ console.warn('Error while generating a title\n', e);
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Removes the message-queue chat component.
460
+ */
461
+ private _removeQueueUI(): void {
462
+ if (this._queueMessageId) {
463
+ const existingMsg = this.messages.find(
464
+ msg => msg.id === this._queueMessageId
465
+ );
466
+ if (existingMsg) {
467
+ const idx = this.messages.indexOf(existingMsg);
468
+ if (idx !== -1) {
469
+ this.messagesDeleted(idx, 1);
470
+ }
471
+ }
472
+ this._queueMessageId = null;
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Creates or updates the message-queue chat component.
478
+ */
479
+ private _updateQueueUI(): void {
480
+ this._removeQueueUI();
481
+
482
+ if (this._messageQueue.length === 0) {
483
+ return;
484
+ }
485
+
486
+ const queueBody = {
487
+ data: {
488
+ 'application/vnd.jupyter.chat.components': 'message-queue'
489
+ },
490
+ metadata: {
491
+ messages: this._messageQueue.map(m => ({
492
+ id: m.id,
493
+ body: m.body,
494
+ attachments: m._originalMsg.attachments
495
+ })),
496
+ targetId: this.name
497
+ }
498
+ } as IMimeModelBody;
499
+
500
+ this._queueMessageId = UUID.uuid4();
501
+ const queueMessage: IMessageContent = {
502
+ body: '',
503
+ mime_model: queueBody,
504
+ sender: { username: 'system', display_name: '' },
505
+ id: this._queueMessageId,
506
+ time: Date.now() / 1000,
507
+ type: 'msg',
508
+ raw_time: false
509
+ };
510
+ this.messageAdded(queueMessage);
511
+ }
512
+
513
+ /**
514
+ * Processes the next message in the queue, or marks the agent as idle.
515
+ */
516
+ private async _drainQueue(): Promise<void> {
517
+ if (this._messageQueue.length === 0) {
518
+ this._isBusy = false;
365
519
  this.updateWriters([]);
520
+ this._removeQueueUI();
521
+ return;
522
+ }
523
+
524
+ // Dequeue and push to chat
525
+ const next = this._messageQueue.shift()!;
526
+ next._originalMsg.time = Date.now() / 1000;
527
+ this.messageAdded(next._originalMsg);
528
+
529
+ await this._processMessage(next._originalMsg);
530
+ }
531
+
532
+ /**
533
+ * Removes a queued message by its ID.
534
+ * @param messageId The ID of the queued message to remove
535
+ */
536
+ removeQueuedMessage(messageId: string): void {
537
+ this.messageQueue = this._messageQueue.filter(msg => msg.id !== messageId);
538
+ }
539
+
540
+ /**
541
+ * Reorders queued messages by their IDs.
542
+ * @param messageIds Array of message IDs in the desired order
543
+ */
544
+ reorderQueuedMessages(messageIds: string[]): void {
545
+ const byId = new Map(this._messageQueue.map(m => [m.id, m]));
546
+ this.messageQueue = messageIds
547
+ .map(id => byId.get(id))
548
+ .filter((m): m is Private.IQueuedItem => m !== undefined);
549
+ }
550
+
551
+ /**
552
+ * Edits a queued message by its ID.
553
+ * @param messageId The ID of the queued message to edit
554
+ * @param newBody The new body of the message
555
+ */
556
+ editQueuedMessage(messageId: string, newBody: string): void {
557
+ const queue = [...this._messageQueue];
558
+ const index = queue.findIndex(m => m.id === messageId);
559
+ if (index !== -1) {
560
+ queue[index] = { ...queue[index], body: newBody };
561
+ this.messageQueue = queue;
366
562
  }
367
563
  }
368
564
 
@@ -434,7 +630,7 @@ export class AIChatModel extends AbstractChatModel {
434
630
  );
435
631
  }
436
632
  } else if (!silent) {
437
- console.log(`Provider not providing when restoring ${filepath}.`);
633
+ console.log(`Provider not provided when restoring ${filepath}.`);
438
634
  }
439
635
 
440
636
  const messages: IMessageContent[] = content.messages.map(message => {
@@ -452,7 +648,7 @@ export class AIChatModel extends AbstractChatModel {
452
648
  });
453
649
  await this.clearMessages();
454
650
  this.messagesInserted(0, messages);
455
- this._agentManager.setHistory(messages);
651
+ await this._rebuildHistory();
456
652
  this.autosave = content.metadata?.autosave ?? false;
457
653
  this.title = content.metadata?.title ?? null;
458
654
  return true;
@@ -473,7 +669,7 @@ export class AIChatModel extends AbstractChatModel {
473
669
  {
474
670
  role: 'system',
475
671
  content:
476
- "Generate a concise title (no more than 10 words) for the following conversation. Do not use formatting. Focus on the user's main intent."
672
+ "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."
477
673
  },
478
674
  {
479
675
  role: 'user',
@@ -494,6 +690,13 @@ export class AIChatModel extends AbstractChatModel {
494
690
  const attachmentsList: IAttachment[] = []; // Actual attachments
495
691
 
496
692
  this.messages.forEach(message => {
693
+ if (
694
+ message.content?.mime_model?.data?.[
695
+ 'application/vnd.jupyter.chat.components'
696
+ ] === 'message-queue'
697
+ ) {
698
+ return;
699
+ }
497
700
  let attachmentIndexes: string[] = [];
498
701
  if (message.attachments) {
499
702
  attachmentIndexes = message.attachments.map(attachment => {
@@ -559,6 +762,75 @@ export class AIChatModel extends AbstractChatModel {
559
762
  // Agent manager handles agent recreation automatically via its own settings listener
560
763
  }
561
764
 
765
+ /**
766
+ * Rebuild history when the active model changes.
767
+ */
768
+ private _onModelChanged(): void {
769
+ const providerConfig = this._settingsModel.getProvider(
770
+ this._agentManager.activeProvider
771
+ );
772
+ const modelKey = providerConfig
773
+ ? `${providerConfig.provider}:${providerConfig.model}`
774
+ : undefined;
775
+ if (modelKey && modelKey !== this._currentModelKey) {
776
+ this._currentModelKey = modelKey;
777
+ this._rebuildHistory().catch(e =>
778
+ console.warn('Failed to rebuild history on model change:', e)
779
+ );
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Rebuilds the agent history from the current messages.
785
+ * For vision-capable models, re-reads binary attachments from disk.
786
+ * For text-only models, uses message text only.
787
+ */
788
+ private async _rebuildHistory(): Promise<void> {
789
+ const providerConfig = this._settingsModel.getProvider(
790
+ this._agentManager.activeProvider
791
+ );
792
+ const supportsImages = modelSupportsImages(
793
+ providerConfig,
794
+ this._providerRegistry
795
+ );
796
+ const supportsPdf = modelSupportsPdf(
797
+ providerConfig,
798
+ this._providerRegistry
799
+ );
800
+ const supportsAudio = modelSupportsAudio(
801
+ providerConfig,
802
+ this._providerRegistry
803
+ );
804
+
805
+ const modelMessages: ModelMessage[] = [];
806
+ for (const msg of this.messages) {
807
+ const isAI = msg.sender.username === 'ai-assistant';
808
+ if (!isAI && msg.attachments?.length) {
809
+ const enhancedContent = await Private.processAttachments(
810
+ msg.attachments,
811
+ this.input.documentManager,
812
+ msg.body,
813
+ supportsImages,
814
+ supportsPdf,
815
+ supportsAudio
816
+ );
817
+
818
+ modelMessages.push({
819
+ role: 'user',
820
+ content: enhancedContent
821
+ } as ModelMessage);
822
+ } else if (msg.body) {
823
+ modelMessages.push({
824
+ role: isAI ? 'assistant' : 'user',
825
+ content: msg.body
826
+ } as ModelMessage);
827
+ }
828
+ // Skip messages with empty body like tool calls
829
+ }
830
+
831
+ this._agentManager.setHistory(modelMessages);
832
+ }
833
+
562
834
  /**
563
835
  * Handles events emitted by the agent manager.
564
836
  * @param event The event data containing type and payload
@@ -613,6 +885,7 @@ export class AIChatModel extends AbstractChatModel {
613
885
  this.messageAdded(aiMessage);
614
886
  this._currentStreamingMessage =
615
887
  this.messages.find(message => message.id === aiMessage.id) ?? null;
888
+ this._updateQueueUI();
616
889
  }
617
890
 
618
891
  /**
@@ -759,13 +1032,18 @@ export class AIChatModel extends AbstractChatModel {
759
1032
  body: '',
760
1033
  mime_model: {
761
1034
  data: {
762
- 'application/vnd.jupyter.chat.components': 'tool-call'
1035
+ 'application/vnd.jupyter.chat.components': 'grouped-tool-calls'
763
1036
  },
764
1037
  metadata: {
765
- toolName: context.toolName,
766
- input: context.input,
767
- status: context.status,
768
- summary: context.summary
1038
+ toolCalls: [
1039
+ {
1040
+ toolCallId: context.toolCallId,
1041
+ title: `${context.toolName}${context.summary ? ' : ' + context.summary : ''}`,
1042
+ kind: context.toolName,
1043
+ status: 'in_progress',
1044
+ rawInput: context.input
1045
+ }
1046
+ ]
769
1047
  }
770
1048
  },
771
1049
  sender: this._getAIUser(),
@@ -776,6 +1054,7 @@ export class AIChatModel extends AbstractChatModel {
776
1054
  };
777
1055
 
778
1056
  this.messageAdded(toolCallMessage);
1057
+ this._updateQueueUI();
779
1058
  }
780
1059
 
781
1060
  /**
@@ -837,13 +1116,22 @@ export class AIChatModel extends AbstractChatModel {
837
1116
  */
838
1117
  private _handleErrorEvent(event: IAgentManager.IAgentEvent<'error'>): void {
839
1118
  this.messageAdded({
840
- body: `Error generating response: ${event.data.error.message}`,
1119
+ body: '',
1120
+ mime_model: {
1121
+ data: {
1122
+ 'application/vnd.jupyter.chat.components': 'error'
1123
+ },
1124
+ metadata: {
1125
+ errorMessage: `Error generating response: ${event.data.error.message}`
1126
+ }
1127
+ },
841
1128
  sender: this._getAIUser(),
842
1129
  id: UUID.uuid4(),
843
1130
  time: Date.now() / 1000,
844
1131
  type: 'msg',
845
1132
  raw_time: false
846
1133
  });
1134
+ this._updateQueueUI();
847
1135
  }
848
1136
 
849
1137
  /**
@@ -856,7 +1144,6 @@ export class AIChatModel extends AbstractChatModel {
856
1144
  if (!context) {
857
1145
  return;
858
1146
  }
859
- context.approvalId = event.data.approvalId;
860
1147
  context.input = JSON.stringify(event.data.args, null, 2);
861
1148
  this._updateToolCallUI(event.data.toolCallId, 'awaiting_approval');
862
1149
  }
@@ -867,15 +1154,13 @@ export class AIChatModel extends AbstractChatModel {
867
1154
  private _handleToolApprovalResolved(
868
1155
  event: IAgentManager.IAgentEvent<'tool_approval_resolved'>
869
1156
  ): void {
870
- const context = Array.from(this._toolContexts.values()).find(
871
- ctx => ctx.approvalId === event.data.approvalId
872
- );
1157
+ const context = this._toolContexts.get(event.data.toolCallId);
873
1158
  if (!context) {
874
1159
  return;
875
1160
  }
876
1161
 
877
1162
  const status = event.data.approved ? 'approved' : 'rejected';
878
- this._updateToolCallUI(context.toolCallId, status);
1163
+ this._updateToolCallUI(event.data.toolCallId, status);
879
1164
 
880
1165
  if (!event.data.approved) {
881
1166
  this._toolContexts.delete(context.toolCallId);
@@ -906,47 +1191,96 @@ export class AIChatModel extends AbstractChatModel {
906
1191
  existingMessage.update({
907
1192
  mime_model: {
908
1193
  data: {
909
- 'application/vnd.jupyter.chat.components': 'tool-call'
1194
+ 'application/vnd.jupyter.chat.components': 'grouped-tool-calls'
910
1195
  },
911
1196
  metadata: {
912
- toolName: context.toolName,
913
- input: context.input,
914
- status: context.status,
915
- summary: context.summary,
916
- output,
917
- targetId: this.name,
918
- approvalId: context.approvalId
1197
+ toolCalls: [
1198
+ {
1199
+ toolCallId: context.toolCallId,
1200
+ title: `${context.toolName}${context.summary ? ' : ' + context.summary : ''}`,
1201
+ kind: context.toolName,
1202
+ status: context.status,
1203
+ rawInput: context.input,
1204
+ rawOutput: output,
1205
+ sessionId: this.name,
1206
+ permissionStatus:
1207
+ status === 'awaiting_approval' ? 'pending' : 'resolved',
1208
+ ...(status === 'awaiting_approval' && {
1209
+ permissionOptions: [
1210
+ { optionId: 'approve', name: 'Approve', kind: 'allow_once' },
1211
+ { optionId: 'reject', name: 'Reject', kind: 'reject_once' }
1212
+ ]
1213
+ })
1214
+ }
1215
+ ]
919
1216
  }
920
1217
  }
921
1218
  });
922
1219
  }
923
1220
 
1221
+ /**
1222
+ * The current message queue
1223
+ */
1224
+ get messageQueue(): Private.IQueuedItem[] {
1225
+ return this._messageQueue;
1226
+ }
1227
+ set messageQueue(value: Private.IQueuedItem[]) {
1228
+ this._messageQueue = value;
1229
+ this._updateQueueUI();
1230
+ if (this._messageQueue.length > 0 && !this._isBusy) {
1231
+ this._drainQueue();
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Whether the chat is busy
1237
+ */
1238
+ get isBusy(): boolean {
1239
+ return this._isBusy;
1240
+ }
1241
+ set isBusy(value: boolean) {
1242
+ this._isBusy = value;
1243
+ }
1244
+
924
1245
  // Private fields
925
1246
  private _settingsModel: IAISettingsModel;
926
1247
  private _user: IUser;
927
1248
  private _toolContexts: Map<string, IToolExecutionContext> = new Map();
928
1249
  private _agentManager: IAgentManager;
1250
+ private _providerRegistry?: IProviderRegistry;
1251
+ private _currentModelKey: string | undefined;
929
1252
  private _currentStreamingMessage: IMessage | null = null;
930
- private _nameChanged = new Signal<AIChatModel, string>(this);
1253
+ private _nameChanged = new Signal<IAIChatModel, string>(this);
931
1254
  private _contentsManager?: Contents.IManager;
932
1255
  private _autosave: boolean = false;
933
- private _autosaveChanged = new Signal<AIChatModel, boolean>(this);
1256
+ private _autosaveChanged = new Signal<IAIChatModel, boolean>(this);
934
1257
  private _autosaveDebouncer: Debouncer;
1258
+ private _messageQueue: Private.IQueuedItem[] = [];
1259
+ private _isBusy: boolean = false;
1260
+ private _queueMessageId: string | null = null;
935
1261
  private _title: string | null = null;
936
- private _titleChanged = new Signal<AIChatModel, string | null>(this);
1262
+ private _titleChanged = new Signal<IAIChatModel, string | null>(this);
937
1263
  }
938
1264
 
939
1265
  namespace Private {
1266
+ export interface IQueuedItem {
1267
+ id: string;
1268
+ body: string;
1269
+ _originalMsg: IMessageContent;
1270
+ }
1271
+
940
1272
  type IDisplayOutput =
941
1273
  | nbformat.IDisplayData
942
1274
  | nbformat.IDisplayUpdate
943
1275
  | nbformat.IExecuteResult;
944
1276
 
945
- const isPlainObject = (value: unknown): value is Record<string, unknown> => {
1277
+ export const isPlainObject = (
1278
+ value: unknown
1279
+ ): value is Record<string, unknown> => {
946
1280
  return typeof value === 'object' && value !== null && !Array.isArray(value);
947
1281
  };
948
1282
 
949
- const isDisplayOutput = (value: unknown): value is IDisplayOutput => {
1283
+ export const isDisplayOutput = (value: unknown): value is IDisplayOutput => {
950
1284
  if (!isPlainObject(value)) {
951
1285
  return false;
952
1286
  }
@@ -959,7 +1293,7 @@ namespace Private {
959
1293
  );
960
1294
  };
961
1295
 
962
- const toMimeBundle = (
1296
+ export const toMimeBundle = (
963
1297
  value: IDisplayOutput,
964
1298
  trustedMimeTypes: ReadonlySet<string>
965
1299
  ): IMimeModelBody | null => {
@@ -987,7 +1321,7 @@ namespace Private {
987
1321
  * Tool outputs are not guaranteed to be raw Jupyter IOPub messages; they are
988
1322
  * often wrapped objects (for example `{ success, result: { outputs: [...] } }`).
989
1323
  */
990
- const toDisplayOutputs = (value: unknown): IDisplayOutput[] => {
1324
+ export const toDisplayOutputs = (value: unknown): IDisplayOutput[] => {
991
1325
  if (isDisplayOutput(value)) {
992
1326
  return [value];
993
1327
  }
@@ -1043,23 +1377,29 @@ namespace Private {
1043
1377
  }
1044
1378
 
1045
1379
  /**
1046
- * Processes file attachments and returns text contents and binary parts separately.
1380
+ * Processes file attachments and returns the message content with the attachments.
1047
1381
  * @param attachments Array of file attachments to process
1048
1382
  * @param documentManager Optional document manager for file operations
1049
- * @returns Text contents and binary parts
1383
+ * @param body The message body
1384
+ * @param supportsImages Whether the model supports images
1385
+ * @param supportsPdf Whether the model supports pdfs
1386
+ * @param supportsAudio Whether the model supports audio
1387
+ * @returns Enhanced message content
1050
1388
  */
1051
1389
  export async function processAttachments(
1052
1390
  attachments: IAttachment[],
1053
- documentManager: IDocumentManager | null | undefined
1054
- ): Promise<{
1055
- textContents: string[];
1056
- binaryParts: Array<ImagePart | FilePart>;
1057
- }> {
1391
+ documentManager: IDocumentManager | null | undefined,
1392
+ body: string,
1393
+ supportsImages: boolean,
1394
+ supportsPdf: boolean,
1395
+ supportsAudio: boolean
1396
+ ): Promise<UserContent> {
1058
1397
  const textContents: string[] = [];
1059
- const binaryParts: Array<ImagePart | FilePart> = [];
1398
+ const includedParts: Array<ImagePart | FilePart> = [];
1399
+ const omittedNames: string[] = [];
1060
1400
 
1061
1401
  if (!documentManager) {
1062
- return { textContents, binaryParts };
1402
+ return body;
1063
1403
  }
1064
1404
 
1065
1405
  for (const attachment of attachments) {
@@ -1093,29 +1433,54 @@ namespace Private {
1093
1433
  }
1094
1434
 
1095
1435
  if (mimetype?.startsWith('image/')) {
1096
- const data = await readBinaryAttachment(
1097
- attachment,
1098
- documentManager
1099
- );
1100
- if (data) {
1101
- binaryParts.push({
1102
- type: 'image',
1103
- image: data,
1104
- mediaType: mimetype
1105
- });
1436
+ if (supportsImages) {
1437
+ const data = await readBinaryAttachment(
1438
+ attachment,
1439
+ documentManager
1440
+ );
1441
+ if (data) {
1442
+ includedParts.push({
1443
+ type: 'image',
1444
+ image: data,
1445
+ mediaType: mimetype
1446
+ });
1447
+ }
1448
+ } else {
1449
+ omittedNames.push(PathExt.basename(attachment.value));
1106
1450
  }
1107
1451
  } else if (mimetype === 'application/pdf') {
1108
- const data = await readBinaryAttachment(
1109
- attachment,
1110
- documentManager
1111
- );
1112
- if (data) {
1113
- binaryParts.push({
1114
- type: 'file',
1115
- data,
1116
- mediaType: mimetype,
1117
- filename: PathExt.basename(attachment.value)
1118
- });
1452
+ if (supportsPdf) {
1453
+ const data = await readBinaryAttachment(
1454
+ attachment,
1455
+ documentManager
1456
+ );
1457
+ if (data) {
1458
+ includedParts.push({
1459
+ type: 'file',
1460
+ data,
1461
+ mediaType: mimetype,
1462
+ filename: PathExt.basename(attachment.value)
1463
+ });
1464
+ }
1465
+ } else {
1466
+ omittedNames.push(PathExt.basename(attachment.value));
1467
+ }
1468
+ } else if (mimetype?.startsWith('audio/')) {
1469
+ if (supportsAudio) {
1470
+ const data = await readBinaryAttachment(
1471
+ attachment,
1472
+ documentManager
1473
+ );
1474
+ if (data) {
1475
+ includedParts.push({
1476
+ type: 'file',
1477
+ data,
1478
+ mediaType: mimetype,
1479
+ filename: PathExt.basename(attachment.value)
1480
+ });
1481
+ }
1482
+ } else {
1483
+ omittedNames.push(PathExt.basename(attachment.value));
1119
1484
  }
1120
1485
  } else {
1121
1486
  const fileContent = await readFileAttachment(
@@ -1142,7 +1507,18 @@ namespace Private {
1142
1507
  }
1143
1508
  }
1144
1509
 
1145
- return { textContents, binaryParts };
1510
+ let textPart = body;
1511
+ if (textContents.length > 0) {
1512
+ textPart += '\n\n--- Attached Files ---\n' + textContents.join('\n\n');
1513
+ }
1514
+
1515
+ if (omittedNames.length > 0) {
1516
+ textPart += `\n[Attachments omitted (not supported by this model): ${omittedNames.join(', ')}.]`;
1517
+ }
1518
+
1519
+ return includedParts.length > 0
1520
+ ? [{ type: 'text', text: textPart }, ...includedParts]
1521
+ : textPart;
1146
1522
  }
1147
1523
 
1148
1524
  /**
@@ -1462,6 +1838,10 @@ export namespace AIChatModel {
1462
1838
  * The contents manager.
1463
1839
  */
1464
1840
  contentsManager?: Contents.IManager;
1841
+ /**
1842
+ * Optional provider registry for model capability lookups.
1843
+ */
1844
+ providerRegistry?: IProviderRegistry;
1465
1845
  /**
1466
1846
  * Whether to restore or not the message (default to true)
1467
1847
  */
@@ -1488,6 +1868,18 @@ export namespace AIChatModel {
1488
1868
  * The agent manager of the chat.
1489
1869
  */
1490
1870
  agentManager: IAgentManager;
1871
+ /**
1872
+ * Removes a queued message by its ID.
1873
+ */
1874
+ removeQueuedMessage: (id: string) => void;
1875
+ /**
1876
+ * Reorders queued messages by their IDs.
1877
+ */
1878
+ reorderQueuedMessages: (ids: string[]) => void;
1879
+ /**
1880
+ * Edits a queued message by its ID.
1881
+ */
1882
+ editQueuedMessage: (id: string, body: string) => void;
1491
1883
  }
1492
1884
 
1493
1885
  /**