@jupyternaut/persona 0.0.0 → 0.20.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/chat-commands/mention.d.ts +9 -0
  2. package/lib/chat-commands/mention.js +30 -0
  3. package/lib/completion/completion-provider.d.ts +86 -0
  4. package/lib/completion/completion-provider.js +246 -0
  5. package/lib/completion/index.d.ts +2 -0
  6. package/lib/completion/index.js +1 -0
  7. package/lib/components/completion-status.d.ts +26 -0
  8. package/lib/components/completion-status.js +52 -0
  9. package/lib/components/index.d.ts +2 -0
  10. package/lib/components/index.js +1 -0
  11. package/lib/diff-manager.d.ts +25 -0
  12. package/lib/diff-manager.js +60 -0
  13. package/lib/index.d.ts +8 -0
  14. package/lib/index.js +522 -0
  15. package/lib/models/settings-model.d.ts +36 -0
  16. package/lib/models/settings-model.js +356 -0
  17. package/lib/persona-registry.d.ts +15 -0
  18. package/lib/persona-registry.js +29 -0
  19. package/lib/persona.d.ts +66 -0
  20. package/lib/persona.js +414 -0
  21. package/lib/process-attachments.d.ts +5 -0
  22. package/lib/process-attachments.js +287 -0
  23. package/lib/tokens.d.ts +101 -0
  24. package/lib/tokens.js +20 -0
  25. package/lib/widgets/ai-settings.d.ts +54 -0
  26. package/lib/widgets/ai-settings.js +572 -0
  27. package/lib/widgets/provider-config-dialog.d.ts +16 -0
  28. package/lib/widgets/provider-config-dialog.js +384 -0
  29. package/package.json +111 -7
  30. package/schema/settings-model.json +287 -0
  31. package/src/chat-commands/mention.tsx +46 -0
  32. package/src/completion/completion-provider.ts +350 -0
  33. package/src/completion/index.ts +1 -0
  34. package/src/components/completion-status.tsx +93 -0
  35. package/src/components/index.ts +1 -0
  36. package/src/diff-manager.ts +81 -0
  37. package/src/index.ts +710 -0
  38. package/src/models/settings-model.ts +415 -0
  39. package/src/persona-registry.ts +46 -0
  40. package/src/persona.ts +610 -0
  41. package/src/process-attachments.ts +369 -0
  42. package/src/tokens.ts +121 -0
  43. package/src/widgets/ai-settings.tsx +1308 -0
  44. package/src/widgets/provider-config-dialog.tsx +997 -0
  45. package/style/base.css +14 -0
  46. package/style/index.css +1 -0
  47. package/style/index.js +1 -0
  48. package/README.md +0 -3
  49. package/index.js +0 -1
@@ -0,0 +1,1308 @@
1
+ import {
2
+ getEffectiveContextWindow,
3
+ SECRETS_REPLACEMENT
4
+ } from '@jupyternaut/agent';
5
+ import type {
6
+ IAgentManagerFactory,
7
+ IAIConfig,
8
+ IAISecretsAccess,
9
+ IAISettingsModel,
10
+ IProviderConfig,
11
+ IProviderRegistry
12
+ } from '@jupyternaut/agent';
13
+ import { IThemeManager } from '@jupyterlab/apputils';
14
+ import { ReactWidget } from '@jupyterlab/ui-components';
15
+ import type { TranslationBundle } from '@jupyterlab/translation';
16
+ import { Debouncer } from '@lumino/polling';
17
+ import Add from '@mui/icons-material/Add';
18
+ import CheckCircle from '@mui/icons-material/CheckCircle';
19
+ import Delete from '@mui/icons-material/Delete';
20
+ import Edit from '@mui/icons-material/Edit';
21
+ import Error from '@mui/icons-material/Error';
22
+ import InfoOutlined from '@mui/icons-material/InfoOutlined';
23
+ import MoreVert from '@mui/icons-material/MoreVert';
24
+ import Settings from '@mui/icons-material/Settings';
25
+ import {
26
+ Alert,
27
+ Box,
28
+ Button,
29
+ Card,
30
+ CardContent,
31
+ Chip,
32
+ Divider,
33
+ FormControl,
34
+ FormControlLabel,
35
+ IconButton,
36
+ InputLabel,
37
+ List,
38
+ ListItem,
39
+ ListItemText,
40
+ Menu,
41
+ MenuItem,
42
+ Select,
43
+ Switch,
44
+ Tab,
45
+ Tabs,
46
+ TextField,
47
+ ThemeProvider,
48
+ Tooltip,
49
+ Typography,
50
+ createTheme
51
+ } from '@mui/material';
52
+ import React, { useEffect, useMemo, useState } from 'react';
53
+
54
+ import { ProviderConfigDialog } from './provider-config-dialog';
55
+
56
+ /**
57
+ * Create a theme that uses IThemeManager to detect theme
58
+ * @param themeManager - Optional theme manager to detect theme
59
+ * @returns A Material-UI theme configured for the current JupyterLab theme
60
+ */
61
+ const createJupyterLabTheme = (themeManager?: IThemeManager) => {
62
+ // Use IThemeManager if available, otherwise default to light theme
63
+ const isDark = themeManager?.theme
64
+ ? !themeManager.isLight(themeManager.theme)
65
+ : false;
66
+
67
+ return createTheme({
68
+ palette: {
69
+ mode: isDark ? 'dark' : 'light'
70
+ }
71
+ });
72
+ };
73
+
74
+ /**
75
+ * A JupyterLab widget for AI settings configuration
76
+ */
77
+ export class AISettingsWidget extends ReactWidget {
78
+ /**
79
+ * Construct a new AI settings widget
80
+ * @param options - The options for initializing the widget
81
+ */
82
+ constructor(options: AISettingsWidget.IOptions) {
83
+ super();
84
+ this._settingsModel = options.settingsModel;
85
+ this._agentManagerFactory = options.agentManagerFactory;
86
+ this._themeManager = options.themeManager;
87
+ this._providerRegistry = options.providerRegistry;
88
+ this._secretsAccess = options.secretsAccess;
89
+ this._trans = options.trans;
90
+ this._mcpServerRenderer = options.mcpServerRenderer;
91
+
92
+ this.id = 'jupyternaut-persona-settings';
93
+ this.title.label = this._trans.__('Jupyternaut Settings');
94
+ this.title.caption = this._trans.__('Configure AI providers and behavior');
95
+ this.title.closable = true;
96
+
97
+ // Disable the secrets manager if the token is empty.
98
+ if (!options.secretsAccess.isAvailable) {
99
+ this._settingsModel.updateConfig({ useSecretsManager: false });
100
+ this._secretsAccess = undefined;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Render the AI settings component
106
+ * @returns A React element containing the AI settings interface
107
+ */
108
+ protected render(): React.ReactElement {
109
+ return (
110
+ <AISettingsComponent
111
+ model={this._settingsModel}
112
+ agentManagerFactory={this._agentManagerFactory}
113
+ themeManager={this._themeManager}
114
+ providerRegistry={this._providerRegistry}
115
+ secretsAccess={this._secretsAccess}
116
+ trans={this._trans}
117
+ McpServerRenderer={this._mcpServerRenderer}
118
+ />
119
+ );
120
+ }
121
+
122
+ private _settingsModel: IAISettingsModel;
123
+ private _agentManagerFactory?: IAgentManagerFactory;
124
+ private _themeManager?: IThemeManager;
125
+ private _providerRegistry: IProviderRegistry;
126
+ private _secretsAccess?: IAISecretsAccess;
127
+ private _trans: TranslationBundle;
128
+ private _mcpServerRenderer?: React.ComponentType<any>;
129
+ }
130
+
131
+ /**
132
+ * Props interface for the AISettingsComponent
133
+ */
134
+ interface IAISettingsComponentProps {
135
+ model: IAISettingsModel;
136
+ agentManagerFactory?: IAgentManagerFactory;
137
+ themeManager?: IThemeManager;
138
+ providerRegistry: IProviderRegistry;
139
+ secretsAccess?: IAISecretsAccess;
140
+ trans: TranslationBundle;
141
+ McpServerRenderer?: React.ComponentType<any>;
142
+ }
143
+
144
+ /**
145
+ * The main AI settings component that provides configuration UI
146
+ * @param props - Component props containing models and theme manager
147
+ * @returns A React component for AI settings configuration
148
+ */
149
+ const AISettingsComponent: React.FC<IAISettingsComponentProps> = ({
150
+ model,
151
+ agentManagerFactory,
152
+ themeManager,
153
+ providerRegistry,
154
+ secretsAccess,
155
+ trans,
156
+ McpServerRenderer
157
+ }) => {
158
+ if (!model) {
159
+ return <div>{trans.__('Settings model not available')}</div>;
160
+ }
161
+
162
+ const [config, setConfig] = useState(model.config || {});
163
+ const [theme, setTheme] = useState(() => createJupyterLabTheme(themeManager));
164
+ const [activeTab, setActiveTab] = useState(0);
165
+ const [dialogOpen, setDialogOpen] = useState(false);
166
+ const [editingProvider, setEditingProvider] = useState<
167
+ IProviderConfig | undefined
168
+ >();
169
+ const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
170
+ const [menuProviderId, setMenuProviderId] = useState<string>('');
171
+ const [systemPromptValue, setSystemPromptValue] = useState(
172
+ config.systemPrompt
173
+ );
174
+ const systemPromptValueRef = React.useRef(config.systemPrompt);
175
+ const [completionPromptValue, setCompletionPromptValue] = useState(
176
+ config.completionSystemPrompt
177
+ );
178
+ const completionPromptValueRef = React.useRef(config.completionSystemPrompt);
179
+
180
+ /**
181
+ * Effect to listen for model state changes and update config
182
+ */
183
+ useEffect(() => {
184
+ if (!model || !model.stateChanged) {
185
+ return;
186
+ }
187
+
188
+ const onStateChanged = () => {
189
+ setConfig(model.config || {});
190
+ };
191
+
192
+ model.stateChanged.connect(onStateChanged);
193
+ return () => {
194
+ model.stateChanged.disconnect(onStateChanged);
195
+ };
196
+ }, [model]);
197
+
198
+ /**
199
+ * Effect to listen for theme changes and update the Material-UI theme
200
+ */
201
+ useEffect(() => {
202
+ if (!themeManager) {
203
+ return;
204
+ }
205
+
206
+ const updateTheme = () => {
207
+ setTheme(createJupyterLabTheme(themeManager));
208
+ };
209
+
210
+ themeManager.themeChanged.connect(updateTheme);
211
+ return () => {
212
+ themeManager.themeChanged.disconnect(updateTheme);
213
+ };
214
+ }, [themeManager]);
215
+
216
+ /**
217
+ * Effect to listen for MCP connection changes to re-render connection status
218
+ */
219
+ useEffect(() => {
220
+ if (!agentManagerFactory) {
221
+ return;
222
+ }
223
+
224
+ const onMCPConnectionChanged = () => {
225
+ // Force a re-render by updating the config state
226
+ setConfig(prevConfig => ({ ...prevConfig }));
227
+ };
228
+
229
+ agentManagerFactory.mcpConnectionChanged.connect(onMCPConnectionChanged);
230
+ return () => {
231
+ agentManagerFactory.mcpConnectionChanged.disconnect(
232
+ onMCPConnectionChanged
233
+ );
234
+ };
235
+ }, [agentManagerFactory]);
236
+
237
+ // Sync local state when config changes externally
238
+ useEffect(() => {
239
+ setSystemPromptValue(config.systemPrompt);
240
+ systemPromptValueRef.current = config.systemPrompt;
241
+ }, [config.systemPrompt]);
242
+
243
+ useEffect(() => {
244
+ setCompletionPromptValue(config.completionSystemPrompt);
245
+ completionPromptValueRef.current = config.completionSystemPrompt;
246
+ }, [config.completionSystemPrompt]);
247
+
248
+ const promptDebouncer = useMemo(
249
+ () =>
250
+ new Debouncer(async () => {
251
+ await handleConfigUpdate({
252
+ systemPrompt: systemPromptValueRef.current,
253
+ completionSystemPrompt: completionPromptValueRef.current
254
+ });
255
+ }, 1000),
256
+ []
257
+ );
258
+
259
+ // Cleanup debouncer on unmount
260
+ useEffect(() => {
261
+ return () => {
262
+ promptDebouncer.dispose();
263
+ };
264
+ }, [promptDebouncer]);
265
+
266
+ const handleSystemPromptChange = (value: string) => {
267
+ setSystemPromptValue(value);
268
+ systemPromptValueRef.current = value;
269
+ void promptDebouncer.invoke();
270
+ };
271
+
272
+ const handleCompletionPromptChange = (value: string) => {
273
+ setCompletionPromptValue(value);
274
+ completionPromptValueRef.current = value;
275
+ void promptDebouncer.invoke();
276
+ };
277
+
278
+ const getSecretFromManager = async (
279
+ provider: string,
280
+ fieldName: string
281
+ ): Promise<string | undefined> => {
282
+ return secretsAccess?.get(`${provider}:${fieldName}`);
283
+ };
284
+
285
+ const setSecretToManager = async (
286
+ provider: string,
287
+ fieldName: string,
288
+ value: string
289
+ ): Promise<void> => {
290
+ await secretsAccess?.set(`${provider}:${fieldName}`, value);
291
+ };
292
+
293
+ /**
294
+ * Attach a secrets field to the secrets manager.
295
+ * @param input - the DOm element to attach.
296
+ * @param provider - the name of the provider.
297
+ * @param fieldName - the name of the field.
298
+ */
299
+ const handleSecretField = async (
300
+ input: HTMLInputElement,
301
+ provider: string,
302
+ fieldName: string
303
+ ): Promise<void> => {
304
+ if (!(model.config.useSecretsManager && secretsAccess?.isAvailable)) {
305
+ return;
306
+ }
307
+ await secretsAccess.attach(`${provider}:${fieldName}`, input);
308
+ };
309
+
310
+ /**
311
+ * Handle adding a new AI provider
312
+ * @param providerConfig - The provider configuration to add
313
+ */
314
+ const handleAddProvider = async (
315
+ providerConfig: Omit<IProviderConfig, 'id'>
316
+ ) => {
317
+ if (
318
+ model.config.useSecretsManager &&
319
+ secretsAccess?.isAvailable &&
320
+ providerConfig.apiKey
321
+ ) {
322
+ providerConfig.apiKey = SECRETS_REPLACEMENT;
323
+ }
324
+ await model.addProvider(providerConfig);
325
+ };
326
+
327
+ /**
328
+ * Handle editing an existing AI provider
329
+ * @param providerConfig - The updated provider configuration
330
+ */
331
+ const handleEditProvider = async (
332
+ providerConfig: Omit<IProviderConfig, 'id'>
333
+ ) => {
334
+ if (editingProvider) {
335
+ if (
336
+ model.config.useSecretsManager &&
337
+ secretsAccess?.isAvailable &&
338
+ providerConfig.apiKey
339
+ ) {
340
+ providerConfig.apiKey = SECRETS_REPLACEMENT;
341
+ }
342
+ await model.updateProvider(editingProvider.id, providerConfig);
343
+ setEditingProvider(undefined);
344
+ }
345
+ };
346
+
347
+ /**
348
+ * Handle deleting an AI provider
349
+ * @param id - The ID of the provider to delete
350
+ */
351
+ const handleDeleteProvider = async (id: string) => {
352
+ await model.removeProvider(id);
353
+ setMenuAnchor(null);
354
+ };
355
+
356
+ /**
357
+ * Open the provider edit dialog
358
+ * @param provider - The provider to edit
359
+ */
360
+ const openEditDialog = async (provider: IProviderConfig) => {
361
+ // Retrieve the API key from the secrets manager if necessary.
362
+ if (model.config.useSecretsManager && secretsAccess?.isAvailable) {
363
+ provider.apiKey =
364
+ (await getSecretFromManager(provider.provider, 'apiKey')) ?? '';
365
+ }
366
+ setEditingProvider(provider);
367
+ setDialogOpen(true);
368
+ setMenuAnchor(null);
369
+ };
370
+
371
+ /**
372
+ * Open the provider add dialog
373
+ */
374
+ const openAddDialog = () => {
375
+ setEditingProvider(undefined);
376
+ setDialogOpen(true);
377
+ };
378
+
379
+ /**
380
+ * Handle provider menu click
381
+ * @param event - The click event
382
+ * @param providerId - The ID of the provider
383
+ */
384
+ const handleMenuClick = (
385
+ event: React.MouseEvent<HTMLElement>,
386
+ providerId: string
387
+ ) => {
388
+ setMenuAnchor(event.currentTarget);
389
+ setMenuProviderId(providerId);
390
+ };
391
+
392
+ /**
393
+ * Handle provider menu close
394
+ */
395
+ const handleMenuClose = () => {
396
+ setMenuAnchor(null);
397
+ setMenuProviderId('');
398
+ };
399
+
400
+ /**
401
+ * Handle updating AI configuration
402
+ * @param updates - Partial configuration updates to apply
403
+ */
404
+ const handleConfigUpdate = async (updates: Partial<IAIConfig>) => {
405
+ if (updates.useSecretsManager !== undefined) {
406
+ if (updates.useSecretsManager) {
407
+ for (const provider of model.config.providers) {
408
+ const settingsApiKey = provider.apiKey;
409
+ // If the secrets manager doesn't have the current API key, set the current
410
+ // one from settings.
411
+ // Update the settings value with SECRETS_REPLACEMENT if a key exist in the
412
+ // secrets manager (was already there or a value was set in settings).
413
+ if (!(await getSecretFromManager(provider.provider, 'apiKey'))) {
414
+ if (settingsApiKey !== undefined) {
415
+ setSecretToManager(
416
+ provider.provider,
417
+ 'apiKey',
418
+ settingsApiKey !== SECRETS_REPLACEMENT ? settingsApiKey : ''
419
+ );
420
+ provider.apiKey = SECRETS_REPLACEMENT;
421
+ await model.updateProvider(provider.id, provider);
422
+ }
423
+ } else {
424
+ provider.apiKey = SECRETS_REPLACEMENT;
425
+ await model.updateProvider(provider.id, provider);
426
+ }
427
+ }
428
+ } else {
429
+ for (const provider of model.config.providers) {
430
+ const apiKey = await getSecretFromManager(
431
+ provider.provider,
432
+ 'apiKey'
433
+ );
434
+ provider.apiKey = apiKey;
435
+ await model.updateProvider(provider.id, provider);
436
+ }
437
+ }
438
+ }
439
+ await model.updateConfig(updates);
440
+ };
441
+
442
+ return (
443
+ <ThemeProvider theme={theme}>
444
+ <Box
445
+ sx={{
446
+ height: '100%',
447
+ maxHeight: '100vh',
448
+ overflow: 'auto',
449
+ p: 2,
450
+ pb: 4,
451
+ boxSizing: 'border-box',
452
+ fontSize: '0.9rem'
453
+ }}
454
+ >
455
+ {/* Header */}
456
+ <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
457
+ <Settings color="primary" sx={{ fontSize: 24 }} />
458
+ <Typography variant="h5" component="h1" sx={{ fontWeight: 600 }}>
459
+ {trans.__('AI Settings')}
460
+ </Typography>
461
+ </Box>
462
+ {/* Tabs */}
463
+ <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
464
+ <Tabs
465
+ value={activeTab}
466
+ onChange={(_, newValue) => setActiveTab(newValue)}
467
+ >
468
+ <Tab label={trans.__('Providers')} />
469
+ <Tab label={trans.__('Behavior')} />
470
+ {McpServerRenderer && <Tab label={trans.__('MCP Servers')} />}
471
+ </Tabs>
472
+ </Box>
473
+
474
+ {/* Tab Panels */}
475
+ {activeTab === 0 && (
476
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
477
+ {/* Default Provider Selection */}
478
+ {config.providers.length > 0 && (
479
+ <Card elevation={2}>
480
+ <CardContent>
481
+ <Typography variant="h6" component="h2" gutterBottom>
482
+ {trans.__('Default Providers')}
483
+ </Typography>
484
+
485
+ <Box
486
+ sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
487
+ >
488
+ <FormControl fullWidth>
489
+ <InputLabel>{trans.__('Chat Provider')}</InputLabel>
490
+ <Select
491
+ value={config.defaultProvider}
492
+ label={trans.__('Chat Provider')}
493
+ onChange={e => model.setActiveProvider(e.target.value)}
494
+ >
495
+ {config.providers.map(provider => (
496
+ <MenuItem key={provider.id} value={provider.id}>
497
+ {provider.name}
498
+ </MenuItem>
499
+ ))}
500
+ </Select>
501
+ </FormControl>
502
+
503
+ <FormControlLabel
504
+ control={
505
+ <Switch
506
+ checked={config.useSameProviderForChatAndCompleter}
507
+ onChange={e =>
508
+ handleConfigUpdate({
509
+ useSameProviderForChatAndCompleter:
510
+ e.target.checked
511
+ })
512
+ }
513
+ color="primary"
514
+ />
515
+ }
516
+ label={trans.__(
517
+ 'Use same provider for chat and completions'
518
+ )}
519
+ />
520
+
521
+ {!config.useSameProviderForChatAndCompleter && (
522
+ <FormControl fullWidth>
523
+ <InputLabel>
524
+ {trans.__('Completion Provider')}
525
+ </InputLabel>
526
+ <Select
527
+ value={config.activeCompleterProvider || ''}
528
+ label={trans.__('Completion Provider')}
529
+ className="jp-ai-completion-provider-select"
530
+ onChange={e =>
531
+ model.setActiveCompleterProvider(
532
+ e.target.value || undefined
533
+ )
534
+ }
535
+ >
536
+ <MenuItem value="">
537
+ <em>{trans.__('No completion')}</em>
538
+ </MenuItem>
539
+ {config.providers.map(provider => (
540
+ <MenuItem key={provider.id} value={provider.id}>
541
+ {provider.name}
542
+ </MenuItem>
543
+ ))}
544
+ </Select>
545
+ </FormControl>
546
+ )}
547
+ </Box>
548
+ </CardContent>
549
+ </Card>
550
+ )}
551
+
552
+ {/* Providers Card */}
553
+ <Card elevation={2}>
554
+ <CardContent>
555
+ <Box
556
+ sx={{
557
+ display: 'flex',
558
+ alignItems: 'center',
559
+ justifyContent: 'space-between',
560
+ mb: 2
561
+ }}
562
+ >
563
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
564
+ <Typography variant="h6" component="h2">
565
+ {trans.__('Configured Providers')}
566
+ </Typography>
567
+ </Box>
568
+ <Button
569
+ variant="contained"
570
+ startIcon={<Add />}
571
+ onClick={openAddDialog}
572
+ size="small"
573
+ >
574
+ {trans.__('Add Provider')}
575
+ </Button>
576
+ </Box>
577
+
578
+ {config.providers.length === 0 ? (
579
+ <Alert severity="info">
580
+ {trans.__(
581
+ 'No providers configured yet. Click "Add Provider" to get started.'
582
+ )}
583
+ </Alert>
584
+ ) : (
585
+ <List>
586
+ {config.providers.map(provider => {
587
+ const isActive = config.defaultProvider === provider.id;
588
+ const isActiveCompleter =
589
+ config.useSameProviderForChatAndCompleter
590
+ ? isActive
591
+ : config.activeCompleterProvider === provider.id;
592
+ const providerInfo = providerRegistry.getProviderInfo(
593
+ provider.provider
594
+ );
595
+ const providerToolCapabilities =
596
+ providerInfo?.providerToolCapabilities;
597
+ const params = provider.parameters;
598
+ const effectiveContextWindow = getEffectiveContextWindow(
599
+ provider,
600
+ providerRegistry
601
+ );
602
+ const webSearchEnabled =
603
+ !!providerToolCapabilities?.webSearch &&
604
+ provider.customSettings?.webSearch?.enabled === true;
605
+ const webFetchEnabled =
606
+ !!providerToolCapabilities?.webFetch &&
607
+ provider.customSettings?.webFetch?.enabled === true;
608
+
609
+ return (
610
+ <ListItem
611
+ key={provider.id}
612
+ sx={{
613
+ flexDirection: 'column',
614
+ alignItems: 'stretch',
615
+ py: 2
616
+ }}
617
+ >
618
+ <Box
619
+ sx={{
620
+ display: 'flex',
621
+ justifyContent: 'space-between',
622
+ alignItems: 'flex-start',
623
+ width: '100%',
624
+ mb: 1
625
+ }}
626
+ >
627
+ <Box sx={{ flex: 1 }}>
628
+ <Box
629
+ sx={{
630
+ display: 'flex',
631
+ alignItems: 'center',
632
+ gap: 1,
633
+ mb: 0.5
634
+ }}
635
+ >
636
+ <Typography
637
+ variant="subtitle1"
638
+ fontWeight="medium"
639
+ >
640
+ {provider.name}
641
+ </Typography>
642
+ {isActive && (
643
+ <Chip
644
+ label={trans.__('Chat')}
645
+ size="small"
646
+ color="primary"
647
+ icon={<CheckCircle />}
648
+ />
649
+ )}
650
+ {isActiveCompleter && (
651
+ <Chip
652
+ label={trans.__('Completion')}
653
+ size="small"
654
+ color="secondary"
655
+ icon={<CheckCircle />}
656
+ />
657
+ )}
658
+ </Box>
659
+ <Typography
660
+ variant="body2"
661
+ color="text.secondary"
662
+ gutterBottom
663
+ >
664
+ {provider.provider} • {provider.model}
665
+ {provider.description &&
666
+ ` • ${provider.description}`}
667
+ </Typography>
668
+
669
+ {/* Display parameters if set */}
670
+ {(params?.temperature !== undefined ||
671
+ params?.maxOutputTokens !== undefined ||
672
+ params?.maxTurns !== undefined ||
673
+ effectiveContextWindow !== undefined ||
674
+ webSearchEnabled ||
675
+ webFetchEnabled) && (
676
+ <Box
677
+ sx={{
678
+ display: 'flex',
679
+ flexWrap: 'wrap',
680
+ gap: 1,
681
+ mt: 1
682
+ }}
683
+ >
684
+ {params?.temperature !== undefined && (
685
+ <Chip
686
+ label={trans.__(
687
+ 'Temp: %1',
688
+ params.temperature
689
+ )}
690
+ size="small"
691
+ variant="outlined"
692
+ />
693
+ )}
694
+ {params?.maxOutputTokens !== undefined && (
695
+ <Chip
696
+ label={trans.__(
697
+ 'Tokens: %1',
698
+ params.maxOutputTokens
699
+ )}
700
+ size="small"
701
+ variant="outlined"
702
+ />
703
+ )}
704
+ {params?.maxTurns !== undefined && (
705
+ <Chip
706
+ label={trans.__(
707
+ 'Turns: %1',
708
+ params.maxTurns
709
+ )}
710
+ size="small"
711
+ variant="outlined"
712
+ />
713
+ )}
714
+ {effectiveContextWindow !== undefined && (
715
+ <Chip
716
+ label={trans.__(
717
+ 'Context: %1',
718
+ effectiveContextWindow
719
+ )}
720
+ size="small"
721
+ variant="outlined"
722
+ />
723
+ )}
724
+ {webSearchEnabled && (
725
+ <Chip
726
+ label={trans.__('Web Search')}
727
+ size="small"
728
+ variant="outlined"
729
+ color="info"
730
+ />
731
+ )}
732
+ {webFetchEnabled && (
733
+ <Chip
734
+ label={trans.__('Web Fetch')}
735
+ size="small"
736
+ variant="outlined"
737
+ color="info"
738
+ />
739
+ )}
740
+ </Box>
741
+ )}
742
+ </Box>
743
+ <IconButton
744
+ onClick={e => handleMenuClick(e, provider.id)}
745
+ size="small"
746
+ >
747
+ <MoreVert />
748
+ </IconButton>
749
+ </Box>
750
+ </ListItem>
751
+ );
752
+ })}
753
+ </List>
754
+ )}
755
+ </CardContent>
756
+ </Card>
757
+
758
+ {/* Secrets Manager Settings */}
759
+ {secretsAccess?.isAvailable && (
760
+ <FormControlLabel
761
+ control={
762
+ <Switch
763
+ checked={config.useSecretsManager}
764
+ onChange={e =>
765
+ handleConfigUpdate({
766
+ useSecretsManager: e.target.checked
767
+ })
768
+ }
769
+ color="primary"
770
+ sx={{ alignSelf: 'flex-start' }}
771
+ />
772
+ }
773
+ label={
774
+ <div>
775
+ <span>
776
+ {trans.__('Use the secrets manager to manage API keys')}
777
+ </span>
778
+ {!config.useSecretsManager && (
779
+ <Alert severity="warning" icon={<Error />} sx={{ mb: 2 }}>
780
+ {trans.__(
781
+ 'The secrets are stored in plain text in settings'
782
+ )}
783
+ </Alert>
784
+ )}
785
+ </div>
786
+ }
787
+ />
788
+ )}
789
+ </Box>
790
+ )}
791
+
792
+ {activeTab === 1 && (
793
+ <Card elevation={2}>
794
+ <CardContent>
795
+ <Typography variant="h6" component="h2" gutterBottom>
796
+ {trans.__('Behavior Settings')}
797
+ </Typography>
798
+
799
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
800
+ <FormControlLabel
801
+ control={
802
+ <Switch
803
+ checked={config.toolsEnabled}
804
+ onChange={e =>
805
+ handleConfigUpdate({
806
+ toolsEnabled: e.target.checked
807
+ })
808
+ }
809
+ color="primary"
810
+ />
811
+ }
812
+ label={
813
+ <Box>
814
+ <Typography variant="body1">
815
+ {trans.__('Enable Tools')}
816
+ </Typography>
817
+ <Typography variant="caption" color="text.secondary">
818
+ {trans.__(
819
+ 'Allow the AI to use tools like notebook operations, code execution, and file management'
820
+ )}
821
+ </Typography>
822
+ </Box>
823
+ }
824
+ />
825
+
826
+ <FormControlLabel
827
+ control={
828
+ <Switch
829
+ checked={config.showCellDiff}
830
+ onChange={e =>
831
+ handleConfigUpdate({
832
+ showCellDiff: e.target.checked
833
+ })
834
+ }
835
+ color="primary"
836
+ />
837
+ }
838
+ label={
839
+ <Box>
840
+ <Typography variant="body1">
841
+ {trans.__('Show Cell Diff')}
842
+ </Typography>
843
+ <Typography variant="caption" color="text.secondary">
844
+ {trans.__(
845
+ 'Show diff view when AI modifies cell content'
846
+ )}
847
+ </Typography>
848
+ </Box>
849
+ }
850
+ />
851
+
852
+ {config.showCellDiff && (
853
+ <FormControl sx={{ ml: 4 }}>
854
+ <InputLabel>{trans.__('Diff Display Mode')}</InputLabel>
855
+ <Select
856
+ value={config.diffDisplayMode}
857
+ label={trans.__('Diff Display Mode')}
858
+ onChange={e =>
859
+ handleConfigUpdate({
860
+ diffDisplayMode: e.target.value as 'split' | 'unified'
861
+ })
862
+ }
863
+ >
864
+ <MenuItem value="split">
865
+ {trans.__('Split View')}
866
+ </MenuItem>
867
+ <MenuItem value="unified">
868
+ {trans.__('Unified View')}
869
+ </MenuItem>
870
+ </Select>
871
+ </FormControl>
872
+ )}
873
+
874
+ <FormControlLabel
875
+ control={
876
+ <Switch
877
+ checked={config.showFileDiff}
878
+ onChange={e =>
879
+ handleConfigUpdate({
880
+ showFileDiff: e.target.checked
881
+ })
882
+ }
883
+ color="primary"
884
+ />
885
+ }
886
+ label={
887
+ <Box>
888
+ <Typography variant="body1">
889
+ {trans.__('Show File Diff')}
890
+ </Typography>
891
+ <Typography variant="caption" color="text.secondary">
892
+ {trans.__(
893
+ 'Show diff view when AI modifies file content'
894
+ )}
895
+ </Typography>
896
+ </Box>
897
+ }
898
+ />
899
+
900
+ <Divider sx={{ my: 1 }} />
901
+
902
+ <TextField
903
+ fullWidth
904
+ multiline
905
+ rows={3}
906
+ label={trans.__('System Prompt')}
907
+ value={systemPromptValue}
908
+ onChange={e => handleSystemPromptChange(e.target.value)}
909
+ placeholder={trans.__(
910
+ "Define the AI's behavior and personality..."
911
+ )}
912
+ helperText={trans.__(
913
+ 'Instructions that define how the AI should behave and respond'
914
+ )}
915
+ />
916
+
917
+ <TextField
918
+ fullWidth
919
+ multiline
920
+ rows={3}
921
+ label={trans.__('Completion System Prompt')}
922
+ value={completionPromptValue}
923
+ onChange={e => handleCompletionPromptChange(e.target.value)}
924
+ placeholder={trans.__(
925
+ 'Define how the AI should generate code completions...'
926
+ )}
927
+ helperText={trans.__(
928
+ 'Instructions that define how the AI should generate code completions'
929
+ )}
930
+ />
931
+
932
+ <Divider sx={{ my: 2 }} />
933
+
934
+ <Box>
935
+ <Typography
936
+ variant="body1"
937
+ gutterBottom
938
+ sx={{
939
+ display: 'inline-flex',
940
+ alignItems: 'center',
941
+ gap: 1
942
+ }}
943
+ >
944
+ {trans.__('Skills Paths')}
945
+ <Tooltip
946
+ title={trans.__(
947
+ 'Directories containing agent skills, relative to the server root. Skills are loaded from all paths; the first occurrence of a skill name takes priority.'
948
+ )}
949
+ >
950
+ <InfoOutlined sx={{ fontSize: 16 }} />
951
+ </Tooltip>
952
+ </Typography>
953
+
954
+ <List sx={{ mb: 2, maxHeight: 200, overflow: 'auto' }}>
955
+ {(config.skillsPaths ?? []).map((skillPath, index) => (
956
+ <ListItem
957
+ key={index}
958
+ divider
959
+ secondaryAction={
960
+ <IconButton
961
+ onClick={() => {
962
+ const newPaths = [...config.skillsPaths];
963
+ newPaths.splice(index, 1);
964
+ handleConfigUpdate({ skillsPaths: newPaths });
965
+ }}
966
+ size="small"
967
+ >
968
+ <Delete />
969
+ </IconButton>
970
+ }
971
+ >
972
+ <ListItemText primary={skillPath} />
973
+ </ListItem>
974
+ ))}
975
+ </List>
976
+
977
+ <TextField
978
+ fullWidth
979
+ label={trans.__('Add Skills Path')}
980
+ placeholder={trans.__('e.g., .claude/skills')}
981
+ onKeyDown={e => {
982
+ if (e.key === 'Enter') {
983
+ const value = (
984
+ e.target as HTMLInputElement
985
+ ).value.trim();
986
+ if (
987
+ value &&
988
+ !(config.skillsPaths ?? []).includes(value)
989
+ ) {
990
+ const newPaths = [
991
+ ...(config.skillsPaths ?? []),
992
+ value
993
+ ];
994
+ handleConfigUpdate({ skillsPaths: newPaths });
995
+ (e.target as HTMLInputElement).value = '';
996
+ }
997
+ }
998
+ }}
999
+ helperText={trans.__(
1000
+ 'Press Enter to add a path. Defaults: .agents/skills, _agents/skills'
1001
+ )}
1002
+ />
1003
+ </Box>
1004
+
1005
+ <Divider sx={{ my: 2 }} />
1006
+
1007
+ <Box>
1008
+ <Typography variant="body1" gutterBottom>
1009
+ {trans.__('Commands Requiring Approval')}
1010
+ </Typography>
1011
+ <Typography
1012
+ variant="caption"
1013
+ color="text.secondary"
1014
+ gutterBottom
1015
+ sx={{ display: 'block' }}
1016
+ >
1017
+ {trans.__(
1018
+ 'Commands that require user approval before AI can execute them'
1019
+ )}
1020
+ </Typography>
1021
+
1022
+ <List sx={{ mb: 2, maxHeight: 200, overflow: 'auto' }}>
1023
+ {config.commandsRequiringApproval.map((command, index) => (
1024
+ <ListItem
1025
+ key={index}
1026
+ divider
1027
+ secondaryAction={
1028
+ <IconButton
1029
+ onClick={() => {
1030
+ const newCommands = [
1031
+ ...config.commandsRequiringApproval
1032
+ ];
1033
+ newCommands.splice(index, 1);
1034
+ handleConfigUpdate({
1035
+ commandsRequiringApproval: newCommands
1036
+ });
1037
+ }}
1038
+ size="small"
1039
+ >
1040
+ <Delete />
1041
+ </IconButton>
1042
+ }
1043
+ >
1044
+ <ListItemText primary={command} />
1045
+ </ListItem>
1046
+ ))}
1047
+ </List>
1048
+
1049
+ <TextField
1050
+ fullWidth
1051
+ label={trans.__('Add New Command')}
1052
+ placeholder={trans.__('e.g., notebook:run-cell')}
1053
+ onKeyDown={e => {
1054
+ if (e.key === 'Enter') {
1055
+ const value = (
1056
+ e.target as HTMLInputElement
1057
+ ).value.trim();
1058
+ if (
1059
+ value &&
1060
+ !config.commandsRequiringApproval.includes(value)
1061
+ ) {
1062
+ const newCommands = [
1063
+ ...config.commandsRequiringApproval,
1064
+ value
1065
+ ];
1066
+ handleConfigUpdate({
1067
+ commandsRequiringApproval: newCommands
1068
+ });
1069
+ (e.target as HTMLInputElement).value = '';
1070
+ }
1071
+ }
1072
+ }}
1073
+ helperText={trans.__(
1074
+ 'Press Enter to add a command. Common commands: notebook:run-cell, console:execute, fileeditor:run-code'
1075
+ )}
1076
+ />
1077
+ </Box>
1078
+
1079
+ <Divider sx={{ my: 2 }} />
1080
+
1081
+ <Box>
1082
+ <Typography variant="body1" gutterBottom>
1083
+ {trans.__('Commands Auto-Rendering MIME Bundles')}
1084
+ </Typography>
1085
+ <Typography
1086
+ variant="caption"
1087
+ color="text.secondary"
1088
+ gutterBottom
1089
+ sx={{ display: 'block' }}
1090
+ >
1091
+ {trans.__(
1092
+ 'Only these execute_command command IDs can auto-render MIME bundle outputs in chat'
1093
+ )}
1094
+ </Typography>
1095
+
1096
+ <List sx={{ mb: 2, maxHeight: 200, overflow: 'auto' }}>
1097
+ {(config.commandsAutoRenderMimeBundles ?? []).map(
1098
+ (command, index) => (
1099
+ <ListItem
1100
+ key={index}
1101
+ divider
1102
+ secondaryAction={
1103
+ <IconButton
1104
+ onClick={() => {
1105
+ const newCommands = [
1106
+ ...(config.commandsAutoRenderMimeBundles ??
1107
+ [])
1108
+ ];
1109
+ newCommands.splice(index, 1);
1110
+ handleConfigUpdate({
1111
+ commandsAutoRenderMimeBundles: newCommands
1112
+ });
1113
+ }}
1114
+ size="small"
1115
+ >
1116
+ <Delete />
1117
+ </IconButton>
1118
+ }
1119
+ >
1120
+ <ListItemText primary={command} />
1121
+ </ListItem>
1122
+ )
1123
+ )}
1124
+ </List>
1125
+
1126
+ <TextField
1127
+ fullWidth
1128
+ label={trans.__('Add Auto-Render Command')}
1129
+ placeholder={trans.__(
1130
+ 'e.g., jupyterlab-ai-commands:execute-in-kernel'
1131
+ )}
1132
+ onKeyDown={e => {
1133
+ if (e.key === 'Enter') {
1134
+ const value = (
1135
+ e.target as HTMLInputElement
1136
+ ).value.trim();
1137
+ const existingCommands =
1138
+ config.commandsAutoRenderMimeBundles ?? [];
1139
+ if (value && !existingCommands.includes(value)) {
1140
+ const newCommands = [...existingCommands, value];
1141
+ handleConfigUpdate({
1142
+ commandsAutoRenderMimeBundles: newCommands
1143
+ });
1144
+ (e.target as HTMLInputElement).value = '';
1145
+ }
1146
+ }
1147
+ }}
1148
+ helperText={trans.__(
1149
+ 'Press Enter to add a command. Default: jupyterlab-ai-commands:execute-in-kernel'
1150
+ )}
1151
+ />
1152
+ </Box>
1153
+
1154
+ <Divider sx={{ my: 2 }} />
1155
+
1156
+ <Box>
1157
+ <Typography variant="body1" gutterBottom>
1158
+ {trans.__('Trusted MIME Types for Auto-Render')}
1159
+ </Typography>
1160
+ <Typography
1161
+ variant="caption"
1162
+ color="text.secondary"
1163
+ gutterBottom
1164
+ sx={{ display: 'block' }}
1165
+ >
1166
+ {trans.__(
1167
+ 'When auto-rendering command outputs, these MIME types are marked trusted in chat'
1168
+ )}
1169
+ </Typography>
1170
+
1171
+ <List sx={{ mb: 2, maxHeight: 200, overflow: 'auto' }}>
1172
+ {(config.trustedMimeTypesForAutoRender ?? []).map(
1173
+ (mimeType, index) => (
1174
+ <ListItem
1175
+ key={index}
1176
+ divider
1177
+ secondaryAction={
1178
+ <IconButton
1179
+ onClick={() => {
1180
+ const newMimeTypes = [
1181
+ ...(config.trustedMimeTypesForAutoRender ??
1182
+ [])
1183
+ ];
1184
+ newMimeTypes.splice(index, 1);
1185
+ handleConfigUpdate({
1186
+ trustedMimeTypesForAutoRender: newMimeTypes
1187
+ });
1188
+ }}
1189
+ size="small"
1190
+ >
1191
+ <Delete />
1192
+ </IconButton>
1193
+ }
1194
+ >
1195
+ <ListItemText primary={mimeType} />
1196
+ </ListItem>
1197
+ )
1198
+ )}
1199
+ </List>
1200
+
1201
+ <TextField
1202
+ fullWidth
1203
+ label={trans.__('Add Trusted MIME Type')}
1204
+ placeholder={trans.__('e.g., text/html')}
1205
+ onKeyDown={e => {
1206
+ if (e.key === 'Enter') {
1207
+ const value = (
1208
+ e.target as HTMLInputElement
1209
+ ).value.trim();
1210
+ const existingMimeTypes =
1211
+ config.trustedMimeTypesForAutoRender ?? [];
1212
+ if (value && !existingMimeTypes.includes(value)) {
1213
+ const newMimeTypes = [...existingMimeTypes, value];
1214
+ handleConfigUpdate({
1215
+ trustedMimeTypesForAutoRender: newMimeTypes
1216
+ });
1217
+ (e.target as HTMLInputElement).value = '';
1218
+ }
1219
+ }
1220
+ }}
1221
+ helperText={trans.__(
1222
+ 'Press Enter to add a MIME type. Default: text/html'
1223
+ )}
1224
+ />
1225
+ </Box>
1226
+ </Box>
1227
+ </CardContent>
1228
+ </Card>
1229
+ )}
1230
+
1231
+ {activeTab === 2 && (
1232
+ <Card elevation={2}>
1233
+ <CardContent>
1234
+ {McpServerRenderer && <McpServerRenderer />}
1235
+ </CardContent>
1236
+ </Card>
1237
+ )}
1238
+
1239
+ {/* Provider Configuration Dialog */}
1240
+ <ProviderConfigDialog
1241
+ open={dialogOpen}
1242
+ onClose={() => setDialogOpen(false)}
1243
+ onSave={editingProvider ? handleEditProvider : handleAddProvider}
1244
+ initialConfig={editingProvider}
1245
+ mode={editingProvider ? 'edit' : 'add'}
1246
+ providerRegistry={providerRegistry}
1247
+ handleSecretField={handleSecretField}
1248
+ trans={trans}
1249
+ />
1250
+
1251
+ {/* Provider Menu */}
1252
+ <Menu
1253
+ anchorEl={menuAnchor}
1254
+ open={Boolean(menuAnchor)}
1255
+ onClose={handleMenuClose}
1256
+ >
1257
+ <MenuItem
1258
+ onClick={() => {
1259
+ const provider = config.providers.find(
1260
+ p => p.id === menuProviderId
1261
+ );
1262
+ if (provider) {
1263
+ openEditDialog(provider);
1264
+ }
1265
+ }}
1266
+ >
1267
+ <Edit sx={{ mr: 1 }} />
1268
+ {trans.__('Edit')}
1269
+ </MenuItem>
1270
+ <MenuItem
1271
+ onClick={() => handleDeleteProvider(menuProviderId)}
1272
+ sx={{ color: 'error.main' }}
1273
+ >
1274
+ <Delete sx={{ mr: 1 }} />
1275
+ {trans.__('Delete')}
1276
+ </MenuItem>
1277
+ </Menu>
1278
+ </Box>
1279
+ </ThemeProvider>
1280
+ );
1281
+ };
1282
+
1283
+ /**
1284
+ * Namespace for AISettingsWidget types and interfaces
1285
+ */
1286
+ export namespace AISettingsWidget {
1287
+ /**
1288
+ * Options interface for constructing an AISettingsWidget
1289
+ */
1290
+ export interface IOptions {
1291
+ settingsModel: IAISettingsModel;
1292
+ agentManagerFactory?: IAgentManagerFactory;
1293
+ themeManager?: IThemeManager;
1294
+ providerRegistry: IProviderRegistry;
1295
+ /**
1296
+ * Access to provider secrets in the shared namespace.
1297
+ */
1298
+ secretsAccess: IAISecretsAccess;
1299
+ /**
1300
+ * The application language translation bundle.
1301
+ */
1302
+ trans: TranslationBundle;
1303
+ /**
1304
+ * The renderer for the MCP settings.
1305
+ */
1306
+ mcpServerRenderer?: React.ComponentType<any>;
1307
+ }
1308
+ }