@jupyterlite/ai 0.12.0 → 0.14.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 (49) hide show
  1. package/lib/agent.d.ts +36 -2
  2. package/lib/agent.js +249 -24
  3. package/lib/{chat-model-registry.d.ts → chat-model-handler.d.ts} +12 -11
  4. package/lib/{chat-model-registry.js → chat-model-handler.js} +6 -40
  5. package/lib/chat-model.d.ts +8 -0
  6. package/lib/chat-model.js +156 -8
  7. package/lib/completion/completion-provider.d.ts +1 -1
  8. package/lib/completion/completion-provider.js +14 -2
  9. package/lib/components/model-select.js +4 -4
  10. package/lib/components/tool-select.d.ts +11 -2
  11. package/lib/components/tool-select.js +77 -18
  12. package/lib/index.d.ts +3 -3
  13. package/lib/index.js +249 -117
  14. package/lib/models/settings-model.d.ts +2 -0
  15. package/lib/models/settings-model.js +2 -0
  16. package/lib/providers/built-in-providers.js +33 -32
  17. package/lib/providers/provider-tools.d.ts +36 -0
  18. package/lib/providers/provider-tools.js +93 -0
  19. package/lib/rendered-message-outputarea.d.ts +24 -0
  20. package/lib/rendered-message-outputarea.js +48 -0
  21. package/lib/tokens.d.ts +65 -7
  22. package/lib/tokens.js +1 -1
  23. package/lib/tools/commands.js +62 -22
  24. package/lib/tools/web.d.ts +8 -0
  25. package/lib/tools/web.js +196 -0
  26. package/lib/widgets/ai-settings.d.ts +4 -9
  27. package/lib/widgets/ai-settings.js +123 -69
  28. package/lib/widgets/main-area-chat.d.ts +6 -0
  29. package/lib/widgets/main-area-chat.js +28 -0
  30. package/lib/widgets/provider-config-dialog.js +211 -11
  31. package/package.json +17 -11
  32. package/schema/settings-model.json +89 -1
  33. package/src/agent.ts +327 -42
  34. package/src/{chat-model-registry.ts → chat-model-handler.ts} +16 -51
  35. package/src/chat-model.ts +223 -14
  36. package/src/completion/completion-provider.ts +26 -12
  37. package/src/components/model-select.tsx +4 -5
  38. package/src/components/tool-select.tsx +110 -7
  39. package/src/index.ts +359 -184
  40. package/src/models/settings-model.ts +6 -0
  41. package/src/providers/built-in-providers.ts +33 -32
  42. package/src/providers/provider-tools.ts +179 -0
  43. package/src/rendered-message-outputarea.ts +62 -0
  44. package/src/tokens.ts +82 -9
  45. package/src/tools/commands.ts +99 -31
  46. package/src/tools/web.ts +238 -0
  47. package/src/widgets/ai-settings.tsx +279 -124
  48. package/src/widgets/main-area-chat.ts +34 -1
  49. package/src/widgets/provider-config-dialog.tsx +504 -11
@@ -0,0 +1,196 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ const DEFAULT_MAX_CONTENT_CHARS = 20000;
4
+ const MAX_ALLOWED_CONTENT_CHARS = 100000;
5
+ const DEFAULT_TIMEOUT_MS = 20000;
6
+ const MAX_TIMEOUT_MS = 120000;
7
+ /**
8
+ * Read response body text with a character cap.
9
+ *
10
+ * Stops early once the cap is reached to avoid buffering arbitrarily large
11
+ * payloads in memory.
12
+ */
13
+ async function readResponseText(response, maxContentChars) {
14
+ if (!response.body) {
15
+ const body = await response.text();
16
+ return {
17
+ content: body.slice(0, maxContentChars),
18
+ isTruncated: body.length > maxContentChars,
19
+ totalChars: body.length,
20
+ totalCharsExact: true
21
+ };
22
+ }
23
+ const reader = response.body.getReader();
24
+ const decoder = new TextDecoder();
25
+ let content = '';
26
+ let totalChars = 0;
27
+ let isTruncated = false;
28
+ let done = false;
29
+ while (!done) {
30
+ const readResult = await reader.read();
31
+ done = readResult.done;
32
+ if (done) {
33
+ continue;
34
+ }
35
+ const chunk = decoder.decode(readResult.value, { stream: true });
36
+ if (!chunk) {
37
+ continue;
38
+ }
39
+ totalChars += chunk.length;
40
+ if (!isTruncated) {
41
+ const remaining = maxContentChars - content.length;
42
+ if (chunk.length <= remaining) {
43
+ content += chunk;
44
+ }
45
+ else {
46
+ content += chunk.slice(0, remaining);
47
+ isTruncated = true;
48
+ }
49
+ }
50
+ if (isTruncated) {
51
+ await reader.cancel();
52
+ return {
53
+ content,
54
+ isTruncated: true,
55
+ totalChars,
56
+ totalCharsExact: false
57
+ };
58
+ }
59
+ }
60
+ const tail = decoder.decode();
61
+ if (tail) {
62
+ totalChars += tail.length;
63
+ const remaining = maxContentChars - content.length;
64
+ if (tail.length <= remaining) {
65
+ content += tail;
66
+ }
67
+ else {
68
+ content += tail.slice(0, remaining);
69
+ isTruncated = true;
70
+ }
71
+ }
72
+ return {
73
+ content,
74
+ isTruncated,
75
+ totalChars,
76
+ totalCharsExact: true
77
+ };
78
+ }
79
+ /**
80
+ * Create a browser-native URL fetch tool.
81
+ *
82
+ * This is best-effort and subject to normal browser constraints (CORS, CSP,
83
+ * mixed content, bot protections).
84
+ */
85
+ export function createBrowserFetchTool() {
86
+ return tool({
87
+ title: 'Browser Fetch',
88
+ description: 'Fetch a URL directly from the browser using HTTP GET for exact URL inspection when CORS/access permits.',
89
+ inputSchema: z.object({
90
+ url: z.string().describe('HTTP(S) URL to fetch'),
91
+ maxContentChars: z
92
+ .number()
93
+ .int()
94
+ .min(1)
95
+ .max(MAX_ALLOWED_CONTENT_CHARS)
96
+ .optional()
97
+ .describe(`Maximum number of response characters to return (default: ${DEFAULT_MAX_CONTENT_CHARS})`),
98
+ timeoutMs: z
99
+ .number()
100
+ .int()
101
+ .min(1000)
102
+ .max(MAX_TIMEOUT_MS)
103
+ .optional()
104
+ .describe(`Timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS}, max: ${MAX_TIMEOUT_MS})`)
105
+ }),
106
+ execute: async (input) => {
107
+ const maxContentChars = Math.min(input.maxContentChars ?? DEFAULT_MAX_CONTENT_CHARS, MAX_ALLOWED_CONTENT_CHARS);
108
+ const timeoutMs = Math.min(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
109
+ let parsedUrl;
110
+ try {
111
+ parsedUrl = new URL(input.url);
112
+ }
113
+ catch {
114
+ return {
115
+ success: false,
116
+ errorType: 'invalid_url',
117
+ error: 'Invalid URL format',
118
+ url: input.url
119
+ };
120
+ }
121
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
122
+ return {
123
+ success: false,
124
+ errorType: 'unsupported_protocol',
125
+ error: 'Only http:// and https:// URLs are supported',
126
+ url: input.url
127
+ };
128
+ }
129
+ const controller = new AbortController();
130
+ const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
131
+ try {
132
+ const response = await fetch(parsedUrl.toString(), {
133
+ method: 'GET',
134
+ credentials: 'omit',
135
+ redirect: 'follow',
136
+ signal: controller.signal,
137
+ headers: {
138
+ Accept: 'text/html,text/plain,application/json,text/markdown,*/*;q=0.8'
139
+ }
140
+ });
141
+ const contentType = response.headers.get('content-type') || '';
142
+ const contentLength = response.headers.get('content-length');
143
+ const body = await readResponseText(response, maxContentChars);
144
+ const success = response.ok;
145
+ return {
146
+ success,
147
+ url: response.url,
148
+ requestedUrl: parsedUrl.toString(),
149
+ status: response.status,
150
+ statusText: response.statusText,
151
+ contentType,
152
+ contentLength,
153
+ ...(success
154
+ ? {}
155
+ : {
156
+ errorType: 'http_error',
157
+ error: `HTTP ${response.status} ${response.statusText}`
158
+ }),
159
+ isTruncated: body.isTruncated,
160
+ returnedChars: body.content.length,
161
+ totalChars: body.totalChars,
162
+ totalCharsExact: body.totalCharsExact,
163
+ content: body.content,
164
+ limitations: 'Browser fetch is subject to CORS, site bot protections, and browser network policy.'
165
+ };
166
+ }
167
+ catch (error) {
168
+ if (error.name === 'AbortError') {
169
+ return {
170
+ success: false,
171
+ errorType: 'timeout',
172
+ error: `Request timed out after ${timeoutMs} ms`,
173
+ url: parsedUrl.toString()
174
+ };
175
+ }
176
+ return {
177
+ success: false,
178
+ errorType: 'network_or_cors',
179
+ error: error instanceof Error && error.message
180
+ ? error.message
181
+ : 'Fetch failed',
182
+ url: parsedUrl.toString(),
183
+ likelyCauses: [
184
+ 'CORS blocked by the target website',
185
+ 'DNS/network resolution failure',
186
+ 'TLS/certificate issue',
187
+ 'Target server rejected browser access'
188
+ ]
189
+ };
190
+ }
191
+ finally {
192
+ clearTimeout(timeoutHandle);
193
+ }
194
+ }
195
+ });
196
+ }
@@ -1,11 +1,10 @@
1
1
  import { IThemeManager } from '@jupyterlab/apputils';
2
2
  import { ReactWidget } from '@jupyterlab/ui-components';
3
3
  import type { TranslationBundle } from '@jupyterlab/translation';
4
- import { ISecretsManager } from 'jupyter-secrets-manager';
5
4
  import React from 'react';
6
5
  import { AgentManagerFactory } from '../agent';
7
6
  import { AISettingsModel } from '../models/settings-model';
8
- import { type IProviderRegistry } from '../tokens';
7
+ import { type IAISecretsAccess, type IProviderRegistry } from '../tokens';
9
8
  /**
10
9
  * A JupyterLab widget for AI settings configuration
11
10
  */
@@ -24,7 +23,7 @@ export declare class AISettingsWidget extends ReactWidget {
24
23
  private _agentManagerFactory?;
25
24
  private _themeManager?;
26
25
  private _providerRegistry;
27
- private _secretsManager?;
26
+ private _secretsAccess?;
28
27
  private _trans;
29
28
  }
30
29
  /**
@@ -40,13 +39,9 @@ export declare namespace AISettingsWidget {
40
39
  themeManager?: IThemeManager;
41
40
  providerRegistry: IProviderRegistry;
42
41
  /**
43
- * The secrets manager.
42
+ * Access to provider secrets in the shared namespace.
44
43
  */
45
- secretsManager?: ISecretsManager;
46
- /**
47
- * The token used to request the secrets manager.
48
- */
49
- token: symbol;
44
+ secretsAccess: IAISecretsAccess;
50
45
  /**
51
46
  * The application language translation bundle.
52
47
  */
@@ -11,9 +11,9 @@ import ErrorOutline from '@mui/icons-material/ErrorOutline';
11
11
  import InfoOutlined from '@mui/icons-material/InfoOutlined';
12
12
  import MoreVert from '@mui/icons-material/MoreVert';
13
13
  import Settings from '@mui/icons-material/Settings';
14
- import { Alert, Box, Button, Card, CardContent, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, FormControlLabel, IconButton, InputLabel, List, ListItem, ListItemSecondaryAction, ListItemText, Menu, MenuItem, Select, Switch, Tab, Tabs, TextField, ThemeProvider, Tooltip, Typography, createTheme } from '@mui/material';
14
+ import { Alert, Box, Button, Card, CardContent, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, FormControlLabel, IconButton, InputLabel, List, ListItem, ListItemText, Menu, MenuItem, Select, Switch, Tab, Tabs, TextField, ThemeProvider, Tooltip, Typography, createTheme } from '@mui/material';
15
15
  import React, { useEffect, useMemo, useState } from 'react';
16
- import { SECRETS_NAMESPACE, SECRETS_REPLACEMENT } from '../tokens';
16
+ import { SECRETS_REPLACEMENT } from '../tokens';
17
17
  import { ProviderConfigDialog } from './provider-config-dialog';
18
18
  /**
19
19
  * Create a theme that uses IThemeManager to detect theme
@@ -41,30 +41,34 @@ export class AISettingsWidget extends ReactWidget {
41
41
  */
42
42
  constructor(options) {
43
43
  super();
44
- Private.setToken(options.token);
45
44
  this._settingsModel = options.settingsModel;
46
45
  this._agentManagerFactory = options.agentManagerFactory;
47
46
  this._themeManager = options.themeManager;
48
47
  this._providerRegistry = options.providerRegistry;
49
- this._secretsManager = options.secretsManager;
48
+ this._secretsAccess = options.secretsAccess;
50
49
  this._trans = options.trans;
51
50
  this.id = 'jupyterlite-ai-settings';
52
51
  this.title.label = this._trans.__('AI Settings');
53
52
  this.title.caption = this._trans.__('Configure AI providers and behavior');
54
53
  this.title.closable = true;
54
+ // Disable the secrets manager if the token is empty.
55
+ if (!options.secretsAccess.isAvailable) {
56
+ this._settingsModel.updateConfig({ useSecretsManager: false });
57
+ this._secretsAccess = undefined;
58
+ }
55
59
  }
56
60
  /**
57
61
  * Render the AI settings component
58
62
  * @returns A React element containing the AI settings interface
59
63
  */
60
64
  render() {
61
- return (React.createElement(AISettingsComponent, { model: this._settingsModel, agentManagerFactory: this._agentManagerFactory, themeManager: this._themeManager, providerRegistry: this._providerRegistry, secretsManager: this._secretsManager, trans: this._trans }));
65
+ return (React.createElement(AISettingsComponent, { model: this._settingsModel, agentManagerFactory: this._agentManagerFactory, themeManager: this._themeManager, providerRegistry: this._providerRegistry, secretsAccess: this._secretsAccess, trans: this._trans }));
62
66
  }
63
67
  _settingsModel;
64
68
  _agentManagerFactory;
65
69
  _themeManager;
66
70
  _providerRegistry;
67
- _secretsManager;
71
+ _secretsAccess;
68
72
  _trans;
69
73
  }
70
74
  /**
@@ -72,7 +76,7 @@ export class AISettingsWidget extends ReactWidget {
72
76
  * @param props - Component props containing models and theme manager
73
77
  * @returns A React component for AI settings configuration
74
78
  */
75
- const AISettingsComponent = ({ model, agentManagerFactory, themeManager, providerRegistry, secretsManager, trans }) => {
79
+ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, providerRegistry, secretsAccess, trans }) => {
76
80
  if (!model) {
77
81
  return React.createElement("div", null, trans.__('Settings model not available'));
78
82
  }
@@ -169,15 +173,10 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
169
173
  void promptDebouncer.invoke();
170
174
  };
171
175
  const getSecretFromManager = async (provider, fieldName) => {
172
- const secret = await secretsManager?.get(Private.getToken(), SECRETS_NAMESPACE, `${provider}:${fieldName}`);
173
- return secret?.value;
176
+ return secretsAccess?.get(`${provider}:${fieldName}`);
174
177
  };
175
178
  const setSecretToManager = async (provider, fieldName, value) => {
176
- await secretsManager?.set(Private.getToken(), SECRETS_NAMESPACE, `${provider}:${fieldName}`, {
177
- namespace: SECRETS_NAMESPACE,
178
- id: `${provider}:${fieldName}`,
179
- value
180
- });
179
+ await secretsAccess?.set(`${provider}:${fieldName}`, value);
181
180
  };
182
181
  /**
183
182
  * Attach a secrets field to the secrets manager.
@@ -186,10 +185,10 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
186
185
  * @param fieldName - the name of the field.
187
186
  */
188
187
  const handleSecretField = async (input, provider, fieldName) => {
189
- if (!(model.config.useSecretsManager && secretsManager)) {
188
+ if (!(model.config.useSecretsManager && secretsAccess?.isAvailable)) {
190
189
  return;
191
190
  }
192
- await secretsManager?.attach(Private.getToken(), SECRETS_NAMESPACE, `${provider}:${fieldName}`, input);
191
+ await secretsAccess.attach(`${provider}:${fieldName}`, input);
193
192
  };
194
193
  /**
195
194
  * Handle adding a new AI provider
@@ -197,7 +196,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
197
196
  */
198
197
  const handleAddProvider = async (providerConfig) => {
199
198
  if (model.config.useSecretsManager &&
200
- secretsManager &&
199
+ secretsAccess?.isAvailable &&
201
200
  providerConfig.apiKey) {
202
201
  providerConfig.apiKey = SECRETS_REPLACEMENT;
203
202
  }
@@ -210,7 +209,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
210
209
  const handleEditProvider = async (providerConfig) => {
211
210
  if (editingProvider) {
212
211
  if (model.config.useSecretsManager &&
213
- secretsManager &&
212
+ secretsAccess?.isAvailable &&
214
213
  providerConfig.apiKey) {
215
214
  providerConfig.apiKey = SECRETS_REPLACEMENT;
216
215
  }
@@ -232,7 +231,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
232
231
  */
233
232
  const openEditDialog = async (provider) => {
234
233
  // Retrieve the API key from the secrets manager if necessary.
235
- if (model.config.useSecretsManager && secretsManager) {
234
+ if (model.config.useSecretsManager && secretsAccess?.isAvailable) {
236
235
  provider.apiKey =
237
236
  (await getSecretFromManager(provider.provider, 'apiKey')) ?? '';
238
237
  }
@@ -271,22 +270,29 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
271
270
  if (updates.useSecretsManager !== undefined) {
272
271
  if (updates.useSecretsManager) {
273
272
  for (const provider of model.config.providers) {
274
- // if the secrets manager doesn't have the current API key, copy the current
273
+ const settingsApiKey = provider.apiKey;
274
+ // If the secrets manager doesn't have the current API key, set the current
275
275
  // one from settings.
276
+ // Update the settings value with SECRETS_REPLACEMENT if a key exist in the
277
+ // secrets manager (was already there or a value was set in settings).
276
278
  if (!(await getSecretFromManager(provider.provider, 'apiKey'))) {
277
- setSecretToManager(provider.provider, 'apiKey', provider.apiKey ?? '');
279
+ if (settingsApiKey !== undefined) {
280
+ setSecretToManager(provider.provider, 'apiKey', settingsApiKey !== SECRETS_REPLACEMENT ? settingsApiKey : '');
281
+ provider.apiKey = SECRETS_REPLACEMENT;
282
+ await model.updateProvider(provider.id, provider);
283
+ }
284
+ }
285
+ else {
286
+ provider.apiKey = SECRETS_REPLACEMENT;
287
+ await model.updateProvider(provider.id, provider);
278
288
  }
279
- provider.apiKey = SECRETS_REPLACEMENT;
280
- await model.updateProvider(provider.id, provider);
281
289
  }
282
290
  }
283
291
  else {
284
292
  for (const provider of model.config.providers) {
285
293
  const apiKey = await getSecretFromManager(provider.provider, 'apiKey');
286
- if (apiKey) {
287
- provider.apiKey = apiKey;
288
- await model.updateProvider(provider.id, provider);
289
- }
294
+ provider.apiKey = apiKey;
295
+ await model.updateProvider(provider.id, provider);
290
296
  }
291
297
  }
292
298
  }
@@ -400,7 +406,13 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
400
406
  const isActiveCompleter = config.useSameProviderForChatAndCompleter
401
407
  ? isActive
402
408
  : config.activeCompleterProvider === provider.id;
409
+ const providerInfo = providerRegistry.getProviderInfo(provider.provider);
410
+ const providerToolCapabilities = providerInfo?.providerToolCapabilities;
403
411
  const params = provider.parameters;
412
+ const webSearchEnabled = !!providerToolCapabilities?.webSearch &&
413
+ provider.customSettings?.webSearch?.enabled === true;
414
+ const webFetchEnabled = !!providerToolCapabilities?.webFetch &&
415
+ provider.customSettings?.webFetch?.enabled === true;
404
416
  return (React.createElement(ListItem, { key: provider.id, sx: {
405
417
  flexDirection: 'column',
406
418
  alignItems: 'stretch',
@@ -429,22 +441,25 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
429
441
  provider.model,
430
442
  provider.description &&
431
443
  ` • ${provider.description}`),
432
- params &&
433
- (params.temperature !== undefined ||
434
- params.maxOutputTokens !== undefined ||
435
- params.maxTurns !== undefined) && (React.createElement(Box, { sx: {
444
+ (params?.temperature !== undefined ||
445
+ params?.maxOutputTokens !== undefined ||
446
+ params?.maxTurns !== undefined ||
447
+ webSearchEnabled ||
448
+ webFetchEnabled) && (React.createElement(Box, { sx: {
436
449
  display: 'flex',
437
450
  flexWrap: 'wrap',
438
451
  gap: 1,
439
452
  mt: 1
440
453
  } },
441
- params.temperature !== undefined && (React.createElement(Chip, { label: trans.__('Temp: %1', params.temperature), size: "small", variant: "outlined" })),
442
- params.maxOutputTokens !== undefined && (React.createElement(Chip, { label: trans.__('Tokens: %1', params.maxOutputTokens), size: "small", variant: "outlined" })),
443
- params.maxTurns !== undefined && (React.createElement(Chip, { label: trans.__('Turns: %1', params.maxTurns), size: "small", variant: "outlined" }))))),
454
+ params?.temperature !== undefined && (React.createElement(Chip, { label: trans.__('Temp: %1', params.temperature), size: "small", variant: "outlined" })),
455
+ params?.maxOutputTokens !== undefined && (React.createElement(Chip, { label: trans.__('Tokens: %1', params.maxOutputTokens), size: "small", variant: "outlined" })),
456
+ params?.maxTurns !== undefined && (React.createElement(Chip, { label: trans.__('Turns: %1', params.maxTurns), size: "small", variant: "outlined" })),
457
+ webSearchEnabled && (React.createElement(Chip, { label: trans.__('Web Search'), size: "small", variant: "outlined", color: "info" })),
458
+ webFetchEnabled && (React.createElement(Chip, { label: trans.__('Web Fetch'), size: "small", variant: "outlined", color: "info" }))))),
444
459
  React.createElement(IconButton, { onClick: e => handleMenuClick(e, provider.id), size: "small" },
445
460
  React.createElement(MoreVert, null)))));
446
461
  }))))),
447
- secretsManager !== undefined && (React.createElement(FormControlLabel, { control: React.createElement(Switch, { checked: config.useSecretsManager, onChange: e => handleConfigUpdate({
462
+ secretsAccess?.isAvailable && (React.createElement(FormControlLabel, { control: React.createElement(Switch, { checked: config.useSecretsManager, onChange: e => handleConfigUpdate({
448
463
  useSecretsManager: e.target.checked
449
464
  }), color: "primary", sx: { alignSelf: 'flex-start' } }), label: React.createElement("div", null,
450
465
  React.createElement("span", null, trans.__('Use the secrets manager to manage API keys')),
@@ -523,19 +538,17 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
523
538
  React.createElement(Box, null,
524
539
  React.createElement(Typography, { variant: "body1", gutterBottom: true }, trans.__('Commands Requiring Approval')),
525
540
  React.createElement(Typography, { variant: "caption", color: "text.secondary", gutterBottom: true, sx: { display: 'block' } }, trans.__('Commands that require user approval before AI can execute them')),
526
- React.createElement(List, { sx: { mb: 2, maxHeight: 200, overflow: 'auto' } }, config.commandsRequiringApproval.map((command, index) => (React.createElement(ListItem, { key: index, divider: true },
527
- React.createElement(ListItemText, { primary: command }),
528
- React.createElement(ListItemSecondaryAction, null,
529
- React.createElement(IconButton, { onClick: () => {
530
- const newCommands = [
531
- ...config.commandsRequiringApproval
532
- ];
533
- newCommands.splice(index, 1);
534
- handleConfigUpdate({
535
- commandsRequiringApproval: newCommands
536
- });
537
- }, size: "small" },
538
- React.createElement(Delete, null))))))),
541
+ React.createElement(List, { sx: { mb: 2, maxHeight: 200, overflow: 'auto' } }, config.commandsRequiringApproval.map((command, index) => (React.createElement(ListItem, { key: index, divider: true, secondaryAction: React.createElement(IconButton, { onClick: () => {
542
+ const newCommands = [
543
+ ...config.commandsRequiringApproval
544
+ ];
545
+ newCommands.splice(index, 1);
546
+ handleConfigUpdate({
547
+ commandsRequiringApproval: newCommands
548
+ });
549
+ }, size: "small" },
550
+ React.createElement(Delete, null)) },
551
+ React.createElement(ListItemText, { primary: command }))))),
539
552
  React.createElement(TextField, { fullWidth: true, label: trans.__('Add New Command'), placeholder: trans.__('e.g., notebook:run-cell'), onKeyDown: e => {
540
553
  if (e.key === 'Enter') {
541
554
  const value = e.target.value.trim();
@@ -551,7 +564,65 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
551
564
  e.target.value = '';
552
565
  }
553
566
  }
554
- }, helperText: trans.__('Press Enter to add a command. Common commands: notebook:run-cell, console:execute, fileeditor:run-code') })))))),
567
+ }, helperText: trans.__('Press Enter to add a command. Common commands: notebook:run-cell, console:execute, fileeditor:run-code') })),
568
+ React.createElement(Divider, { sx: { my: 2 } }),
569
+ React.createElement(Box, null,
570
+ React.createElement(Typography, { variant: "body1", gutterBottom: true }, trans.__('Commands Auto-Rendering MIME Bundles')),
571
+ React.createElement(Typography, { variant: "caption", color: "text.secondary", gutterBottom: true, sx: { display: 'block' } }, trans.__('Only these execute_command command IDs can auto-render MIME bundle outputs in chat')),
572
+ React.createElement(List, { sx: { mb: 2, maxHeight: 200, overflow: 'auto' } }, (config.commandsAutoRenderMimeBundles ?? []).map((command, index) => (React.createElement(ListItem, { key: index, divider: true, secondaryAction: React.createElement(IconButton, { onClick: () => {
573
+ const newCommands = [
574
+ ...(config.commandsAutoRenderMimeBundles ??
575
+ [])
576
+ ];
577
+ newCommands.splice(index, 1);
578
+ handleConfigUpdate({
579
+ commandsAutoRenderMimeBundles: newCommands
580
+ });
581
+ }, size: "small" },
582
+ React.createElement(Delete, null)) },
583
+ React.createElement(ListItemText, { primary: command }))))),
584
+ React.createElement(TextField, { fullWidth: true, label: trans.__('Add Auto-Render Command'), placeholder: trans.__('e.g., jupyterlab-ai-commands:execute-in-kernel'), onKeyDown: e => {
585
+ if (e.key === 'Enter') {
586
+ const value = e.target.value.trim();
587
+ const existingCommands = config.commandsAutoRenderMimeBundles ?? [];
588
+ if (value && !existingCommands.includes(value)) {
589
+ const newCommands = [...existingCommands, value];
590
+ handleConfigUpdate({
591
+ commandsAutoRenderMimeBundles: newCommands
592
+ });
593
+ e.target.value = '';
594
+ }
595
+ }
596
+ }, helperText: trans.__('Press Enter to add a command. Default: jupyterlab-ai-commands:execute-in-kernel') })),
597
+ React.createElement(Divider, { sx: { my: 2 } }),
598
+ React.createElement(Box, null,
599
+ React.createElement(Typography, { variant: "body1", gutterBottom: true }, trans.__('Trusted MIME Types for Auto-Render')),
600
+ React.createElement(Typography, { variant: "caption", color: "text.secondary", gutterBottom: true, sx: { display: 'block' } }, trans.__('When auto-rendering command outputs, these MIME types are marked trusted in chat')),
601
+ React.createElement(List, { sx: { mb: 2, maxHeight: 200, overflow: 'auto' } }, (config.trustedMimeTypesForAutoRender ?? []).map((mimeType, index) => (React.createElement(ListItem, { key: index, divider: true, secondaryAction: React.createElement(IconButton, { onClick: () => {
602
+ const newMimeTypes = [
603
+ ...(config.trustedMimeTypesForAutoRender ??
604
+ [])
605
+ ];
606
+ newMimeTypes.splice(index, 1);
607
+ handleConfigUpdate({
608
+ trustedMimeTypesForAutoRender: newMimeTypes
609
+ });
610
+ }, size: "small" },
611
+ React.createElement(Delete, null)) },
612
+ React.createElement(ListItemText, { primary: mimeType }))))),
613
+ React.createElement(TextField, { fullWidth: true, label: trans.__('Add Trusted MIME Type'), placeholder: trans.__('e.g., text/html'), onKeyDown: e => {
614
+ if (e.key === 'Enter') {
615
+ const value = e.target.value.trim();
616
+ const existingMimeTypes = config.trustedMimeTypesForAutoRender ?? [];
617
+ if (value && !existingMimeTypes.includes(value)) {
618
+ const newMimeTypes = [...existingMimeTypes, value];
619
+ handleConfigUpdate({
620
+ trustedMimeTypesForAutoRender: newMimeTypes
621
+ });
622
+ e.target.value = '';
623
+ }
624
+ }
625
+ }, helperText: trans.__('Press Enter to add a MIME type. Default: text/html') })))))),
555
626
  activeTab === 2 && (React.createElement(Card, { elevation: 2 },
556
627
  React.createElement(CardContent, null,
557
628
  React.createElement(Box, { sx: {
@@ -565,7 +636,8 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
565
636
  React.createElement(Typography, { variant: "h6", component: "h2" }, trans.__('Remote MCP Servers'))),
566
637
  React.createElement(Button, { variant: "contained", startIcon: React.createElement(Add, null), onClick: openAddMCPDialog, size: "small" }, trans.__('Add Server'))),
567
638
  React.createElement(Typography, { variant: "body2", color: "text.secondary", sx: { mb: 2 } }, trans.__("Configure remote Model Context Protocol (MCP) servers to extend the AI's capabilities with external tools and data sources.")),
568
- config.mcpServers.length === 0 ? (React.createElement(Alert, { severity: "info" }, trans.__('No MCP servers configured yet. Click "Add Server" to connect to remote MCP services.'))) : (React.createElement(List, null, config.mcpServers.map(server => (React.createElement(ListItem, { key: server.id, divider: true },
639
+ config.mcpServers.length === 0 ? (React.createElement(Alert, { severity: "info" }, trans.__('No MCP servers configured yet. Click "Add Server" to connect to remote MCP services.'))) : (React.createElement(List, null, config.mcpServers.map(server => (React.createElement(ListItem, { key: server.id, divider: true, secondaryAction: React.createElement(IconButton, { onClick: e => handleMCPMenuClick(e, server.id), size: "small" },
640
+ React.createElement(MoreVert, null)) },
569
641
  React.createElement(ListItemText, { primary: React.createElement(Box, { sx: {
570
642
  display: 'flex',
571
643
  alignItems: 'center',
@@ -582,10 +654,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
582
654
  React.createElement(Typography, { variant: "body2", color: "text.secondary" }, server.url),
583
655
  server.enabled && agentManagerFactory && (React.createElement(Typography, { variant: "caption", color: "text.secondary" }, trans.__('Status: %1', agentManagerFactory.isMCPServerConnected(server.name)
584
656
  ? trans.__('Connected')
585
- : trans.__('Connection failed'))))) }),
586
- React.createElement(ListItemSecondaryAction, null,
587
- React.createElement(IconButton, { onClick: e => handleMCPMenuClick(e, server.id), size: "small" },
588
- React.createElement(MoreVert, null))))))))))),
657
+ : trans.__('Connection failed'))))) }))))))))),
589
658
  React.createElement(ProviderConfigDialog, { open: dialogOpen, onClose: () => setDialogOpen(false), onSave: editingProvider ? handleEditProvider : handleAddProvider, initialConfig: editingProvider, mode: editingProvider ? 'edit' : 'add', providerRegistry: providerRegistry, handleSecretField: handleSecretField, trans: trans }),
590
659
  React.createElement(Menu, { anchorEl: menuAnchor, open: Boolean(menuAnchor), onClose: handleMenuClose },
591
660
  React.createElement(MenuItem, { onClick: () => {
@@ -674,18 +743,3 @@ const MCPServerDialog = ({ open, onClose, onSave, initialConfig, mode, trans })
674
743
  React.createElement(Button, { onClick: onClose }, trans.__('Cancel')),
675
744
  React.createElement(Button, { onClick: handleSave, variant: "contained", disabled: !canSave }, mode === 'add' ? trans.__('Add') : trans.__('Save')))));
676
745
  };
677
- var Private;
678
- (function (Private) {
679
- /**
680
- * The token to use with the secrets manager, setter and getter.
681
- */
682
- let secretsToken;
683
- function setToken(value) {
684
- secretsToken = value;
685
- }
686
- Private.setToken = setToken;
687
- function getToken() {
688
- return secretsToken;
689
- }
690
- Private.getToken = getToken;
691
- })(Private || (Private = {}));
@@ -21,5 +21,11 @@ export declare class MainAreaChat extends MainAreaWidget<ChatWidget> {
21
21
  * Get the model of the chat.
22
22
  */
23
23
  get model(): AIChatModel;
24
+ /**
25
+ * Get the area of the chat.
26
+ */
27
+ get area(): string | undefined;
28
+ private _writersChanged;
24
29
  private _approvalButtons;
30
+ private _outputAreaCompat;
25
31
  }
@@ -2,6 +2,7 @@ import { CommandToolbarButton, MainAreaWidget } from '@jupyterlab/apputils';
2
2
  import { launchIcon } from '@jupyterlab/ui-components';
3
3
  import { ApprovalButtons } from '../approval-buttons';
4
4
  import { TokenUsageWidget } from '../components/token-usage-display';
5
+ import { RenderedMessageOutputAreaCompat } from '../rendered-message-outputarea';
5
6
  import { CommandIds } from '../tokens';
6
7
  /**
7
8
  * The chat as a main area widget.
@@ -34,11 +35,19 @@ export class MainAreaChat extends MainAreaWidget {
34
35
  chatPanel: this.content,
35
36
  agentManager: this.model.agentManager
36
37
  });
38
+ // Temporary compat: keep output-area CSS context for MIME renderers
39
+ // until jupyter-chat provides it natively.
40
+ this._outputAreaCompat = new RenderedMessageOutputAreaCompat({
41
+ chatPanel: this.content
42
+ });
43
+ this.model.writersChanged.connect(this._writersChanged);
37
44
  }
38
45
  dispose() {
39
46
  super.dispose();
40
47
  // Dispose of the approval buttons widget when the chat is disposed.
41
48
  this._approvalButtons.dispose();
49
+ this._outputAreaCompat.dispose();
50
+ this.model.writersChanged.disconnect(this._writersChanged);
42
51
  }
43
52
  /**
44
53
  * Get the model of the chat.
@@ -46,5 +55,24 @@ export class MainAreaChat extends MainAreaWidget {
46
55
  get model() {
47
56
  return this.content.model;
48
57
  }
58
+ /**
59
+ * Get the area of the chat.
60
+ */
61
+ get area() {
62
+ return this.content.area;
63
+ }
64
+ _writersChanged = (_, writers) => {
65
+ // Check if AI is currently writing (streaming)
66
+ const aiWriting = writers.some(writer => writer.user.username === 'ai-assistant');
67
+ if (aiWriting) {
68
+ this.content.inputToolbarRegistry?.hide('send');
69
+ this.content.inputToolbarRegistry?.show('stop');
70
+ }
71
+ else {
72
+ this.content.inputToolbarRegistry?.hide('stop');
73
+ this.content.inputToolbarRegistry?.show('send');
74
+ }
75
+ };
49
76
  _approvalButtons;
77
+ _outputAreaCompat;
50
78
  }