@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/lib/tokens.d.ts CHANGED
@@ -4,7 +4,7 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
4
4
  import { Token } from '@lumino/coreutils';
5
5
  import type { IDisposable } from '@lumino/disposable';
6
6
  import { ISignal } from '@lumino/signaling';
7
- import type { Tool, LanguageModel } from 'ai';
7
+ import type { Tool, LanguageModel, UserContent, ModelMessage } from 'ai';
8
8
  import { ISecretsManager } from 'jupyter-secrets-manager';
9
9
  import type { IModelOptions } from './providers/models';
10
10
  import { AIChatModel } from './chat-model';
@@ -17,6 +17,7 @@ export declare namespace CommandIds {
17
17
  const openSettings = "@jupyterlite/ai:open-settings";
18
18
  const reposition = "@jupyterlite/ai:reposition";
19
19
  const openChat = "@jupyterlite/ai:open-chat";
20
+ const openOrRevealChat = "@jupyterlite/ai:open-or-reveal-chat";
20
21
  const moveChat = "@jupyterlite/ai:move-chat";
21
22
  const refreshSkills = "@jupyterlite/ai:refresh-skills";
22
23
  const saveChat = "@jupyterlite/ai:save-chat";
@@ -153,6 +154,12 @@ export interface IProviderToolCapabilities {
153
154
  /**
154
155
  * Provider information
155
156
  */
157
+ export interface IProviderModelInfo {
158
+ /**
159
+ * Default context window for the model in tokens.
160
+ */
161
+ contextWindow?: number;
162
+ }
156
163
  export interface IProviderInfo {
157
164
  /**
158
165
  * Unique identifier for the provider
@@ -173,6 +180,10 @@ export interface IProviderInfo {
173
180
  * Default model names for this provider
174
181
  */
175
182
  defaultModels: string[];
183
+ /**
184
+ * Optional per-model metadata keyed by model ID.
185
+ */
186
+ modelInfo?: Record<string, IProviderModelInfo>;
176
187
  /**
177
188
  * Whether this provider supports custom base URLs
178
189
  */
@@ -246,6 +257,7 @@ export interface IProviderParameters {
246
257
  temperature?: number;
247
258
  maxOutputTokens?: number;
248
259
  maxTurns?: number;
260
+ contextWindow?: number;
249
261
  supportsFillInMiddle?: boolean;
250
262
  useFilterText?: boolean;
251
263
  }
@@ -282,6 +294,7 @@ export interface IAIConfig {
282
294
  toolsEnabled: boolean;
283
295
  sendWithShiftEnter: boolean;
284
296
  showTokenUsage: boolean;
297
+ showContextUsage: boolean;
285
298
  commandsRequiringApproval: string[];
286
299
  commandsAutoRenderMimeBundles: string[];
287
300
  trustedMimeTypesForAutoRender: string[];
@@ -453,7 +466,7 @@ export interface IAgentManager {
453
466
  /**
454
467
  * Clears conversation history and resets agent state.
455
468
  */
456
- clearHistory(): void;
469
+ clearHistory(): Promise<void>;
457
470
  /**
458
471
  * Sets the conversation history with a list of messages from the chat.
459
472
  * @param messages The chat messages to set as history
@@ -480,7 +493,12 @@ export interface IAgentManager {
480
493
  * Handles the complete execution cycle including tool calls.
481
494
  * @param message The user message to respond to (may include processed attachment content)
482
495
  */
483
- generateResponse(message: string): Promise<void>;
496
+ generateResponse(message: UserContent): Promise<void>;
497
+ /**
498
+ * Create a transient language model to request a text response, which won't be added to history.
499
+ * @param messages - the messages sequence to send to the model.
500
+ */
501
+ textResponse(messages: ModelMessage[]): Promise<string>;
484
502
  /**
485
503
  * Initializes the AI agent with current settings and tools.
486
504
  * Sets up the agent with model configuration, tools, and MCP tools.
@@ -549,6 +567,10 @@ export interface ICreateChatOptions {
549
567
  * Whether the chat is autosaved or not.
550
568
  */
551
569
  autosave?: boolean;
570
+ /**
571
+ * An optional title to the chat.
572
+ */
573
+ title?: string | null;
552
574
  }
553
575
  /**
554
576
  * Token for the chat model handler.
@@ -633,6 +655,15 @@ export interface ITokenUsage {
633
655
  * Number of output tokens generated (completion tokens)
634
656
  */
635
657
  outputTokens: number;
658
+ /**
659
+ * Estimated prompt tokens used by the most recent model request.
660
+ * This is based on the final step of the latest request.
661
+ */
662
+ lastRequestInputTokens?: number;
663
+ /**
664
+ * Configured context window size for the active provider/model.
665
+ */
666
+ contextWindow?: number;
636
667
  }
637
668
  /**
638
669
  * The string that replaces a secret key in settings.
package/lib/tokens.js CHANGED
@@ -7,6 +7,7 @@ export var CommandIds;
7
7
  CommandIds.openSettings = '@jupyterlite/ai:open-settings';
8
8
  CommandIds.reposition = '@jupyterlite/ai:reposition';
9
9
  CommandIds.openChat = '@jupyterlite/ai:open-chat';
10
+ CommandIds.openOrRevealChat = '@jupyterlite/ai:open-or-reveal-chat';
10
11
  CommandIds.moveChat = '@jupyterlite/ai:move-chat';
11
12
  CommandIds.refreshSkills = '@jupyterlite/ai:refresh-skills';
12
13
  CommandIds.saveChat = '@jupyterlite/ai:save-chat';
@@ -15,15 +16,15 @@ export var CommandIds;
15
16
  /**
16
17
  * The tool registry token.
17
18
  */
18
- export const IToolRegistry = new Token('@jupyterlite/ai:tool-registry', 'Tool registry for AI agent functionality');
19
+ export const IToolRegistry = new Token('@jupyterlite/ai:IToolRegistry', 'Tool registry for AI agent functionality');
19
20
  /**
20
21
  * The skill registry token.
21
22
  */
22
- export const ISkillRegistry = new Token('@jupyterlite/ai:skill-registry', 'Skill registry for AI agent functionality');
23
+ export const ISkillRegistry = new Token('@jupyterlite/ai:ISkillRegistry', 'Skill registry for AI agent functionality');
23
24
  /**
24
25
  * Token for the provider registry.
25
26
  */
26
- export const IProviderRegistry = new Token('@jupyterlite/ai:provider-registry', 'Registry for AI providers');
27
+ export const IProviderRegistry = new Token('@jupyterlite/ai:IProviderRegistry', 'Registry for AI providers');
27
28
  /**
28
29
  * Token for the AI settings model.
29
30
  */
@@ -31,19 +32,19 @@ export const IAISettingsModel = new Token('@jupyterlite/ai:IAISettingsModel');
31
32
  /**
32
33
  * Token for the agent manager.
33
34
  */
34
- export const IAgentManager = new Token('@jupyterlite/ai:agent-manager');
35
+ export const IAgentManager = new Token('@jupyterlite/ai:IAgentManager');
35
36
  /*
36
37
  * Token for the agent manager factory.
37
38
  */
38
- export const IAgentManagerFactory = new Token('@jupyterlite/ai:agent-manager-factory');
39
+ export const IAgentManagerFactory = new Token('@jupyterlite/ai:IAgentManagerFactory');
39
40
  /**
40
41
  * Token for the chat model handler.
41
42
  */
42
- export const IChatModelHandler = new Token('@jupyterlite/ai:chat-model-handler');
43
+ export const IChatModelHandler = new Token('@jupyterlite/ai:IChatModelHandler');
43
44
  /**
44
45
  * Token for the diff manager.
45
46
  */
46
- export const IDiffManager = new Token('@jupyterlite/ai:diff-manager');
47
+ export const IDiffManager = new Token('@jupyterlite/ai:IDiffManager');
47
48
  /**
48
49
  * The string that replaces a secret key in settings.
49
50
  */
@@ -13,6 +13,7 @@ import MoreVert from '@mui/icons-material/MoreVert';
13
13
  import Settings from '@mui/icons-material/Settings';
14
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 { getEffectiveContextWindow } from '../providers/model-info';
16
17
  import { SECRETS_REPLACEMENT } from '../tokens';
17
18
  import { ProviderConfigDialog } from './provider-config-dialog';
18
19
  /**
@@ -409,6 +410,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
409
410
  const providerInfo = providerRegistry.getProviderInfo(provider.provider);
410
411
  const providerToolCapabilities = providerInfo?.providerToolCapabilities;
411
412
  const params = provider.parameters;
413
+ const effectiveContextWindow = getEffectiveContextWindow(provider, providerRegistry);
412
414
  const webSearchEnabled = !!providerToolCapabilities?.webSearch &&
413
415
  provider.customSettings?.webSearch?.enabled === true;
414
416
  const webFetchEnabled = !!providerToolCapabilities?.webFetch &&
@@ -444,6 +446,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
444
446
  (params?.temperature !== undefined ||
445
447
  params?.maxOutputTokens !== undefined ||
446
448
  params?.maxTurns !== undefined ||
449
+ effectiveContextWindow !== undefined ||
447
450
  webSearchEnabled ||
448
451
  webFetchEnabled) && (React.createElement(Box, { sx: {
449
452
  display: 'flex',
@@ -454,6 +457,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
454
457
  params?.temperature !== undefined && (React.createElement(Chip, { label: trans.__('Temp: %1', params.temperature), size: "small", variant: "outlined" })),
455
458
  params?.maxOutputTokens !== undefined && (React.createElement(Chip, { label: trans.__('Tokens: %1', params.maxOutputTokens), size: "small", variant: "outlined" })),
456
459
  params?.maxTurns !== undefined && (React.createElement(Chip, { label: trans.__('Turns: %1', params.maxTurns), size: "small", variant: "outlined" })),
460
+ effectiveContextWindow !== undefined && (React.createElement(Chip, { label: trans.__('Context: %1', effectiveContextWindow), size: "small", variant: "outlined" })),
457
461
  webSearchEnabled && (React.createElement(Chip, { label: trans.__('Web Search'), size: "small", variant: "outlined", color: "info" })),
458
462
  webFetchEnabled && (React.createElement(Chip, { label: trans.__('Web Fetch'), size: "small", variant: "outlined", color: "info" }))))),
459
463
  React.createElement(IconButton, { onClick: e => handleMenuClick(e, provider.id), size: "small" },
@@ -483,6 +487,11 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
483
487
  }), color: "primary" }), label: React.createElement(Box, null,
484
488
  React.createElement(Typography, { variant: "body1" }, trans.__('Show Token Usage')),
485
489
  React.createElement(Typography, { variant: "caption", color: "text.secondary" }, trans.__('Display token usage information in the chat toolbar'))) }),
490
+ React.createElement(FormControlLabel, { control: React.createElement(Switch, { checked: config.showContextUsage, onChange: e => handleConfigUpdate({
491
+ showContextUsage: e.target.checked
492
+ }), color: "primary" }), label: React.createElement(Box, null,
493
+ React.createElement(Typography, { variant: "body1" }, trans.__('Show Context Usage')),
494
+ React.createElement(Typography, { variant: "caption", color: "text.secondary" }, trans.__('Display estimated context usage in the chat toolbar'))) }),
486
495
  React.createElement(FormControlLabel, { control: React.createElement(Switch, { checked: config.showCellDiff, onChange: e => handleConfigUpdate({
487
496
  showCellDiff: e.target.checked
488
497
  }), color: "primary" }), label: React.createElement(Box, null,
@@ -26,5 +26,6 @@ export declare class MainAreaChat extends MainAreaWidget<ChatWidget> {
26
26
  */
27
27
  get area(): string | undefined;
28
28
  private _writersChanged;
29
+ private _titleChanged;
29
30
  private _outputAreaCompat;
30
31
  }
@@ -1,7 +1,7 @@
1
1
  import { CommandToolbarButton, MainAreaWidget } from '@jupyterlab/apputils';
2
2
  import { launchIcon } from '@jupyterlab/ui-components';
3
3
  import { SaveComponentWidget } from '../components/save-button';
4
- import { TokenUsageWidget } from '../components/token-usage-display';
4
+ import { UsageWidget } from '../components/usage-display';
5
5
  import { RenderedMessageOutputAreaCompat } from '../rendered-message-outputarea';
6
6
  import { CommandIds } from '../tokens';
7
7
  /**
@@ -10,7 +10,8 @@ import { CommandIds } from '../tokens';
10
10
  export class MainAreaChat extends MainAreaWidget {
11
11
  constructor(options) {
12
12
  super(options);
13
- this.title.label = this.content.model.name;
13
+ this.title.label = this.model.name;
14
+ this.title.caption = this.model.title ?? this.model.name;
14
15
  const { trans } = options;
15
16
  // Move to side button.
16
17
  this.toolbar.addItem('moveToSide', new CommandToolbarButton({
@@ -30,25 +31,27 @@ export class MainAreaChat extends MainAreaWidget {
30
31
  }));
31
32
  }
32
33
  // Add the token usage button.
33
- const tokenUsageWidget = new TokenUsageWidget({
34
+ const usageWidget = new UsageWidget({
34
35
  tokenUsageChanged: this.model.tokenUsageChanged,
35
36
  settingsModel: options.settingsModel,
36
37
  initialTokenUsage: this.model.agentManager.tokenUsage,
37
38
  translator: trans
38
39
  });
39
- this.toolbar.addItem('token-usage', tokenUsageWidget);
40
+ this.toolbar.addItem('usage', usageWidget);
40
41
  // Temporary compat: keep output-area CSS context for MIME renderers
41
42
  // until jupyter-chat provides it natively.
42
43
  this._outputAreaCompat = new RenderedMessageOutputAreaCompat({
43
44
  chatPanel: this.content
44
45
  });
45
46
  this.model.writersChanged.connect(this._writersChanged);
47
+ this.model.titleChanged.connect(this._titleChanged);
46
48
  }
47
49
  dispose() {
48
50
  super.dispose();
49
51
  // Dispose of the approval buttons widget when the chat is disposed.
50
52
  this._outputAreaCompat.dispose();
51
53
  this.model.writersChanged.disconnect(this._writersChanged);
54
+ this.model.titleChanged.disconnect(this._titleChanged);
52
55
  }
53
56
  /**
54
57
  * Get the model of the chat.
@@ -74,5 +77,8 @@ export class MainAreaChat extends MainAreaWidget {
74
77
  this.content.inputToolbarRegistry?.show('send');
75
78
  }
76
79
  };
80
+ _titleChanged = () => {
81
+ this.title.caption = this.model.title ?? this.model.name;
82
+ };
77
83
  _outputAreaCompat;
78
84
  }
@@ -4,6 +4,7 @@ import Visibility from '@mui/icons-material/Visibility';
4
4
  import VisibilityOff from '@mui/icons-material/VisibilityOff';
5
5
  import { Accordion, AccordionDetails, AccordionSummary, Autocomplete, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, IconButton, InputAdornment, InputLabel, List, ListItem, ListItemText, MenuItem, Select, Slider, Switch, TextField, Typography } from '@mui/material';
6
6
  import React from 'react';
7
+ import { getProviderModelInfo } from '../providers/model-info';
7
8
  /**
8
9
  * Default parameter values for provider configuration
9
10
  */
@@ -80,6 +81,7 @@ export const ProviderConfigDialog = ({ open, onClose, onSave, initialConfig, mod
80
81
  const [expandedAdvanced, setExpandedAdvanced] = React.useState(false);
81
82
  const selectedProviderInfo = React.useMemo(() => providerRegistry.getProviderInfo(provider), [providerRegistry, provider]);
82
83
  const providerToolCapabilities = selectedProviderInfo?.providerToolCapabilities;
84
+ const selectedModelInfo = React.useMemo(() => getProviderModelInfo(selectedProviderInfo, model), [selectedProviderInfo, model]);
83
85
  const webSearchImplementation = providerToolCapabilities?.webSearch?.implementation;
84
86
  const supportsWebSearch = !!providerToolCapabilities?.webSearch;
85
87
  const supportsWebFetch = !!providerToolCapabilities?.webFetch;
@@ -300,13 +302,24 @@ export const ProviderConfigDialog = ({ open, onClose, onSave, initialConfig, mod
300
302
  maxOutputTokens: e.target.value
301
303
  ? Number(e.target.value)
302
304
  : undefined
303
- }), placeholder: trans.__('Leave empty for provider default'), helperText: trans.__('Maximum length of AI responses'), inputProps: { min: 1 } }),
305
+ }), placeholder: trans.__('Leave empty for provider default'), helperText: trans.__('Maximum length of AI responses'), slotProps: { htmlInput: { min: 1 } } }),
304
306
  React.createElement(TextField, { fullWidth: true, label: trans.__('Max Turns (Optional)'), type: "number", value: parameters.maxTurns ?? '', onChange: e => setParameters({
305
307
  ...parameters,
306
308
  maxTurns: e.target.value
307
309
  ? Number(e.target.value)
308
310
  : undefined
309
- }), placeholder: trans.__('Default: %1', DEFAULT_MAX_TURNS), helperText: trans.__('Maximum number of tool execution turns'), inputProps: { min: 1, max: 100 } }),
311
+ }), placeholder: trans.__('Default: %1', DEFAULT_MAX_TURNS), helperText: trans.__('Maximum number of tool execution turns'), slotProps: { htmlInput: { min: 1, max: 100 } } }),
312
+ React.createElement(TextField, { fullWidth: true, label: trans.__('Context Window (Optional)'), type: "number", value: parameters.contextWindow ?? '', onChange: e => setParameters({
313
+ ...parameters,
314
+ contextWindow: e.target.value
315
+ ? Number(e.target.value)
316
+ : undefined
317
+ }), placeholder: selectedModelInfo?.contextWindow !== undefined
318
+ ? trans.__('Default: %1', selectedModelInfo.contextWindow.toLocaleString())
319
+ : trans.__('e.g., 128000'), helperText: selectedModelInfo?.contextWindow !== undefined &&
320
+ parameters.contextWindow === undefined
321
+ ? trans.__('Using provider metadata default of %1 tokens for this model unless you override it here.', selectedModelInfo.contextWindow.toLocaleString())
322
+ : trans.__('Model context window size in tokens (used for context usage estimation)'), slotProps: { htmlInput: { min: 1 } } }),
310
323
  React.createElement(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 2, mb: 1 } }, trans.__('Completion Options')),
311
324
  React.createElement(FormControlLabel, { control: React.createElement(Switch, { checked: parameters.supportsFillInMiddle ?? false, onChange: e => setParameters({
312
325
  ...parameters,
@@ -344,7 +357,7 @@ export const ProviderConfigDialog = ({ open, onClose, onSave, initialConfig, mod
344
357
  webSearchImplementation === 'anthropic' && (React.createElement(React.Fragment, null,
345
358
  React.createElement(TextField, { fullWidth: true, label: trans.__('Web Search Max Uses'), type: "number", value: webSearchSettings.maxUses ?? '', onChange: e => updateCustomSetting('webSearch', 'maxUses', e.target.value
346
359
  ? Number(e.target.value)
347
- : undefined), inputProps: { min: 1 } }),
360
+ : undefined), slotProps: { htmlInput: { min: 1 } } }),
348
361
  renderDomainList('webSearch.blockedDomains', trans.__('Blocked Domains'), trans.__('spam.example.com'), webSearchSettings.blockedDomains))))))),
349
362
  supportsWebFetch && (React.createElement(React.Fragment, null,
350
363
  React.createElement(FormControlLabel, { control: React.createElement(Switch, { checked: webFetchSettings.enabled === true, onChange: e => updateCustomSetting('webFetch', 'enabled', e.target.checked) }), label: trans.__('Enable Web Fetch') }),
@@ -358,10 +371,10 @@ export const ProviderConfigDialog = ({ open, onClose, onSave, initialConfig, mod
358
371
  } },
359
372
  React.createElement(TextField, { fullWidth: true, label: trans.__('Web Fetch Max Uses'), type: "number", value: webFetchSettings.maxUses ?? '', onChange: e => updateCustomSetting('webFetch', 'maxUses', e.target.value
360
373
  ? Number(e.target.value)
361
- : undefined), inputProps: { min: 1 } }),
374
+ : undefined), slotProps: { htmlInput: { min: 1 } } }),
362
375
  React.createElement(TextField, { fullWidth: true, label: trans.__('Web Fetch Max Content Tokens'), type: "number", value: webFetchSettings.maxContentTokens ?? '', onChange: e => updateCustomSetting('webFetch', 'maxContentTokens', e.target.value
363
376
  ? Number(e.target.value)
364
- : undefined), inputProps: { min: 1 } }),
377
+ : undefined), slotProps: { htmlInput: { min: 1 } } }),
365
378
  renderDomainList('webFetch.allowedDomains', trans.__('Allowed Domains'), trans.__('docs.example.com'), webFetchSettings.allowedDomains),
366
379
  renderDomainList('webFetch.blockedDomains', trans.__('Blocked Domains'), trans.__('spam.example.com'), webFetchSettings.blockedDomains),
367
380
  React.createElement(FormControlLabel, { control: React.createElement(Switch, { checked: webFetchSettings.citationsEnabled === true, onChange: e => updateCustomSetting('webFetch', 'citationsEnabled', e.target.checked) }), label: trans.__('Enable Citations') })))))))))))),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyterlite/ai",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "AI code completions and chat for JupyterLite",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -53,7 +53,8 @@
53
53
  "watch:src": "tsc -w --sourceMap",
54
54
  "watch:labextension": "jupyter labextension watch .",
55
55
  "docs": "jupyter book start",
56
- "docs:build": "sed -e 's/\\[@/[/g' -e 's/@/\\&#64;/g' CHANGELOG.md > docs/_changelog_content.md && jupyter book build --html"
56
+ "docs:build": "sed -e 's/\\[@/[/g' -e 's/@/\\&#64;/g' CHANGELOG.md > docs/_changelog_content.md && jupyter book build --html",
57
+ "sync:model-context-windows": "node scripts/sync-model-context-windows.mjs && prettier --write src/providers/generated-context-windows.ts && eslint --fix src/providers/generated-context-windows.ts"
57
58
  },
58
59
  "dependencies": {
59
60
  "@ai-sdk/anthropic": "^3.0.58",
@@ -54,6 +54,11 @@
54
54
  "maximum": 100,
55
55
  "default": 25
56
56
  },
57
+ "contextWindow": {
58
+ "type": "number",
59
+ "description": "Model context window size in tokens (used for context usage estimation)",
60
+ "minimum": 1
61
+ },
57
62
  "supportsFillInMiddle": {
58
63
  "type": "boolean",
59
64
  "description": "Whether the model supports fill-in-middle completion"
@@ -211,6 +216,12 @@
211
216
  "type": "boolean",
212
217
  "default": false
213
218
  },
219
+ "showContextUsage": {
220
+ "title": "Show Context Usage",
221
+ "description": "Display estimated context usage percentage in the chat toolbar",
222
+ "type": "boolean",
223
+ "default": false
224
+ },
214
225
  "commandsRequiringApproval": {
215
226
  "title": "Commands Requiring Approval",
216
227
  "description": "List of commands that require user approval before AI can execute them",
package/src/agent.ts CHANGED
@@ -4,6 +4,7 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
4
4
  import { PromiseDelegate } from '@lumino/coreutils';
5
5
  import { ISignal, Signal } from '@lumino/signaling';
6
6
  import {
7
+ generateText,
7
8
  ToolLoopAgent,
8
9
  type ModelMessage,
9
10
  type LanguageModel,
@@ -13,11 +14,14 @@ import {
13
14
  type TypedToolError,
14
15
  type TypedToolOutputDenied,
15
16
  type TypedToolResult,
16
- type AssistantModelMessage
17
+ type UserContent,
18
+ type AssistantModelMessage,
19
+ APICallError
17
20
  } from 'ai';
18
21
  import { ISecretsManager } from 'jupyter-secrets-manager';
19
22
 
20
23
  import { createModel } from './providers/models';
24
+ import { getEffectiveContextWindow } from './providers/model-info';
21
25
  import {
22
26
  createProviderTools,
23
27
  type IProviderCustomSettings
@@ -53,6 +57,10 @@ interface IStreamProcessResult {
53
57
  * Whether an approval request was encountered and processed.
54
58
  */
55
59
  approvalProcessed: boolean;
60
+ /**
61
+ * Whether the stream was aborted before completion.
62
+ */
63
+ aborted: boolean;
56
64
  /**
57
65
  * The approval response message to add to history (if approval was processed).
58
66
  */
@@ -387,7 +395,17 @@ export class AgentManager implements IAgentManager {
387
395
  return this._activeProvider;
388
396
  }
389
397
  set activeProvider(value: string) {
398
+ const previousProvider = this._activeProvider;
390
399
  this._activeProvider = value;
400
+
401
+ // Reset request-level context estimate only when switching between providers.
402
+ if (previousProvider && previousProvider !== value) {
403
+ this._tokenUsage.lastRequestInputTokens = undefined;
404
+ }
405
+
406
+ this._tokenUsage.contextWindow = this._getActiveContextWindow();
407
+
408
+ this._tokenUsageChanged.emit(this._tokenUsage);
391
409
  this.initializeAgent();
392
410
  this._activeProviderChanged.emit(this._activeProvider);
393
411
  }
@@ -463,7 +481,11 @@ export class AgentManager implements IAgentManager {
463
481
 
464
482
  // Clear history and token usage
465
483
  this._history = [];
466
- this._tokenUsage = { inputTokens: 0, outputTokens: 0 };
484
+ this._tokenUsage = {
485
+ inputTokens: 0,
486
+ outputTokens: 0,
487
+ contextWindow: this._getActiveContextWindow()
488
+ };
467
489
  this._tokenUsageChanged.emit(this._tokenUsage);
468
490
  }
469
491
 
@@ -485,12 +507,13 @@ export class AgentManager implements IAgentManager {
485
507
  this._pendingApprovals.clear();
486
508
 
487
509
  // Convert chat messages to model messages
488
- const modelMessages = messages.map(msg => {
489
- const isAIMessage = msg.sender.username === 'ai-assistant';
510
+ const modelMessages: ModelMessage[] = messages.map(msg => {
511
+ const role =
512
+ msg.sender.username === 'ai-assistant' ? 'assistant' : 'user';
490
513
  return {
491
- role: isAIMessage ? 'assistant' : 'user',
514
+ role,
492
515
  content: msg.body
493
- } as ModelMessage;
516
+ };
494
517
  });
495
518
  this._history = Private.sanitizeModelMessages(modelMessages);
496
519
  }
@@ -552,10 +575,17 @@ export class AgentManager implements IAgentManager {
552
575
  * Handles the complete execution cycle including tool calls.
553
576
  * @param message The user message to respond to (may include processed attachment content)
554
577
  */
555
- async generateResponse(message: string): Promise<void> {
578
+ async generateResponse(message: UserContent): Promise<void> {
556
579
  this._streaming = new PromiseDelegate();
557
580
  this._controller = new AbortController();
558
581
  const responseHistory: ModelMessage[] = [];
582
+
583
+ // Add user message to history
584
+ responseHistory.push({
585
+ role: 'user',
586
+ content: message
587
+ });
588
+
559
589
  try {
560
590
  // Ensure we have an agent
561
591
  if (!this._agent) {
@@ -566,12 +596,6 @@ export class AgentManager implements IAgentManager {
566
596
  throw new Error('Failed to initialize agent');
567
597
  }
568
598
 
569
- // Add user message to history
570
- responseHistory.push({
571
- role: 'user',
572
- content: message
573
- });
574
-
575
599
  let continueLoop = true;
576
600
  while (continueLoop) {
577
601
  const result = await this._agent.stream({
@@ -581,9 +605,22 @@ export class AgentManager implements IAgentManager {
581
605
 
582
606
  const streamResult = await this._processStreamResult(result);
583
607
 
584
- // Get response messages and update token usage
608
+ if (streamResult.aborted) {
609
+ try {
610
+ const responseMessages = await result.response;
611
+ if (responseMessages.messages?.length) {
612
+ this._history.push(
613
+ ...Private.sanitizeModelMessages(responseMessages.messages)
614
+ );
615
+ }
616
+ } catch {
617
+ // Aborting before a step finishes leaves no completed response to persist.
618
+ }
619
+ break;
620
+ }
621
+
622
+ // Get response messages for completed steps.
585
623
  const responseMessages = await result.response;
586
- this._updateTokenUsage(await result.usage);
587
624
 
588
625
  // Add response messages to history
589
626
  if (responseMessages.messages?.length) {
@@ -615,9 +652,41 @@ export class AgentManager implements IAgentManager {
615
652
  this._history.push(...Private.sanitizeModelMessages(responseHistory));
616
653
  } catch (error) {
617
654
  if ((error as Error).name !== 'AbortError') {
655
+ let helpMessage = `${(error as Error).message}`;
656
+
657
+ // Remove attachments from history on payload rejection errors
658
+ if (
659
+ APICallError.isInstance(error) &&
660
+ (error.statusCode === 400 ||
661
+ error.statusCode === 404 ||
662
+ error.statusCode === 413 ||
663
+ error.statusCode === 415 ||
664
+ error.statusCode === 422)
665
+ ) {
666
+ for (const msg of [...this._history, ...responseHistory]) {
667
+ if (msg.role === 'user' && Array.isArray(msg.content)) {
668
+ const hasMedia = msg.content.some(p => p.type !== 'text');
669
+ if (hasMedia) {
670
+ const textContent = msg.content
671
+ .filter(p => p.type === 'text')
672
+ .map(p => (p as { text: string }).text)
673
+ .join('\n');
674
+ msg.content =
675
+ textContent || '_Attachment removed due to error_';
676
+ }
677
+ }
678
+ }
679
+ helpMessage +=
680
+ '\n\nAttachments have been removed from history. Please send your prompt again.';
681
+ }
618
682
  this._agentEvent.emit({
619
683
  type: 'error',
620
- data: { error: error as Error }
684
+ data: { error: new Error(helpMessage) }
685
+ });
686
+ this._history.push(...Private.sanitizeModelMessages(responseHistory));
687
+ this._history.push({
688
+ role: 'assistant',
689
+ content: helpMessage
621
690
  });
622
691
  }
623
692
  } finally {
@@ -627,16 +696,56 @@ export class AgentManager implements IAgentManager {
627
696
  }
628
697
 
629
698
  /**
630
- * Updates token usage statistics.
699
+ * Create a transient language model to request a text response which won't be added to history.
700
+ * @param messages - the messages sequence to send to the model.
701
+ */
702
+ async textResponse(messages: ModelMessage[]): Promise<string> {
703
+ try {
704
+ const model = await this._createModel();
705
+ const result = await generateText({
706
+ model,
707
+ messages
708
+ });
709
+ this._updateTokenUsage(result.totalUsage, result.totalUsage.inputTokens);
710
+ return result.text;
711
+ } catch (e) {
712
+ throw `Error while getting the topic of the chat\n${e}`;
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Updates cumulative token usage statistics from a completed model step.
631
718
  */
632
719
  private _updateTokenUsage(
633
- usage: { inputTokens?: number; outputTokens?: number } | undefined
720
+ usage: { inputTokens?: number; outputTokens?: number } | undefined,
721
+ lastRequestInputTokens?: number
634
722
  ): void {
723
+ const contextWindow = this._getActiveContextWindow();
724
+ const estimatedRequestInputTokens =
725
+ lastRequestInputTokens ?? usage?.inputTokens;
726
+
635
727
  if (usage) {
636
728
  this._tokenUsage.inputTokens += usage.inputTokens ?? 0;
637
729
  this._tokenUsage.outputTokens += usage.outputTokens ?? 0;
638
- this._tokenUsageChanged.emit(this._tokenUsage);
639
730
  }
731
+
732
+ this._tokenUsage.lastRequestInputTokens = estimatedRequestInputTokens;
733
+ this._tokenUsage.contextWindow = contextWindow;
734
+
735
+ this._tokenUsageChanged.emit(this._tokenUsage);
736
+ }
737
+
738
+ /**
739
+ * Gets the configured context window for the active provider.
740
+ */
741
+ private _getActiveContextWindow(): number | undefined {
742
+ const activeProviderConfig = this._settingsModel.getProvider(
743
+ this._activeProvider
744
+ );
745
+ return getEffectiveContextWindow(
746
+ activeProviderConfig,
747
+ this._providerRegistry
748
+ );
640
749
  }
641
750
 
642
751
  /**
@@ -699,6 +808,13 @@ export class AgentManager implements IAgentManager {
699
808
  activeProviderConfig && this._providerRegistry
700
809
  ? this._providerRegistry.getProviderInfo(activeProviderConfig.provider)
701
810
  : null;
811
+ const contextWindow = getEffectiveContextWindow(
812
+ activeProviderConfig,
813
+ this._providerRegistry
814
+ );
815
+
816
+ this._tokenUsage.contextWindow = contextWindow;
817
+ this._tokenUsageChanged.emit(this._tokenUsage);
702
818
 
703
819
  const temperature =
704
820
  activeProviderConfig?.parameters?.temperature ?? DEFAULT_TEMPERATURE;
@@ -806,7 +922,10 @@ ${richOutputWorkflowInstruction}`;
806
922
  ): Promise<IStreamProcessResult> {
807
923
  let fullResponse = '';
808
924
  let currentMessageId: string | null = null;
809
- const processResult: IStreamProcessResult = { approvalProcessed: false };
925
+ const processResult: IStreamProcessResult = {
926
+ approvalProcessed: false,
927
+ aborted: false
928
+ };
810
929
 
811
930
  for await (const part of result.fullStream) {
812
931
  switch (part.type) {
@@ -868,7 +987,18 @@ ${richOutputWorkflowInstruction}`;
868
987
  await this._handleApprovalRequest(part, processResult);
869
988
  break;
870
989
 
871
- // Ignore: text-start, text-end, finish, error, and others
990
+ case 'error':
991
+ throw part.error;
992
+
993
+ case 'finish-step':
994
+ this._updateTokenUsage(part.usage, part.usage.inputTokens);
995
+ break;
996
+
997
+ case 'abort':
998
+ processResult.aborted = true;
999
+ break;
1000
+
1001
+ // Ignore: text-start, text-end, finish, and others
872
1002
  default:
873
1003
  break;
874
1004
  }