@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/chat-model.js CHANGED
@@ -49,6 +49,31 @@ export class AIChatModel extends AbstractChatModel {
49
49
  }
50
50
  this.setReady();
51
51
  }
52
+ /**
53
+ * A signal emitting when the chat name has changed.
54
+ */
55
+ get nameChanged() {
56
+ return this._nameChanged;
57
+ }
58
+ /**
59
+ * The title of the chat.
60
+ */
61
+ get title() {
62
+ return this._title;
63
+ }
64
+ set title(value) {
65
+ this._title = value;
66
+ if (this.autosave) {
67
+ this._autosaveDebouncer.invoke();
68
+ }
69
+ this._titleChanged.emit(this._title);
70
+ }
71
+ /**
72
+ * A signal emitting when the chat title has changed.
73
+ */
74
+ get titleChanged() {
75
+ return this._titleChanged;
76
+ }
52
77
  /**
53
78
  * Whether to save the chat automatically.
54
79
  */
@@ -74,12 +99,6 @@ export class AIChatModel extends AbstractChatModel {
74
99
  get autosaveChanged() {
75
100
  return this._autosaveChanged;
76
101
  }
77
- /**
78
- * A signal emitting when the chat name has changed.
79
- */
80
- get nameChanged() {
81
- return this._nameChanged;
82
- }
83
102
  /**
84
103
  * Gets the current user information.
85
104
  */
@@ -135,10 +154,10 @@ export class AIChatModel extends AbstractChatModel {
135
154
  /**
136
155
  * Clears all messages from the chat and resets conversation state.
137
156
  */
138
- clearMessages = () => {
157
+ clearMessages = async () => {
139
158
  this.messagesDeleted(0, this.messages.length);
140
159
  this._toolContexts.clear();
141
- this._agentManager.clearHistory();
160
+ await this._agentManager.clearHistory();
142
161
  };
143
162
  /**
144
163
  * Adds a non-user message to the chat (used by chat commands).
@@ -192,11 +211,18 @@ export class AIChatModel extends AbstractChatModel {
192
211
  // Process attachments and add their content to the message
193
212
  let enhancedMessage = message.body;
194
213
  if (this.input.attachments.length > 0) {
195
- const attachmentContents = await this._processAttachments(this.input.attachments);
214
+ const { textContents, binaryParts } = await Private.processAttachments(this.input.attachments, this.input.documentManager);
196
215
  this.input.clearAttachments();
197
- if (attachmentContents.length > 0) {
198
- enhancedMessage +=
199
- '\n\n--- Attached Files ---\n' + attachmentContents.join('\n\n');
216
+ let textPart = message.body;
217
+ if (textContents.length > 0) {
218
+ textPart +=
219
+ '\n\n--- Attached Files ---\n' + textContents.join('\n\n');
220
+ }
221
+ if (binaryParts.length > 0) {
222
+ enhancedMessage = [{ type: 'text', text: textPart }, ...binaryParts];
223
+ }
224
+ else {
225
+ enhancedMessage = textPart;
200
226
  }
201
227
  }
202
228
  this.updateWriters([{ user: this._getAIUser() }]);
@@ -299,12 +325,33 @@ export class AIChatModel extends AbstractChatModel {
299
325
  attachments
300
326
  };
301
327
  });
302
- this.clearMessages();
328
+ await this.clearMessages();
303
329
  this.messagesInserted(0, messages);
304
330
  this._agentManager.setHistory(messages);
305
331
  this.autosave = content.metadata?.autosave ?? false;
332
+ this.title = content.metadata?.title ?? null;
306
333
  return true;
307
334
  };
335
+ /**
336
+ * Request a title to this chat, regarding the message history.
337
+ */
338
+ async requestTitle() {
339
+ const history = this.messages
340
+ .filter(msg => msg.body !== '')
341
+ .map(msg => `${msg.sender.username === 'ai-assistant' ? 'assistant' : 'user'}: ${msg.body}`)
342
+ .join('\n');
343
+ const messages = [
344
+ {
345
+ role: 'system',
346
+ content: "Generate a concise title (no more than 10 words) for the following conversation. Do not use formatting. Focus on the user's main intent."
347
+ },
348
+ {
349
+ role: 'user',
350
+ content: history
351
+ }
352
+ ];
353
+ return this.agentManager.textResponse(messages);
354
+ }
308
355
  /**
309
356
  * Serialize the model for backup
310
357
  */
@@ -348,7 +395,8 @@ export class AIChatModel extends AbstractChatModel {
348
395
  attachments,
349
396
  metadata: {
350
397
  provider,
351
- autosave: this.autosave
398
+ autosave: this.autosave,
399
+ ...(this.title ? { title: this.title } : {})
352
400
  }
353
401
  };
354
402
  }
@@ -658,49 +706,216 @@ export class AIChatModel extends AbstractChatModel {
658
706
  }
659
707
  });
660
708
  }
709
+ // Private fields
710
+ _settingsModel;
711
+ _user;
712
+ _toolContexts = new Map();
713
+ _agentManager;
714
+ _currentStreamingMessage = null;
715
+ _nameChanged = new Signal(this);
716
+ _contentsManager;
717
+ _autosave = false;
718
+ _autosaveChanged = new Signal(this);
719
+ _autosaveDebouncer;
720
+ _title = null;
721
+ _titleChanged = new Signal(this);
722
+ }
723
+ var Private;
724
+ (function (Private) {
725
+ const isPlainObject = (value) => {
726
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
727
+ };
728
+ const isDisplayOutput = (value) => {
729
+ if (!isPlainObject(value)) {
730
+ return false;
731
+ }
732
+ const output = value;
733
+ return (nbformat.isDisplayData(output) ||
734
+ nbformat.isDisplayUpdate(output) ||
735
+ nbformat.isExecuteResult(output));
736
+ };
737
+ const toMimeBundle = (value, trustedMimeTypes) => {
738
+ const data = value.data;
739
+ if (!isPlainObject(data) || Object.keys(data).length === 0) {
740
+ return null;
741
+ }
742
+ return {
743
+ data: data,
744
+ ...(isPlainObject(value.metadata)
745
+ ? { metadata: value.metadata }
746
+ : {}),
747
+ // MIME auto-rendering only runs for explicitly configured command IDs.
748
+ // Trust handling is configurable to keep risky MIME execution opt-in.
749
+ ...(Object.keys(data).some(m => trustedMimeTypes.has(m))
750
+ ? { trusted: true }
751
+ : {})
752
+ };
753
+ };
754
+ /**
755
+ * Normalize arbitrary tool payloads into canonical display outputs.
756
+ *
757
+ * Tool outputs are not guaranteed to be raw Jupyter IOPub messages; they are
758
+ * often wrapped objects (for example `{ success, result: { outputs: [...] } }`).
759
+ */
760
+ const toDisplayOutputs = (value) => {
761
+ if (isDisplayOutput(value)) {
762
+ return [value];
763
+ }
764
+ if (Array.isArray(value)) {
765
+ return value.filter(isDisplayOutput);
766
+ }
767
+ if (!isPlainObject(value)) {
768
+ return [];
769
+ }
770
+ if (Array.isArray(value.outputs)) {
771
+ return value.outputs.filter(isDisplayOutput);
772
+ }
773
+ if ('result' in value) {
774
+ return toDisplayOutputs(value.result);
775
+ }
776
+ return [];
777
+ };
661
778
  /**
662
- * Processes file attachments and returns their content as formatted strings.
779
+ * Extract rendermime-ready mime bundles from arbitrary tool results.
780
+ */
781
+ function extractMimeBundlesFromUnknown(content, options = {}) {
782
+ const bundles = [];
783
+ const outputs = toDisplayOutputs(content);
784
+ const trustedMimeTypes = new Set(options.trustedMimeTypes ?? []);
785
+ for (const output of outputs) {
786
+ const bundle = toMimeBundle(output, trustedMimeTypes);
787
+ if (bundle) {
788
+ bundles.push(bundle);
789
+ }
790
+ }
791
+ return bundles;
792
+ }
793
+ Private.extractMimeBundlesFromUnknown = extractMimeBundlesFromUnknown;
794
+ function formatToolOutput(outputData) {
795
+ if (typeof outputData === 'string') {
796
+ return outputData;
797
+ }
798
+ try {
799
+ return JSON.stringify(outputData, null, 2);
800
+ }
801
+ catch {
802
+ return '[Complex object - cannot serialize]';
803
+ }
804
+ }
805
+ Private.formatToolOutput = formatToolOutput;
806
+ /**
807
+ * Processes file attachments and returns text contents and binary parts separately.
663
808
  * @param attachments Array of file attachments to process
664
- * @returns Array of formatted attachment contents
809
+ * @param documentManager Optional document manager for file operations
810
+ * @returns Text contents and binary parts
665
811
  */
666
- async _processAttachments(attachments) {
667
- const contents = [];
812
+ async function processAttachments(attachments, documentManager) {
813
+ const textContents = [];
814
+ const binaryParts = [];
815
+ if (!documentManager) {
816
+ return { textContents, binaryParts };
817
+ }
668
818
  for (const attachment of attachments) {
669
819
  try {
670
820
  if (attachment.type === 'notebook' && attachment.cells?.length) {
671
- const cellContents = await this._readNotebookCells(attachment);
821
+ const cellContents = await readNotebookCells(attachment, documentManager);
672
822
  if (cellContents) {
673
- contents.push(cellContents);
823
+ textContents.push(cellContents);
674
824
  }
675
825
  }
676
826
  else {
677
- const fileContent = await this._readFileAttachment(attachment);
678
- if (fileContent) {
679
- const fileExtension = PathExt.extname(attachment.value).toLowerCase();
680
- const language = fileExtension === '.ipynb' ? 'json' : '';
681
- contents.push(`**File: ${attachment.value}**\n\`\`\`${language}\n${fileContent}\n\`\`\``);
827
+ let mimetype = attachment.mimetype;
828
+ const fileExtension = PathExt.extname(attachment.value).toLowerCase();
829
+ // Fetch mimetype from server metadata if not provided
830
+ if (!mimetype) {
831
+ try {
832
+ const diskModel = await documentManager.services.contents.get(attachment.value, { content: false });
833
+ mimetype = diskModel?.mimetype;
834
+ }
835
+ catch (e) {
836
+ console.warn(`Failed to fetch metadata for ${attachment.value}:`, e);
837
+ }
838
+ }
839
+ if (mimetype?.startsWith('image/')) {
840
+ const data = await readBinaryAttachment(attachment, documentManager);
841
+ if (data) {
842
+ binaryParts.push({
843
+ type: 'image',
844
+ image: data,
845
+ mediaType: mimetype
846
+ });
847
+ }
848
+ }
849
+ else if (mimetype === 'application/pdf') {
850
+ const data = await readBinaryAttachment(attachment, documentManager);
851
+ if (data) {
852
+ binaryParts.push({
853
+ type: 'file',
854
+ data,
855
+ mediaType: mimetype,
856
+ filename: PathExt.basename(attachment.value)
857
+ });
858
+ }
859
+ }
860
+ else {
861
+ const fileContent = await readFileAttachment(attachment, documentManager);
862
+ if (fileContent) {
863
+ const language = fileExtension === '.ipynb' ||
864
+ mimetype === 'application/x-ipynb+json'
865
+ ? 'json'
866
+ : '';
867
+ textContents.push(`**File: ${attachment.value}**\n\`\`\`${language}\n${fileContent}\n\`\`\``);
868
+ }
682
869
  }
683
870
  }
684
871
  }
685
872
  catch (error) {
686
873
  console.warn(`Failed to read attachment ${attachment.value}:`, error);
687
- contents.push(`**File: ${attachment.value}** (Could not read file)`);
874
+ textContents.push(`**File: ${attachment.value}** (Could not read file)`);
875
+ }
876
+ }
877
+ return { textContents, binaryParts };
878
+ }
879
+ Private.processAttachments = processAttachments;
880
+ /**
881
+ * Reads a binary attachment and returns its base64-encoded content.
882
+ * @param attachment The attachment to read
883
+ * @param documentManager Optional document manager for file operations
884
+ * @returns Base64 string or null if unable to read
885
+ */
886
+ async function readBinaryAttachment(attachment, documentManager) {
887
+ if (!documentManager) {
888
+ return null;
889
+ }
890
+ try {
891
+ const diskModel = await documentManager.services.contents.get(attachment.value, { content: true });
892
+ if (diskModel?.content && diskModel.format === 'base64') {
893
+ // Strip whitespace/newlines
894
+ return diskModel.content.replace(/\s/g, '');
688
895
  }
896
+ return null;
897
+ }
898
+ catch (error) {
899
+ console.warn(`Failed to read binary attachment ${attachment.value}:`, error);
900
+ return null;
689
901
  }
690
- return contents;
691
902
  }
903
+ Private.readBinaryAttachment = readBinaryAttachment;
692
904
  /**
693
905
  * Reads the content of a notebook cell.
694
906
  * @param attachment The notebook attachment to read
907
+ * @param documentManager Optional document manager for file operations
695
908
  * @returns Cell content as string or null if unable to read
696
909
  */
697
- async _readNotebookCells(attachment) {
698
- if (attachment.type !== 'notebook' || !attachment.cells) {
910
+ async function readNotebookCells(attachment, documentManager) {
911
+ if (attachment.type !== 'notebook' ||
912
+ !attachment.cells ||
913
+ !documentManager) {
699
914
  return null;
700
915
  }
701
916
  try {
702
917
  // Try reading from live notebook if open
703
- const widget = this.input.documentManager?.findWidget(attachment.value);
918
+ const widget = documentManager.findWidget(attachment.value);
704
919
  let cellData;
705
920
  let kernelLang = 'text';
706
921
  const ymodel = widget?.context.model.sharedModel;
@@ -714,7 +929,7 @@ export class AIChatModel extends AbstractChatModel {
714
929
  }
715
930
  else {
716
931
  // Fallback: reading from disk
717
- const model = await this.input.documentManager?.services.contents.get(attachment.value);
932
+ const model = await documentManager.services.contents.get(attachment.value);
718
933
  if (!model || model.type !== 'notebook') {
719
934
  return null;
720
935
  }
@@ -829,19 +1044,22 @@ export class AIChatModel extends AbstractChatModel {
829
1044
  return null;
830
1045
  }
831
1046
  }
1047
+ Private.readNotebookCells = readNotebookCells;
832
1048
  /**
833
1049
  * Reads the content of a file attachment.
834
1050
  * @param attachment The file attachment to read
1051
+ * @param documentManager Optional document manager for file operations
835
1052
  * @returns File content as string or null if unable to read
836
1053
  */
837
- async _readFileAttachment(attachment) {
1054
+ async function readFileAttachment(attachment, documentManager) {
838
1055
  // Handle both 'file' and 'notebook' types since both have a 'value' path
839
- if (attachment.type !== 'file' && attachment.type !== 'notebook') {
1056
+ if ((attachment.type !== 'file' && attachment.type !== 'notebook') ||
1057
+ !documentManager) {
840
1058
  return null;
841
1059
  }
842
1060
  try {
843
1061
  // Try reading from an open widget first
844
- const widget = this.input.documentManager?.findWidget(attachment.value);
1062
+ const widget = documentManager.findWidget(attachment.value);
845
1063
  if (widget && widget.context && widget.context.model) {
846
1064
  const model = widget.context.model;
847
1065
  const ymodel = model.sharedModel;
@@ -853,7 +1071,7 @@ export class AIChatModel extends AbstractChatModel {
853
1071
  }
854
1072
  }
855
1073
  // If not open, load from disk
856
- const diskModel = await this.input.documentManager?.services.contents.get(attachment.value);
1074
+ const diskModel = await documentManager.services.contents.get(attachment.value);
857
1075
  if (!diskModel?.content) {
858
1076
  return null;
859
1077
  }
@@ -879,99 +1097,5 @@ export class AIChatModel extends AbstractChatModel {
879
1097
  return null;
880
1098
  }
881
1099
  }
882
- // Private fields
883
- _settingsModel;
884
- _user;
885
- _toolContexts = new Map();
886
- _agentManager;
887
- _currentStreamingMessage = null;
888
- _nameChanged = new Signal(this);
889
- _contentsManager;
890
- _autosave = false;
891
- _autosaveChanged = new Signal(this);
892
- _autosaveDebouncer;
893
- }
894
- var Private;
895
- (function (Private) {
896
- const isPlainObject = (value) => {
897
- return typeof value === 'object' && value !== null && !Array.isArray(value);
898
- };
899
- const isDisplayOutput = (value) => {
900
- if (!isPlainObject(value)) {
901
- return false;
902
- }
903
- const output = value;
904
- return (nbformat.isDisplayData(output) ||
905
- nbformat.isDisplayUpdate(output) ||
906
- nbformat.isExecuteResult(output));
907
- };
908
- const toMimeBundle = (value, trustedMimeTypes) => {
909
- const data = value.data;
910
- if (!isPlainObject(data) || Object.keys(data).length === 0) {
911
- return null;
912
- }
913
- return {
914
- data: data,
915
- ...(isPlainObject(value.metadata)
916
- ? { metadata: value.metadata }
917
- : {}),
918
- // MIME auto-rendering only runs for explicitly configured command IDs.
919
- // Trust handling is configurable to keep risky MIME execution opt-in.
920
- ...(Object.keys(data).some(m => trustedMimeTypes.has(m))
921
- ? { trusted: true }
922
- : {})
923
- };
924
- };
925
- /**
926
- * Normalize arbitrary tool payloads into canonical display outputs.
927
- *
928
- * Tool outputs are not guaranteed to be raw Jupyter IOPub messages; they are
929
- * often wrapped objects (for example `{ success, result: { outputs: [...] } }`).
930
- */
931
- const toDisplayOutputs = (value) => {
932
- if (isDisplayOutput(value)) {
933
- return [value];
934
- }
935
- if (Array.isArray(value)) {
936
- return value.filter(isDisplayOutput);
937
- }
938
- if (!isPlainObject(value)) {
939
- return [];
940
- }
941
- if (Array.isArray(value.outputs)) {
942
- return value.outputs.filter(isDisplayOutput);
943
- }
944
- if ('result' in value) {
945
- return toDisplayOutputs(value.result);
946
- }
947
- return [];
948
- };
949
- /**
950
- * Extract rendermime-ready mime bundles from arbitrary tool results.
951
- */
952
- function extractMimeBundlesFromUnknown(content, options = {}) {
953
- const bundles = [];
954
- const outputs = toDisplayOutputs(content);
955
- const trustedMimeTypes = new Set(options.trustedMimeTypes ?? []);
956
- for (const output of outputs) {
957
- const bundle = toMimeBundle(output, trustedMimeTypes);
958
- if (bundle) {
959
- bundles.push(bundle);
960
- }
961
- }
962
- return bundles;
963
- }
964
- Private.extractMimeBundlesFromUnknown = extractMimeBundlesFromUnknown;
965
- function formatToolOutput(outputData) {
966
- if (typeof outputData === 'string') {
967
- return outputData;
968
- }
969
- try {
970
- return JSON.stringify(outputData, null, 2);
971
- }
972
- catch {
973
- return '[Complex object - cannot serialize]';
974
- }
975
- }
976
- Private.formatToolOutput = formatToolOutput;
1100
+ Private.readFileAttachment = readFileAttachment;
977
1101
  })(Private || (Private = {}));
@@ -7,7 +7,7 @@ export interface IClearButtonProps extends InputToolbarRegistry.IToolbarItemProp
7
7
  /**
8
8
  * The function to clear messages.
9
9
  */
10
- clearMessages: () => void;
10
+ clearMessages: () => Promise<void>;
11
11
  /**
12
12
  * The application language translator.
13
13
  */
@@ -21,7 +21,7 @@ export function clearItem(translator) {
21
21
  return {
22
22
  element: (props) => {
23
23
  const { model } = props;
24
- const clearMessages = () => model.chatContext.clearMessages();
24
+ const clearMessages = async () => await model.chatContext.clearMessages();
25
25
  const clearProps = {
26
26
  ...props,
27
27
  clearMessages,