@jupyterlite/ai 0.15.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.
Files changed (45) hide show
  1. package/lib/agent.d.ts +12 -2
  2. package/lib/agent.js +112 -17
  3. package/lib/chat-commands/clear.js +1 -1
  4. package/lib/chat-model-handler.js +4 -1
  5. package/lib/chat-model.d.ts +25 -24
  6. package/lib/chat-model.js +262 -132
  7. package/lib/components/clear-button.d.ts +1 -1
  8. package/lib/components/clear-button.js +1 -1
  9. package/lib/components/index.d.ts +1 -1
  10. package/lib/components/index.js +1 -1
  11. package/lib/components/{token-usage-display.d.ts → usage-display.d.ts} +11 -11
  12. package/lib/components/usage-display.js +109 -0
  13. package/lib/index.js +205 -20
  14. package/lib/models/settings-model.js +1 -0
  15. package/lib/providers/built-in-providers.js +5 -0
  16. package/lib/providers/generated-context-windows.d.ts +8 -0
  17. package/lib/providers/generated-context-windows.js +96 -0
  18. package/lib/providers/model-info.d.ts +3 -0
  19. package/lib/providers/model-info.js +58 -0
  20. package/lib/tokens.d.ts +34 -3
  21. package/lib/tokens.js +8 -7
  22. package/lib/widgets/ai-settings.js +9 -0
  23. package/lib/widgets/main-area-chat.d.ts +1 -0
  24. package/lib/widgets/main-area-chat.js +10 -4
  25. package/lib/widgets/provider-config-dialog.js +18 -5
  26. package/package.json +3 -2
  27. package/schema/settings-model.json +11 -0
  28. package/src/agent.ts +151 -21
  29. package/src/chat-commands/clear.ts +1 -1
  30. package/src/chat-model-handler.ts +6 -1
  31. package/src/chat-model.ts +350 -175
  32. package/src/components/clear-button.tsx +3 -3
  33. package/src/components/index.ts +1 -1
  34. package/src/components/usage-display.tsx +208 -0
  35. package/src/index.ts +250 -26
  36. package/src/models/settings-model.ts +1 -0
  37. package/src/providers/built-in-providers.ts +5 -0
  38. package/src/providers/generated-context-windows.ts +102 -0
  39. package/src/providers/model-info.ts +88 -0
  40. package/src/tokens.ts +46 -10
  41. package/src/widgets/ai-settings.tsx +42 -0
  42. package/src/widgets/main-area-chat.ts +12 -4
  43. package/src/widgets/provider-config-dialog.tsx +45 -5
  44. package/lib/components/token-usage-display.js +0 -72
  45. package/src/components/token-usage-display.tsx +0 -137
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,6 +32,8 @@ 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
39
  import type { IAgentManager, IAISettingsModel, ITokenUsage } from './tokens';
@@ -133,6 +136,34 @@ export class AIChatModel extends AbstractChatModel {
133
136
  this.setReady();
134
137
  }
135
138
 
139
+ /**
140
+ * A signal emitting when the chat name has changed.
141
+ */
142
+ get nameChanged(): ISignal<AIChatModel, string> {
143
+ return this._nameChanged;
144
+ }
145
+
146
+ /**
147
+ * The title of the chat.
148
+ */
149
+ get title(): string | null {
150
+ return this._title;
151
+ }
152
+ set title(value: string | null) {
153
+ this._title = value;
154
+ if (this.autosave) {
155
+ this._autosaveDebouncer.invoke();
156
+ }
157
+ this._titleChanged.emit(this._title);
158
+ }
159
+
160
+ /**
161
+ * A signal emitting when the chat title has changed.
162
+ */
163
+ get titleChanged(): ISignal<AIChatModel, string | null> {
164
+ return this._titleChanged;
165
+ }
166
+
136
167
  /**
137
168
  * Whether to save the chat automatically.
138
169
  */
@@ -171,13 +202,6 @@ export class AIChatModel extends AbstractChatModel {
171
202
  return this._autosaveChanged;
172
203
  }
173
204
 
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
205
  /**
182
206
  * Gets the current user information.
183
207
  */
@@ -243,10 +267,10 @@ export class AIChatModel extends AbstractChatModel {
243
267
  /**
244
268
  * Clears all messages from the chat and resets conversation state.
245
269
  */
246
- clearMessages = (): void => {
270
+ clearMessages = async (): Promise<void> => {
247
271
  this.messagesDeleted(0, this.messages.length);
248
272
  this._toolContexts.clear();
249
- this._agentManager.clearHistory();
273
+ await this._agentManager.clearHistory();
250
274
  };
251
275
 
252
276
  /**
@@ -303,16 +327,24 @@ export class AIChatModel extends AbstractChatModel {
303
327
 
304
328
  try {
305
329
  // Process attachments and add their content to the message
306
- let enhancedMessage = message.body;
330
+ let enhancedMessage: UserContent = message.body;
307
331
  if (this.input.attachments.length > 0) {
308
- const attachmentContents = await this._processAttachments(
309
- this.input.attachments
332
+ const { textContents, binaryParts } = await Private.processAttachments(
333
+ this.input.attachments,
334
+ this.input.documentManager
310
335
  );
311
336
  this.input.clearAttachments();
312
337
 
313
- if (attachmentContents.length > 0) {
314
- enhancedMessage +=
315
- '\n\n--- Attached Files ---\n' + attachmentContents.join('\n\n');
338
+ let textPart = message.body;
339
+ if (textContents.length > 0) {
340
+ textPart +=
341
+ '\n\n--- Attached Files ---\n' + textContents.join('\n\n');
342
+ }
343
+
344
+ if (binaryParts.length > 0) {
345
+ enhancedMessage = [{ type: 'text', text: textPart }, ...binaryParts];
346
+ } else {
347
+ enhancedMessage = textPart;
316
348
  }
317
349
  }
318
350
 
@@ -376,7 +408,7 @@ export class AIChatModel extends AbstractChatModel {
376
408
  return false;
377
409
  }
378
410
  const contentModel = await this._contentsManager
379
- .get(filepath, { content: true })
411
+ .get(filepath, { content: true, type: 'file', format: 'text' })
380
412
  .catch(() => {
381
413
  if (!silent) {
382
414
  console.log(`There is no backup for chat '${this.name}'`);
@@ -386,9 +418,12 @@ export class AIChatModel extends AbstractChatModel {
386
418
  if (!contentModel) {
387
419
  return false;
388
420
  }
389
- const content = JSON.parse(
390
- contentModel.content
391
- ) as AIChatModel.ExportedChat;
421
+ let content: AIChatModel.ExportedChat;
422
+ try {
423
+ content = JSON.parse(contentModel.content);
424
+ } catch (e) {
425
+ throw `Error when parsing the chat ${filepath}\n${e}`;
426
+ }
392
427
 
393
428
  if (content.metadata?.provider) {
394
429
  if (this._settingsModel.getProvider(content.metadata.provider)) {
@@ -415,13 +450,39 @@ export class AIChatModel extends AbstractChatModel {
415
450
  attachments
416
451
  };
417
452
  });
418
- this.clearMessages();
453
+ await this.clearMessages();
419
454
  this.messagesInserted(0, messages);
420
455
  this._agentManager.setHistory(messages);
421
456
  this.autosave = content.metadata?.autosave ?? false;
457
+ this.title = content.metadata?.title ?? null;
422
458
  return true;
423
459
  };
424
460
 
461
+ /**
462
+ * Request a title to this chat, regarding the message history.
463
+ */
464
+ async requestTitle(): Promise<string> {
465
+ const history = this.messages
466
+ .filter(msg => msg.body !== '')
467
+ .map(
468
+ msg =>
469
+ `${msg.sender.username === 'ai-assistant' ? 'assistant' : 'user'}: ${msg.body}`
470
+ )
471
+ .join('\n');
472
+ const messages: ModelMessage[] = [
473
+ {
474
+ role: 'system',
475
+ 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."
477
+ },
478
+ {
479
+ role: 'user',
480
+ content: history
481
+ }
482
+ ];
483
+ return this.agentManager.textResponse(messages);
484
+ }
485
+
425
486
  /**
426
487
  * Serialize the model for backup
427
488
  */
@@ -471,7 +532,8 @@ export class AIChatModel extends AbstractChatModel {
471
532
  attachments,
472
533
  metadata: {
473
534
  provider,
474
- autosave: this.autosave
535
+ autosave: this.autosave,
536
+ ...(this.title ? { title: this.title } : {})
475
537
  }
476
538
  };
477
539
  }
@@ -859,61 +921,286 @@ export class AIChatModel extends AbstractChatModel {
859
921
  });
860
922
  }
861
923
 
924
+ // Private fields
925
+ private _settingsModel: IAISettingsModel;
926
+ private _user: IUser;
927
+ private _toolContexts: Map<string, IToolExecutionContext> = new Map();
928
+ private _agentManager: IAgentManager;
929
+ private _currentStreamingMessage: IMessage | null = null;
930
+ private _nameChanged = new Signal<AIChatModel, string>(this);
931
+ private _contentsManager?: Contents.IManager;
932
+ private _autosave: boolean = false;
933
+ private _autosaveChanged = new Signal<AIChatModel, boolean>(this);
934
+ private _autosaveDebouncer: Debouncer;
935
+ private _title: string | null = null;
936
+ private _titleChanged = new Signal<AIChatModel, string | null>(this);
937
+ }
938
+
939
+ namespace Private {
940
+ type IDisplayOutput =
941
+ | nbformat.IDisplayData
942
+ | nbformat.IDisplayUpdate
943
+ | nbformat.IExecuteResult;
944
+
945
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
946
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
947
+ };
948
+
949
+ const isDisplayOutput = (value: unknown): value is IDisplayOutput => {
950
+ if (!isPlainObject(value)) {
951
+ return false;
952
+ }
953
+
954
+ const output = value as nbformat.IOutput;
955
+ return (
956
+ nbformat.isDisplayData(output) ||
957
+ nbformat.isDisplayUpdate(output) ||
958
+ nbformat.isExecuteResult(output)
959
+ );
960
+ };
961
+
962
+ const toMimeBundle = (
963
+ value: IDisplayOutput,
964
+ trustedMimeTypes: ReadonlySet<string>
965
+ ): IMimeModelBody | null => {
966
+ const data = value.data;
967
+ if (!isPlainObject(data) || Object.keys(data).length === 0) {
968
+ return null;
969
+ }
970
+
971
+ return {
972
+ data: data as IRenderMime.IMimeModel['data'],
973
+ ...(isPlainObject(value.metadata)
974
+ ? { metadata: value.metadata as IRenderMime.IMimeModel['metadata'] }
975
+ : {}),
976
+ // MIME auto-rendering only runs for explicitly configured command IDs.
977
+ // Trust handling is configurable to keep risky MIME execution opt-in.
978
+ ...(Object.keys(data).some(m => trustedMimeTypes.has(m))
979
+ ? { trusted: true }
980
+ : {})
981
+ };
982
+ };
983
+
984
+ /**
985
+ * Normalize arbitrary tool payloads into canonical display outputs.
986
+ *
987
+ * Tool outputs are not guaranteed to be raw Jupyter IOPub messages; they are
988
+ * often wrapped objects (for example `{ success, result: { outputs: [...] } }`).
989
+ */
990
+ const toDisplayOutputs = (value: unknown): IDisplayOutput[] => {
991
+ if (isDisplayOutput(value)) {
992
+ return [value];
993
+ }
994
+
995
+ if (Array.isArray(value)) {
996
+ return value.filter(isDisplayOutput);
997
+ }
998
+
999
+ if (!isPlainObject(value)) {
1000
+ return [];
1001
+ }
1002
+
1003
+ if (Array.isArray(value.outputs)) {
1004
+ return value.outputs.filter(isDisplayOutput);
1005
+ }
1006
+
1007
+ if ('result' in value) {
1008
+ return toDisplayOutputs(value.result);
1009
+ }
1010
+
1011
+ return [];
1012
+ };
1013
+
1014
+ /**
1015
+ * Extract rendermime-ready mime bundles from arbitrary tool results.
1016
+ */
1017
+ export function extractMimeBundlesFromUnknown(
1018
+ content: unknown,
1019
+ options: { trustedMimeTypes?: ReadonlyArray<string> } = {}
1020
+ ): IMimeModelBody[] {
1021
+ const bundles: IMimeModelBody[] = [];
1022
+ const outputs = toDisplayOutputs(content);
1023
+ const trustedMimeTypes = new Set(options.trustedMimeTypes ?? []);
1024
+ for (const output of outputs) {
1025
+ const bundle = toMimeBundle(output, trustedMimeTypes);
1026
+ if (bundle) {
1027
+ bundles.push(bundle);
1028
+ }
1029
+ }
1030
+ return bundles;
1031
+ }
1032
+
1033
+ export function formatToolOutput(outputData: unknown): string {
1034
+ if (typeof outputData === 'string') {
1035
+ return outputData;
1036
+ }
1037
+
1038
+ try {
1039
+ return JSON.stringify(outputData, null, 2);
1040
+ } catch {
1041
+ return '[Complex object - cannot serialize]';
1042
+ }
1043
+ }
1044
+
862
1045
  /**
863
- * Processes file attachments and returns their content as formatted strings.
1046
+ * Processes file attachments and returns text contents and binary parts separately.
864
1047
  * @param attachments Array of file attachments to process
865
- * @returns Array of formatted attachment contents
1048
+ * @param documentManager Optional document manager for file operations
1049
+ * @returns Text contents and binary parts
866
1050
  */
867
- private async _processAttachments(
868
- attachments: IAttachment[]
869
- ): Promise<string[]> {
870
- const contents: string[] = [];
1051
+ export async function processAttachments(
1052
+ attachments: IAttachment[],
1053
+ documentManager: IDocumentManager | null | undefined
1054
+ ): Promise<{
1055
+ textContents: string[];
1056
+ binaryParts: Array<ImagePart | FilePart>;
1057
+ }> {
1058
+ const textContents: string[] = [];
1059
+ const binaryParts: Array<ImagePart | FilePart> = [];
1060
+
1061
+ if (!documentManager) {
1062
+ return { textContents, binaryParts };
1063
+ }
871
1064
 
872
1065
  for (const attachment of attachments) {
873
1066
  try {
874
1067
  if (attachment.type === 'notebook' && attachment.cells?.length) {
875
- const cellContents = await this._readNotebookCells(attachment);
1068
+ const cellContents = await readNotebookCells(
1069
+ attachment,
1070
+ documentManager
1071
+ );
876
1072
  if (cellContents) {
877
- contents.push(cellContents);
1073
+ textContents.push(cellContents);
878
1074
  }
879
1075
  } else {
880
- const fileContent = await this._readFileAttachment(attachment);
881
- if (fileContent) {
882
- const fileExtension = PathExt.extname(
883
- attachment.value
884
- ).toLowerCase();
885
- const language = fileExtension === '.ipynb' ? 'json' : '';
886
- contents.push(
887
- `**File: ${attachment.value}**\n\`\`\`${language}\n${fileContent}\n\`\`\``
1076
+ let mimetype = attachment.mimetype;
1077
+ const fileExtension = PathExt.extname(attachment.value).toLowerCase();
1078
+
1079
+ // Fetch mimetype from server metadata if not provided
1080
+ if (!mimetype) {
1081
+ try {
1082
+ const diskModel = await documentManager.services.contents.get(
1083
+ attachment.value,
1084
+ { content: false }
1085
+ );
1086
+ mimetype = diskModel?.mimetype;
1087
+ } catch (e) {
1088
+ console.warn(
1089
+ `Failed to fetch metadata for ${attachment.value}:`,
1090
+ e
1091
+ );
1092
+ }
1093
+ }
1094
+
1095
+ 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
+ });
1106
+ }
1107
+ } else if (mimetype === 'application/pdf') {
1108
+ const data = await readBinaryAttachment(
1109
+ attachment,
1110
+ documentManager
888
1111
  );
1112
+ if (data) {
1113
+ binaryParts.push({
1114
+ type: 'file',
1115
+ data,
1116
+ mediaType: mimetype,
1117
+ filename: PathExt.basename(attachment.value)
1118
+ });
1119
+ }
1120
+ } else {
1121
+ const fileContent = await readFileAttachment(
1122
+ attachment,
1123
+ documentManager
1124
+ );
1125
+ if (fileContent) {
1126
+ const language =
1127
+ fileExtension === '.ipynb' ||
1128
+ mimetype === 'application/x-ipynb+json'
1129
+ ? 'json'
1130
+ : '';
1131
+ textContents.push(
1132
+ `**File: ${attachment.value}**\n\`\`\`${language}\n${fileContent}\n\`\`\``
1133
+ );
1134
+ }
889
1135
  }
890
1136
  }
891
1137
  } catch (error) {
892
1138
  console.warn(`Failed to read attachment ${attachment.value}:`, error);
893
- contents.push(`**File: ${attachment.value}** (Could not read file)`);
1139
+ textContents.push(
1140
+ `**File: ${attachment.value}** (Could not read file)`
1141
+ );
894
1142
  }
895
1143
  }
896
1144
 
897
- return contents;
1145
+ return { textContents, binaryParts };
1146
+ }
1147
+
1148
+ /**
1149
+ * Reads a binary attachment and returns its base64-encoded content.
1150
+ * @param attachment The attachment to read
1151
+ * @param documentManager Optional document manager for file operations
1152
+ * @returns Base64 string or null if unable to read
1153
+ */
1154
+ export async function readBinaryAttachment(
1155
+ attachment: IAttachment,
1156
+ documentManager: IDocumentManager | null | undefined
1157
+ ): Promise<string | null> {
1158
+ if (!documentManager) {
1159
+ return null;
1160
+ }
1161
+
1162
+ try {
1163
+ const diskModel = await documentManager.services.contents.get(
1164
+ attachment.value,
1165
+ { content: true }
1166
+ );
1167
+ if (diskModel?.content && diskModel.format === 'base64') {
1168
+ // Strip whitespace/newlines
1169
+ return (diskModel.content as string).replace(/\s/g, '');
1170
+ }
1171
+ return null;
1172
+ } catch (error) {
1173
+ console.warn(
1174
+ `Failed to read binary attachment ${attachment.value}:`,
1175
+ error
1176
+ );
1177
+ return null;
1178
+ }
898
1179
  }
899
1180
 
900
1181
  /**
901
1182
  * Reads the content of a notebook cell.
902
1183
  * @param attachment The notebook attachment to read
1184
+ * @param documentManager Optional document manager for file operations
903
1185
  * @returns Cell content as string or null if unable to read
904
1186
  */
905
- private async _readNotebookCells(
906
- attachment: IAttachment
1187
+ export async function readNotebookCells(
1188
+ attachment: IAttachment,
1189
+ documentManager: IDocumentManager | null | undefined
907
1190
  ): Promise<string | null> {
908
- if (attachment.type !== 'notebook' || !attachment.cells) {
1191
+ if (
1192
+ attachment.type !== 'notebook' ||
1193
+ !attachment.cells ||
1194
+ !documentManager
1195
+ ) {
909
1196
  return null;
910
1197
  }
911
1198
 
912
1199
  try {
913
1200
  // Try reading from live notebook if open
914
- const widget = this.input.documentManager?.findWidget(
915
- attachment.value
916
- ) as IDocumentWidget<Notebook, INotebookModel> | undefined;
1201
+ const widget = documentManager.findWidget(attachment.value) as
1202
+ | IDocumentWidget<Notebook, INotebookModel>
1203
+ | undefined;
917
1204
  let cellData: nbformat.ICell[];
918
1205
  let kernelLang = 'text';
919
1206
 
@@ -932,7 +1219,7 @@ export class AIChatModel extends AbstractChatModel {
932
1219
  kernelLang = String(lang);
933
1220
  } else {
934
1221
  // Fallback: reading from disk
935
- const model = await this.input.documentManager?.services.contents.get(
1222
+ const model = await documentManager.services.contents.get(
936
1223
  attachment.value
937
1224
  );
938
1225
  if (!model || model.type !== 'notebook') {
@@ -1076,21 +1363,26 @@ export class AIChatModel extends AbstractChatModel {
1076
1363
  /**
1077
1364
  * Reads the content of a file attachment.
1078
1365
  * @param attachment The file attachment to read
1366
+ * @param documentManager Optional document manager for file operations
1079
1367
  * @returns File content as string or null if unable to read
1080
1368
  */
1081
- private async _readFileAttachment(
1082
- attachment: IAttachment
1369
+ export async function readFileAttachment(
1370
+ attachment: IAttachment,
1371
+ documentManager: IDocumentManager | null | undefined
1083
1372
  ): Promise<string | null> {
1084
1373
  // Handle both 'file' and 'notebook' types since both have a 'value' path
1085
- if (attachment.type !== 'file' && attachment.type !== 'notebook') {
1374
+ if (
1375
+ (attachment.type !== 'file' && attachment.type !== 'notebook') ||
1376
+ !documentManager
1377
+ ) {
1086
1378
  return null;
1087
1379
  }
1088
1380
 
1089
1381
  try {
1090
1382
  // Try reading from an open widget first
1091
- const widget = this.input.documentManager?.findWidget(
1092
- attachment.value
1093
- ) as IDocumentWidget<Notebook, INotebookModel> | undefined;
1383
+ const widget = documentManager.findWidget(attachment.value) as
1384
+ | IDocumentWidget<Notebook, INotebookModel>
1385
+ | undefined;
1094
1386
 
1095
1387
  if (widget && widget.context && widget.context.model) {
1096
1388
  const model = widget.context.model;
@@ -1105,7 +1397,7 @@ export class AIChatModel extends AbstractChatModel {
1105
1397
  }
1106
1398
 
1107
1399
  // If not open, load from disk
1108
- const diskModel = await this.input.documentManager?.services.contents.get(
1400
+ const diskModel = await documentManager.services.contents.get(
1109
1401
  attachment.value
1110
1402
  );
1111
1403
 
@@ -1136,127 +1428,6 @@ export class AIChatModel extends AbstractChatModel {
1136
1428
  return null;
1137
1429
  }
1138
1430
  }
1139
-
1140
- // Private fields
1141
- private _settingsModel: IAISettingsModel;
1142
- private _user: IUser;
1143
- private _toolContexts: Map<string, IToolExecutionContext> = new Map();
1144
- private _agentManager: IAgentManager;
1145
- private _currentStreamingMessage: IMessage | null = null;
1146
- private _nameChanged = new Signal<AIChatModel, string>(this);
1147
- private _contentsManager?: Contents.IManager;
1148
- private _autosave: boolean = false;
1149
- private _autosaveChanged = new Signal<AIChatModel, boolean>(this);
1150
- private _autosaveDebouncer: Debouncer;
1151
- }
1152
-
1153
- namespace Private {
1154
- type IMimeBody = Partial<IRenderMime.IMimeModel> &
1155
- Pick<IRenderMime.IMimeModel, 'data'>;
1156
- type IDisplayOutput =
1157
- | nbformat.IDisplayData
1158
- | nbformat.IDisplayUpdate
1159
- | nbformat.IExecuteResult;
1160
-
1161
- const isPlainObject = (value: unknown): value is Record<string, unknown> => {
1162
- return typeof value === 'object' && value !== null && !Array.isArray(value);
1163
- };
1164
-
1165
- const isDisplayOutput = (value: unknown): value is IDisplayOutput => {
1166
- if (!isPlainObject(value)) {
1167
- return false;
1168
- }
1169
-
1170
- const output = value as nbformat.IOutput;
1171
- return (
1172
- nbformat.isDisplayData(output) ||
1173
- nbformat.isDisplayUpdate(output) ||
1174
- nbformat.isExecuteResult(output)
1175
- );
1176
- };
1177
-
1178
- const toMimeBundle = (
1179
- value: IDisplayOutput,
1180
- trustedMimeTypes: ReadonlySet<string>
1181
- ): IMimeBody | null => {
1182
- const data = value.data;
1183
- if (!isPlainObject(data) || Object.keys(data).length === 0) {
1184
- return null;
1185
- }
1186
-
1187
- return {
1188
- data: data as IRenderMime.IMimeModel['data'],
1189
- ...(isPlainObject(value.metadata)
1190
- ? { metadata: value.metadata as IRenderMime.IMimeModel['metadata'] }
1191
- : {}),
1192
- // MIME auto-rendering only runs for explicitly configured command IDs.
1193
- // Trust handling is configurable to keep risky MIME execution opt-in.
1194
- ...(Object.keys(data).some(m => trustedMimeTypes.has(m))
1195
- ? { trusted: true }
1196
- : {})
1197
- };
1198
- };
1199
-
1200
- /**
1201
- * Normalize arbitrary tool payloads into canonical display outputs.
1202
- *
1203
- * Tool outputs are not guaranteed to be raw Jupyter IOPub messages; they are
1204
- * often wrapped objects (for example `{ success, result: { outputs: [...] } }`).
1205
- */
1206
- const toDisplayOutputs = (value: unknown): IDisplayOutput[] => {
1207
- if (isDisplayOutput(value)) {
1208
- return [value];
1209
- }
1210
-
1211
- if (Array.isArray(value)) {
1212
- return value.filter(isDisplayOutput);
1213
- }
1214
-
1215
- if (!isPlainObject(value)) {
1216
- return [];
1217
- }
1218
-
1219
- if (Array.isArray(value.outputs)) {
1220
- return value.outputs.filter(isDisplayOutput);
1221
- }
1222
-
1223
- if ('result' in value) {
1224
- return toDisplayOutputs(value.result);
1225
- }
1226
-
1227
- return [];
1228
- };
1229
-
1230
- /**
1231
- * Extract rendermime-ready mime bundles from arbitrary tool results.
1232
- */
1233
- export function extractMimeBundlesFromUnknown(
1234
- content: unknown,
1235
- options: { trustedMimeTypes?: ReadonlyArray<string> } = {}
1236
- ): IMimeBody[] {
1237
- const bundles: IMimeBody[] = [];
1238
- const outputs = toDisplayOutputs(content);
1239
- const trustedMimeTypes = new Set(options.trustedMimeTypes ?? []);
1240
- for (const output of outputs) {
1241
- const bundle = toMimeBundle(output, trustedMimeTypes);
1242
- if (bundle) {
1243
- bundles.push(bundle);
1244
- }
1245
- }
1246
- return bundles;
1247
- }
1248
-
1249
- export function formatToolOutput(outputData: unknown): string {
1250
- if (typeof outputData === 'string') {
1251
- return outputData;
1252
- }
1253
-
1254
- try {
1255
- return JSON.stringify(outputData, null, 2);
1256
- } catch {
1257
- return '[Complex object - cannot serialize]';
1258
- }
1259
- }
1260
1431
  }
1261
1432
 
1262
1433
  /**
@@ -1308,7 +1479,7 @@ export namespace AIChatModel {
1308
1479
  /**
1309
1480
  * The clear messages callback.
1310
1481
  */
1311
- clearMessages: () => void;
1482
+ clearMessages: () => Promise<void>;
1312
1483
  /**
1313
1484
  * Adds an assistant/system message to the chat.
1314
1485
  */
@@ -1347,6 +1518,10 @@ export namespace AIChatModel {
1347
1518
  * Whether the chat is automatically saved.
1348
1519
  */
1349
1520
  autosave?: boolean;
1521
+ /**
1522
+ * An optional title of the chat.
1523
+ */
1524
+ title?: string;
1350
1525
  };
1351
1526
  };
1352
1527
  }