@plmbr/notebook-intelligence 5.0.0 → 5.1.0-a.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/lib/api.d.ts +5 -0
- package/lib/api.js +7 -0
- package/lib/chat-sidebar.js +166 -65
- package/lib/components/markdown-link.d.ts +33 -0
- package/lib/components/markdown-link.js +114 -0
- package/lib/components/safe-anchor.d.ts +25 -0
- package/lib/components/safe-anchor.js +33 -0
- package/lib/components/tool-call-card.d.ts +26 -0
- package/lib/components/tool-call-card.js +54 -0
- package/lib/components/tool-call-group.d.ts +11 -0
- package/lib/components/tool-call-group.js +33 -0
- package/lib/icons.d.ts +3 -0
- package/lib/icons.js +3 -0
- package/lib/index.js +5 -7
- package/lib/markdown-renderer.js +23 -1
- package/lib/tokens.d.ts +1 -0
- package/lib/tokens.js +1 -0
- package/lib/tool-call-stream.d.ts +24 -0
- package/lib/tool-call-stream.js +28 -0
- package/lib/utils.d.ts +9 -0
- package/lib/utils.js +22 -2
- package/package.json +7 -1
- package/src/api.ts +8 -0
- package/src/chat-sidebar.tsx +203 -93
- package/src/components/markdown-link.tsx +161 -0
- package/src/components/safe-anchor.tsx +60 -0
- package/src/components/tool-call-card.tsx +146 -0
- package/src/components/tool-call-group.tsx +65 -0
- package/src/icons.ts +3 -0
- package/src/index.ts +6 -6
- package/src/markdown-renderer.tsx +30 -0
- package/src/tokens.ts +1 -0
- package/src/tool-call-stream.ts +44 -0
- package/src/utils.ts +25 -2
- package/style/base.css +173 -4
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ NBI reloads open document tabs when their files change on disk, so edits an AI a
|
|
|
159
159
|
|
|
160
160
|
## Configuration
|
|
161
161
|
|
|
162
|
-
Configure your provider, model, and API key from NBI Settings — the gear icon in the chat panel, the `/settings` chat command, or the JupyterLab command palette. For background, see the [provider blog post](https://
|
|
162
|
+
Configure your provider, model, and API key from NBI Settings — the gear icon in the chat panel, the `/settings` chat command, or the JupyterLab command palette. For background, see the [provider blog post](https://plmbr.dev/blog/archive/support-for-any-llm-provider/).
|
|
163
163
|
|
|
164
164
|
<img src="media/provider-list.png" alt="Settings dialog" width=500 />
|
|
165
165
|
|
|
@@ -383,6 +383,12 @@ jupyter lab --NotebookIntelligence.enable_chat_feedback=true
|
|
|
383
383
|
|
|
384
384
|
The feedback fires an in-process `telemetry` event. Nothing leaves the process by default — see the [admin guide](docs/admin-guide.md#chat-feedback-event-hook) for how to wire it into your observability stack.
|
|
385
385
|
|
|
386
|
+
The thumbs buttons reveal on hover by default. To keep them always visible, enable:
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
c.NotebookIntelligence.enable_chat_feedback_always_visible = True
|
|
390
|
+
```
|
|
391
|
+
|
|
386
392
|
<img src="media/chat-feedback.png" alt="Chat feedback" width=500 />
|
|
387
393
|
|
|
388
394
|
## Documentation
|
|
@@ -398,10 +404,10 @@ The feedback fires an in-process `telemetry` event. Nothing leaves the process b
|
|
|
398
404
|
|
|
399
405
|
## Further reading
|
|
400
406
|
|
|
401
|
-
- [Introducing Notebook Intelligence!](https://
|
|
402
|
-
- [Building AI Extensions for JupyterLab](https://
|
|
403
|
-
- [Building AI Agents for JupyterLab](https://
|
|
404
|
-
- [Notebook Intelligence now supports any LLM Provider and AI Model!](https://
|
|
407
|
+
- [Introducing Notebook Intelligence!](https://plmbr.dev/blog/archive/introducing-notebook-intelligence/)
|
|
408
|
+
- [Building AI Extensions for JupyterLab](https://plmbr.dev/blog/archive/building-ai-extensions-for-jupyterlab/)
|
|
409
|
+
- [Building AI Agents for JupyterLab](https://plmbr.dev/blog/archive/building-ai-agents-for-jupyterlab/)
|
|
410
|
+
- [Notebook Intelligence now supports any LLM Provider and AI Model!](https://plmbr.dev/blog/archive/support-for-any-llm-provider/)
|
|
405
411
|
|
|
406
412
|
## Roadmap
|
|
407
413
|
|
package/lib/api.d.ts
CHANGED
|
@@ -174,6 +174,10 @@ export declare class NBIConfig {
|
|
|
174
174
|
getMCPServerPrompt(serverId: string, promptName: string): any;
|
|
175
175
|
get mcpServerSettings(): any;
|
|
176
176
|
get claudeSettings(): any;
|
|
177
|
+
get spinnerVerbs(): {
|
|
178
|
+
mode: string;
|
|
179
|
+
verbs: string[];
|
|
180
|
+
} | null;
|
|
177
181
|
get claudeModels(): IClaudeModelInfo[];
|
|
178
182
|
get isInClaudeCodeMode(): boolean;
|
|
179
183
|
get isClaudeCliAvailable(): boolean;
|
|
@@ -183,6 +187,7 @@ export declare class NBIConfig {
|
|
|
183
187
|
get isCodexCliAvailable(): boolean;
|
|
184
188
|
isCodingAgentLauncherDisabledByPolicy(launcherId: string): boolean;
|
|
185
189
|
get chatFeedbackEnabled(): boolean;
|
|
190
|
+
get chatFeedbackAlwaysVisible(): boolean;
|
|
186
191
|
get tourOverrides(): Record<string, any>;
|
|
187
192
|
get allowGithubSkillImport(): boolean;
|
|
188
193
|
get additionalSkippedWorkspaceDirectories(): string[];
|
package/lib/api.js
CHANGED
|
@@ -142,6 +142,10 @@ export class NBIConfig {
|
|
|
142
142
|
get claudeSettings() {
|
|
143
143
|
return this.capabilities.claude_settings;
|
|
144
144
|
}
|
|
145
|
+
get spinnerVerbs() {
|
|
146
|
+
var _b;
|
|
147
|
+
return (_b = this.capabilities.spinner_verbs) !== null && _b !== void 0 ? _b : null;
|
|
148
|
+
}
|
|
145
149
|
get claudeModels() {
|
|
146
150
|
var _b;
|
|
147
151
|
return ((_b = this.capabilities.claude_models) !== null && _b !== void 0 ? _b : []).map(claudeModelFromWire);
|
|
@@ -181,6 +185,9 @@ export class NBIConfig {
|
|
|
181
185
|
get chatFeedbackEnabled() {
|
|
182
186
|
return this.capabilities.chat_feedback_enabled === true;
|
|
183
187
|
}
|
|
188
|
+
get chatFeedbackAlwaysVisible() {
|
|
189
|
+
return this.capabilities.chat_feedback_always_visible === true;
|
|
190
|
+
}
|
|
184
191
|
// Admin-supplied tour-copy overrides, served from the capabilities
|
|
185
192
|
// response after server-side validation. Returns the raw dict; the
|
|
186
193
|
// tour module decides how to apply it. Defaults to a shared frozen
|
package/lib/chat-sidebar.js
CHANGED
|
@@ -13,12 +13,15 @@ import copySvgstr from '../style/icons/copy.svg';
|
|
|
13
13
|
import copilotSvgstr from '../style/icons/copilot.svg';
|
|
14
14
|
import copilotWarningSvgstr from '../style/icons/copilot-warning.svg';
|
|
15
15
|
import { VscSend, VscStopCircle, VscEye, VscEyeClosed, VscAdd, VscClose, VscHistory, VscTriangleRight, VscTriangleDown, VscSettingsGear, VscPassFilled, VscTools, VscTrash, VscThumbsup, VscThumbsdown, VscThumbsupFilled, VscThumbsdownFilled, VscCloudUpload, VscFile, VscRefresh } from './icons';
|
|
16
|
-
import { extractLLMGeneratedCode, isDarkTheme,
|
|
16
|
+
import { extractLLMGeneratedCode, isDarkTheme, writeTextToClipboard } from './utils';
|
|
17
17
|
import { CheckBoxItem } from './components/checkbox';
|
|
18
|
+
import { SafeAnchor } from './components/safe-anchor';
|
|
18
19
|
import { mcpServerSettingsToEnabledState } from './components/mcp-util';
|
|
19
20
|
import claudeSvgStr from '../style/icons/claude.svg';
|
|
20
21
|
import { AskUserQuestion } from './components/ask-user-question';
|
|
21
22
|
import { ClaudeSessionPicker } from './components/claude-session-picker';
|
|
23
|
+
import { ToolCallGroup } from './components/tool-call-group';
|
|
24
|
+
import { upsertToolCallContent } from './tool-call-stream';
|
|
22
25
|
import { TourOverlay } from './tour/tour-overlay';
|
|
23
26
|
import { TOUR_ANCHOR } from './tour/tour-anchors';
|
|
24
27
|
import { TOUR_START_EVENT, TOUR_STOP_EVENT } from './tour/tour-events';
|
|
@@ -281,8 +284,81 @@ function ChatResponseHTMLFrame(props) {
|
|
|
281
284
|
}
|
|
282
285
|
// Memoize ChatResponse for performance
|
|
283
286
|
function ChatResponse(props) {
|
|
284
|
-
var _a, _b, _c;
|
|
287
|
+
var _a, _b, _c, _d;
|
|
285
288
|
const [renderCount, setRenderCount] = useState(0);
|
|
289
|
+
const shuffledOrder = useRef([]);
|
|
290
|
+
const shufflePos = useRef(0);
|
|
291
|
+
const _spinnerVerbs = NBIAPI.config.isInClaudeCodeMode
|
|
292
|
+
? ((_a = NBIAPI.config.spinnerVerbs) !== null && _a !== void 0 ? _a : null)
|
|
293
|
+
: null;
|
|
294
|
+
const hasCustomVerbs = (_spinnerVerbs === null || _spinnerVerbs === void 0 ? void 0 : _spinnerVerbs.mode) === 'replace' &&
|
|
295
|
+
Array.isArray(_spinnerVerbs.verbs) &&
|
|
296
|
+
_spinnerVerbs.verbs.length > 0;
|
|
297
|
+
// Fisher-Yates shuffle. When `avoidFirst` is set, swap it out of
|
|
298
|
+
// position 0 so the new first verb is never the same as the last shown
|
|
299
|
+
// (prevents an identical repeat at the wrap point between passes).
|
|
300
|
+
const shuffleVerbs = (verbs, avoidFirst) => {
|
|
301
|
+
const order = verbs.map((_, i) => i);
|
|
302
|
+
for (let i = order.length - 1; i > 0; i--) {
|
|
303
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
304
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
305
|
+
}
|
|
306
|
+
if (avoidFirst !== undefined &&
|
|
307
|
+
order[0] === avoidFirst &&
|
|
308
|
+
order.length > 1) {
|
|
309
|
+
[order[0], order[1]] = [order[1], order[0]];
|
|
310
|
+
}
|
|
311
|
+
return order;
|
|
312
|
+
};
|
|
313
|
+
// Initialize the shuffle synchronously in useState so the correct verb
|
|
314
|
+
// is shown on the very first paint. A useEffect-based init fires after
|
|
315
|
+
// paint and causes a single-frame flash of verbs[0] before correcting.
|
|
316
|
+
const [verbIndex, setVerbIndex] = useState(() => {
|
|
317
|
+
var _a;
|
|
318
|
+
const sv = NBIAPI.config.isInClaudeCodeMode
|
|
319
|
+
? ((_a = NBIAPI.config.spinnerVerbs) !== null && _a !== void 0 ? _a : null)
|
|
320
|
+
: null;
|
|
321
|
+
if (!sv ||
|
|
322
|
+
sv.mode !== 'replace' ||
|
|
323
|
+
!Array.isArray(sv.verbs) ||
|
|
324
|
+
sv.verbs.length === 0) {
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
const order = shuffleVerbs(sv.verbs);
|
|
328
|
+
shuffledOrder.current = order;
|
|
329
|
+
shufflePos.current = 0;
|
|
330
|
+
return order[0];
|
|
331
|
+
});
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!props.showGenerating || !hasCustomVerbs) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const verbs = _spinnerVerbs.verbs;
|
|
337
|
+
// Shuffle already initialized by useState on mount. Only re-initialize
|
|
338
|
+
// if hasCustomVerbs just became true after a capabilities refresh
|
|
339
|
+
// (shuffledOrder would be empty because the lazy init found no verbs).
|
|
340
|
+
if (shuffledOrder.current.length === 0) {
|
|
341
|
+
shuffledOrder.current = shuffleVerbs(verbs);
|
|
342
|
+
shufflePos.current = 0;
|
|
343
|
+
setVerbIndex(shuffledOrder.current[0]);
|
|
344
|
+
}
|
|
345
|
+
let id;
|
|
346
|
+
const scheduleNext = () => {
|
|
347
|
+
const delay = 4000 + Math.random() * 3000;
|
|
348
|
+
id = setTimeout(() => {
|
|
349
|
+
shufflePos.current++;
|
|
350
|
+
if (shufflePos.current >= shuffledOrder.current.length) {
|
|
351
|
+
const lastShown = shuffledOrder.current[shuffledOrder.current.length - 1];
|
|
352
|
+
shuffledOrder.current = shuffleVerbs(verbs, lastShown);
|
|
353
|
+
shufflePos.current = 0;
|
|
354
|
+
}
|
|
355
|
+
setVerbIndex(shuffledOrder.current[shufflePos.current]);
|
|
356
|
+
scheduleNext();
|
|
357
|
+
}, delay);
|
|
358
|
+
};
|
|
359
|
+
scheduleNext();
|
|
360
|
+
return () => clearTimeout(id);
|
|
361
|
+
}, [props.showGenerating, hasCustomVerbs]);
|
|
286
362
|
const msg = props.message;
|
|
287
363
|
const timestamp = msg.date.toLocaleTimeString('en-US', { hour12: false });
|
|
288
364
|
const openNotebook = (event) => {
|
|
@@ -376,6 +452,19 @@ function ChatResponse(props) {
|
|
|
376
452
|
lastItem.reasoningFinished = true;
|
|
377
453
|
}
|
|
378
454
|
}
|
|
455
|
+
else if (item.type === ResponseStreamDataType.ToolCall &&
|
|
456
|
+
lastItemType === ResponseStreamDataType.ToolCall) {
|
|
457
|
+
// Bundle a run of consecutive tool calls into one group item so the
|
|
458
|
+
// renderer can collapse them; ToolCallGroup unwraps content.toolCalls.
|
|
459
|
+
const lastItem = groupedContents[groupedContents.length - 1];
|
|
460
|
+
lastItem.content.toolCalls.push(structuredClone(item.content));
|
|
461
|
+
}
|
|
462
|
+
else if (item.type === ResponseStreamDataType.ToolCall) {
|
|
463
|
+
const grouped = structuredClone(item);
|
|
464
|
+
grouped.content = { toolCalls: [structuredClone(item.content)] };
|
|
465
|
+
groupedContents.push(grouped);
|
|
466
|
+
lastItemType = item.type;
|
|
467
|
+
}
|
|
379
468
|
else {
|
|
380
469
|
groupedContents.push(structuredClone(item));
|
|
381
470
|
lastItemType = item.type;
|
|
@@ -417,28 +506,35 @@ function ChatResponse(props) {
|
|
|
417
506
|
? 'Thought'
|
|
418
507
|
: `Thinking (${Math.floor(item.reasoningTime)} s)`;
|
|
419
508
|
};
|
|
420
|
-
const chatParticipantId = ((
|
|
509
|
+
const chatParticipantId = ((_b = msg.participant) === null || _b === void 0 ? void 0 : _b.id) || 'default';
|
|
421
510
|
return (React.createElement("div", { className: `chat-message chat-message-${msg.from}`, "data-render-count": renderCount },
|
|
422
511
|
React.createElement("div", { className: "chat-message-header" },
|
|
423
512
|
React.createElement("div", { className: "chat-message-from" },
|
|
424
|
-
((
|
|
513
|
+
((_c = msg.participant) === null || _c === void 0 ? void 0 : _c.iconPath) && (React.createElement("div", { className: `chat-message-from-icon chat-message-from-icon-${chatParticipantId} ${isDarkTheme() ? 'dark' : ''}` },
|
|
425
514
|
React.createElement("img", { src: msg.participant.iconPath, alt: "" }))),
|
|
426
515
|
React.createElement("div", { className: "chat-message-from-title" }, msg.from === 'user'
|
|
427
516
|
? 'User'
|
|
428
|
-
: ((
|
|
517
|
+
: ((_d = msg.participant) === null || _d === void 0 ? void 0 : _d.name) || 'AI Assistant'),
|
|
429
518
|
React.createElement("div", { className: "chat-message-from-progress", style: { display: `${props.showGenerating ? 'visible' : 'none'}` } },
|
|
430
519
|
React.createElement("span", {
|
|
431
520
|
// Key on the heartbeat tick so React re-mounts the dot on
|
|
432
521
|
// every beat; CSS-animation restart from an attribute-only
|
|
433
522
|
// change is not reliable across browsers.
|
|
434
523
|
key: props.heartbeatTick, className: `generating-pulse${props.isStalled ? ' is-stalled' : ''}`, "aria-hidden": "true" }),
|
|
435
|
-
React.createElement("div", { className: "generating-label", "aria-
|
|
524
|
+
React.createElement("div", { className: "generating-label", "aria-hidden": "true" },
|
|
436
525
|
props.isStalled
|
|
437
526
|
? 'Still working, server may be slow'
|
|
438
|
-
:
|
|
527
|
+
: hasCustomVerbs
|
|
528
|
+
? _spinnerVerbs.verbs[verbIndex]
|
|
529
|
+
: 'Generating',
|
|
439
530
|
props.showGenerating && props.elapsedSeconds > 0
|
|
440
531
|
? ` (${formatElapsedSeconds(props.elapsedSeconds)})`
|
|
441
|
-
: '')
|
|
532
|
+
: ''),
|
|
533
|
+
React.createElement("div", { className: "nbi-sr-only", "aria-live": "polite", "aria-atomic": "true" }, props.isStalled
|
|
534
|
+
? 'Still working, server may be slow'
|
|
535
|
+
: hasCustomVerbs
|
|
536
|
+
? _spinnerVerbs.verbs[verbIndex]
|
|
537
|
+
: 'Generating'))),
|
|
442
538
|
React.createElement("div", { className: "chat-message-timestamp" }, timestamp)),
|
|
443
539
|
React.createElement("div", { className: "chat-message-content" },
|
|
444
540
|
groupedContents.map((item, index) => {
|
|
@@ -474,17 +570,8 @@ function ChatResponse(props) {
|
|
|
474
570
|
React.createElement("button", { className: "jp-Dialog-button jp-mod-accept jp-mod-styled", onClick: () => runCommand(item.content.commandId, item.content.args) },
|
|
475
571
|
React.createElement("div", { className: "jp-Dialog-buttonLabel" }, item.content.title))));
|
|
476
572
|
case ResponseStreamDataType.Anchor: {
|
|
477
|
-
const safeUri = safeAnchorUri(item.content.uri);
|
|
478
|
-
if (!safeUri) {
|
|
479
|
-
return (React.createElement("div", { className: "chat-response-anchor", key: `key-${index}` },
|
|
480
|
-
React.createElement("span", null,
|
|
481
|
-
item.content.title,
|
|
482
|
-
React.createElement("span", { className: "nbi-sr-only" }, " (link blocked)"))));
|
|
483
|
-
}
|
|
484
573
|
return (React.createElement("div", { className: "chat-response-anchor", key: `key-${index}` },
|
|
485
|
-
React.createElement(
|
|
486
|
-
item.content.title,
|
|
487
|
-
React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)"))));
|
|
574
|
+
React.createElement(SafeAnchor, { href: item.content.uri }, item.content.title)));
|
|
488
575
|
}
|
|
489
576
|
case ResponseStreamDataType.Progress:
|
|
490
577
|
// Render only the most recent progress entry, and only while
|
|
@@ -495,6 +582,12 @@ function ChatResponse(props) {
|
|
|
495
582
|
// ✗ for error) rather than forcing a single rendering here.
|
|
496
583
|
return index === groupedContents.length - 1 &&
|
|
497
584
|
props.showGenerating ? (React.createElement("div", { className: "chat-response-progress", key: `key-${index}` }, item.content)) : null;
|
|
585
|
+
case ResponseStreamDataType.ToolCall:
|
|
586
|
+
// Unlike Progress, tool-call cards persist after the turn ends,
|
|
587
|
+
// so they render regardless of `showGenerating`. The grouping
|
|
588
|
+
// pass bundled this run's calls into content.toolCalls; the
|
|
589
|
+
// group renders a single card or a collapsible group.
|
|
590
|
+
return (React.createElement(ToolCallGroup, { key: `key-${index}`, toolCalls: item.content.toolCalls }));
|
|
498
591
|
case ResponseStreamDataType.Confirmation:
|
|
499
592
|
return answeredForms.get(item.id) ===
|
|
500
593
|
'confirmed' ? null : answeredForms.get(item.id) ===
|
|
@@ -548,8 +641,8 @@ function ChatResponse(props) {
|
|
|
548
641
|
}),
|
|
549
642
|
msg.notebookLink && (React.createElement("button", { type: "button", className: "copilot-generated-notebook-link", "data-ref": msg.notebookLink, "aria-label": `Open notebook ${msg.notebookLink}`, onClick: openNotebook }, "open notebook"))),
|
|
550
643
|
msg.from === 'copilot' &&
|
|
551
|
-
!props.showGenerating &&
|
|
552
|
-
NBIAPI.config.chatFeedbackEnabled && (React.createElement("div", { className:
|
|
644
|
+
(NBIAPI.config.chatFeedbackAlwaysVisible || !props.showGenerating) &&
|
|
645
|
+
NBIAPI.config.chatFeedbackEnabled && (React.createElement("div", { className: `chat-message-feedback${NBIAPI.config.chatFeedbackAlwaysVisible ? ' always-visible' : ''}` },
|
|
553
646
|
React.createElement("button", { className: `chat-feedback-btn ${msg.feedback === 'positive' ? 'selected' : ''}`, onClick: () => {
|
|
554
647
|
var _a;
|
|
555
648
|
props.onFeedback(msg.id, 'positive');
|
|
@@ -566,7 +659,7 @@ function ChatResponse(props) {
|
|
|
566
659
|
}
|
|
567
660
|
});
|
|
568
661
|
}
|
|
569
|
-
}, "aria-label": "Rate as
|
|
662
|
+
}, "aria-label": "Rate response as good", "aria-pressed": msg.feedback === 'positive', title: "Good response" }, msg.feedback === 'positive' ? (React.createElement(VscThumbsupFilled, null)) : (React.createElement(VscThumbsup, null))),
|
|
570
663
|
React.createElement("button", { className: `chat-feedback-btn ${msg.feedback === 'negative' ? 'selected' : ''}`, onClick: () => {
|
|
571
664
|
var _a;
|
|
572
665
|
props.onFeedback(msg.id, 'negative');
|
|
@@ -583,7 +676,7 @@ function ChatResponse(props) {
|
|
|
583
676
|
}
|
|
584
677
|
});
|
|
585
678
|
}
|
|
586
|
-
}, "aria-label": "Rate as
|
|
679
|
+
}, "aria-label": "Rate response as bad", "aria-pressed": msg.feedback === 'negative', title: "Bad response" }, msg.feedback === 'negative' ? (React.createElement(VscThumbsdownFilled, null)) : (React.createElement(VscThumbsdown, null)))))));
|
|
587
680
|
}
|
|
588
681
|
const MemoizedChatResponse = memo(ChatResponse);
|
|
589
682
|
async function submitCompletionRequest(request, responseEmitter) {
|
|
@@ -2131,21 +2224,29 @@ function SidebarComponent(props) {
|
|
|
2131
2224
|
}
|
|
2132
2225
|
if (delta['nbiContent']) {
|
|
2133
2226
|
const nbiContent = delta['nbiContent'];
|
|
2134
|
-
|
|
2135
|
-
id
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
nbiContent.
|
|
2144
|
-
|
|
2145
|
-
:
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2227
|
+
if (nbiContent.type === ResponseStreamDataType.ToolCall) {
|
|
2228
|
+
// A tool call streams twice under one id (start, then finish);
|
|
2229
|
+
// merge by id so it stays one persistent card. See
|
|
2230
|
+
// upsertToolCallContent.
|
|
2231
|
+
upsertToolCallContent(contents, nbiContent.content, new Date(response.created));
|
|
2232
|
+
}
|
|
2233
|
+
else {
|
|
2234
|
+
contents.push({
|
|
2235
|
+
id: UUID.uuid4(),
|
|
2236
|
+
type: nbiContent.type,
|
|
2237
|
+
content: nbiContent.content || '',
|
|
2238
|
+
reasoningContent: nbiContent.reasoning_content || '',
|
|
2239
|
+
reasoningTag: nbiContent.reasoning_content
|
|
2240
|
+
? '<think>'
|
|
2241
|
+
: undefined,
|
|
2242
|
+
reasoningFinished: nbiContent.type === ResponseStreamDataType.Markdown &&
|
|
2243
|
+
nbiContent.reasoning_content
|
|
2244
|
+
? true
|
|
2245
|
+
: false,
|
|
2246
|
+
contentDetail: nbiContent.detail,
|
|
2247
|
+
created: new Date(response.created)
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2149
2250
|
}
|
|
2150
2251
|
else {
|
|
2151
2252
|
responseMessage =
|
|
@@ -2505,21 +2606,29 @@ function SidebarComponent(props) {
|
|
|
2505
2606
|
}
|
|
2506
2607
|
if (delta['nbiContent']) {
|
|
2507
2608
|
const nbiContent = delta['nbiContent'];
|
|
2508
|
-
|
|
2509
|
-
id
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
nbiContent.
|
|
2518
|
-
|
|
2519
|
-
:
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2609
|
+
if (nbiContent.type === ResponseStreamDataType.ToolCall) {
|
|
2610
|
+
// A tool call streams twice under one id (start, then finish);
|
|
2611
|
+
// merge by id so it stays one persistent card. See
|
|
2612
|
+
// upsertToolCallContent.
|
|
2613
|
+
upsertToolCallContent(contents, nbiContent.content, new Date(response.created));
|
|
2614
|
+
}
|
|
2615
|
+
else {
|
|
2616
|
+
contents.push({
|
|
2617
|
+
id: UUID.uuid4(),
|
|
2618
|
+
type: nbiContent.type,
|
|
2619
|
+
content: nbiContent.content || '',
|
|
2620
|
+
reasoningContent: nbiContent.reasoning_content || '',
|
|
2621
|
+
reasoningTag: nbiContent.reasoning_content
|
|
2622
|
+
? '<think>'
|
|
2623
|
+
: undefined,
|
|
2624
|
+
reasoningFinished: nbiContent.type === ResponseStreamDataType.Markdown &&
|
|
2625
|
+
nbiContent.reasoning_content
|
|
2626
|
+
? true
|
|
2627
|
+
: false,
|
|
2628
|
+
contentDetail: nbiContent.detail,
|
|
2629
|
+
created: new Date(response.created)
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2523
2632
|
}
|
|
2524
2633
|
else {
|
|
2525
2634
|
const responseMessage = (_e = (_d = (_c = response.data['choices']) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d['delta']) === null || _e === void 0 ? void 0 : _e['content'];
|
|
@@ -3369,28 +3478,20 @@ function GitHubCopilotLoginDialogBodyComponent(props) {
|
|
|
3369
3478
|
ghLoginStatus === GitHubCopilotLoginStatus.NotLoggedIn && (React.createElement(React.Fragment, null,
|
|
3370
3479
|
React.createElement("div", null, "Your code and data are directly transferred to GitHub Copilot as needed without storing any copies other than keeping in the process memory."),
|
|
3371
3480
|
React.createElement("div", null,
|
|
3372
|
-
React.createElement(
|
|
3373
|
-
"GitHub Copilot",
|
|
3374
|
-
React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
|
|
3481
|
+
React.createElement(SafeAnchor, { href: "https://github.com/features/copilot" }, "GitHub Copilot"),
|
|
3375
3482
|
' ',
|
|
3376
3483
|
"requires a subscription and it has a free tier. GitHub Copilot is subject to the",
|
|
3377
3484
|
' ',
|
|
3378
|
-
React.createElement(
|
|
3379
|
-
"GitHub Terms for Additional Products and Features",
|
|
3380
|
-
React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
|
|
3485
|
+
React.createElement(SafeAnchor, { href: "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features" }, "GitHub Terms for Additional Products and Features"),
|
|
3381
3486
|
"."),
|
|
3382
3487
|
React.createElement("div", null,
|
|
3383
3488
|
React.createElement("h4", null, "Privacy and terms"),
|
|
3384
3489
|
"By using Notebook Intelligence with GitHub Copilot subscription you agree to",
|
|
3385
3490
|
' ',
|
|
3386
|
-
React.createElement(
|
|
3387
|
-
"GitHub Copilot chat terms",
|
|
3388
|
-
React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
|
|
3491
|
+
React.createElement(SafeAnchor, { href: "https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide" }, "GitHub Copilot chat terms"),
|
|
3389
3492
|
". Review the terms to understand about usage, limitations and ways to improve GitHub Copilot. Please review",
|
|
3390
3493
|
' ',
|
|
3391
|
-
React.createElement(
|
|
3392
|
-
"Privacy Statement",
|
|
3393
|
-
React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
|
|
3494
|
+
React.createElement(SafeAnchor, { href: "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" }, "Privacy Statement"),
|
|
3394
3495
|
"."),
|
|
3395
3496
|
React.createElement("div", null,
|
|
3396
3497
|
React.createElement("button", { className: "jp-Dialog-button jp-mod-accept jp-mod-reject jp-mod-styled", onClick: handleLoginClick },
|
|
@@ -3415,7 +3516,7 @@ function GitHubCopilotLoginDialogBodyComponent(props) {
|
|
|
3415
3516
|
' ',
|
|
3416
3517
|
"and enter at",
|
|
3417
3518
|
' ',
|
|
3418
|
-
React.createElement(
|
|
3519
|
+
React.createElement(SafeAnchor, { href: deviceActivationURL }, deviceActivationURL),
|
|
3419
3520
|
' ',
|
|
3420
3521
|
"to allow access to GitHub Copilot from this app. Activation could take up to a minute after you enter the code."))),
|
|
3421
3522
|
ghLoginStatus === GitHubCopilotLoginStatus.ActivatingDevice && (React.createElement("div", { style: { marginTop: '10px' } },
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
3
|
+
type MarkdownLinkProps = {
|
|
4
|
+
app: JupyterFrontEnd;
|
|
5
|
+
baseDir: string;
|
|
6
|
+
href: unknown;
|
|
7
|
+
title?: unknown;
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Render an anchor node coming out of `react-markdown` so chat-sidebar
|
|
12
|
+
* links can never replace the JupyterLab shell or pivot through the
|
|
13
|
+
* lab origin.
|
|
14
|
+
*
|
|
15
|
+
* Three branches:
|
|
16
|
+
* - Fragment-only (`#section`): inert plain text. A new-tab open would
|
|
17
|
+
* navigate to `about:blank#section`, and a same-tab open would scroll
|
|
18
|
+
* the wrong document; neither matches what the LLM meant.
|
|
19
|
+
* - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
|
|
20
|
+
* resolved against the active document's directory, re-validated,
|
|
21
|
+
* and routed through JupyterLab's `docmanager:open` command so a
|
|
22
|
+
* `.ipynb` opens with the notebook factory and a `.md` opens in the
|
|
23
|
+
* editor. The anchor's `href` stays `"#"` because a populated `href`
|
|
24
|
+
* bypasses React's onClick on middle/Cmd-click, letting the browser
|
|
25
|
+
* navigate `/lab/<path>` with session cookies attached; the hover
|
|
26
|
+
* preview moves to `title` so the user still sees the intended
|
|
27
|
+
* target.
|
|
28
|
+
* - Everything else: handed to `SafeAnchor`, which enforces the
|
|
29
|
+
* `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
|
|
30
|
+
* `rel="noopener noreferrer"`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function MarkdownLink({ app, baseDir, href, title, children }: MarkdownLinkProps): React.ReactElement;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { PathExt } from '@jupyterlab/coreutils';
|
|
4
|
+
import { SafeAnchor } from './safe-anchor';
|
|
5
|
+
import { hasDangerousTextCodepoints } from '../utils';
|
|
6
|
+
// Match an absolute URI by its scheme prefix so a workspace-relative path
|
|
7
|
+
// (`README.md`) is distinguished from a protocol-rooted URL (`http://...`).
|
|
8
|
+
// Mirrors the SCHEME_RE in utils.ts; kept local because this discriminant
|
|
9
|
+
// answers a different question (presence vs. allowlist).
|
|
10
|
+
const SCHEME_PREFIX_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
|
11
|
+
/**
|
|
12
|
+
* True when a freshly-joined workspace path is *not* safe to hand to
|
|
13
|
+
* `docmanager:open` or expose on a rendered anchor. Rejects:
|
|
14
|
+
*
|
|
15
|
+
* - leading `..` segments or absolute paths: the join didn't anchor and
|
|
16
|
+
* the path escapes the Jupyter root (ContentsManager rejects too, but
|
|
17
|
+
* we want to fail closed visually as well so the status bar/title
|
|
18
|
+
* never previews a traversal target),
|
|
19
|
+
* - any embedded scheme: `PathExt.join('', 'java\tscript:alert(1)')`
|
|
20
|
+
* returns the input verbatim, so a path that looks workspace-relative
|
|
21
|
+
* pre-join can unmask into a `javascript:` href when `baseDir` is
|
|
22
|
+
* empty (any active doc at server root),
|
|
23
|
+
* - dangerous codepoints (bidi-override, zero-width, C0/C1/DEL, etc.):
|
|
24
|
+
* the WHATWG URL parser strips these from the scheme during
|
|
25
|
+
* recognition, and they also visually impersonate the link target on
|
|
26
|
+
* hover / in dev-tools logs.
|
|
27
|
+
*/
|
|
28
|
+
function isUnsafeWorkspacePath(path) {
|
|
29
|
+
// Empty / cwd-only paths reach here when react-markdown's built-in
|
|
30
|
+
// `urlTransform` strips an unsafe scheme (`javascript:`, `data:`, ...)
|
|
31
|
+
// to an empty string before our override runs: the result joins to
|
|
32
|
+
// either `""` or `"."`, both of which would render as a dead
|
|
33
|
+
// `<a href="#">` that 404s on click. Surface them as blocked-link
|
|
34
|
+
// spans so the user sees why nothing happened.
|
|
35
|
+
if (path === '' || path === '.' || path === './') {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (path.startsWith('/') || path === '..' || path.startsWith('../')) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (SCHEME_PREFIX_RE.test(path)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (hasDangerousTextCodepoints(path)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Render an anchor node coming out of `react-markdown` so chat-sidebar
|
|
51
|
+
* links can never replace the JupyterLab shell or pivot through the
|
|
52
|
+
* lab origin.
|
|
53
|
+
*
|
|
54
|
+
* Three branches:
|
|
55
|
+
* - Fragment-only (`#section`): inert plain text. A new-tab open would
|
|
56
|
+
* navigate to `about:blank#section`, and a same-tab open would scroll
|
|
57
|
+
* the wrong document; neither matches what the LLM meant.
|
|
58
|
+
* - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
|
|
59
|
+
* resolved against the active document's directory, re-validated,
|
|
60
|
+
* and routed through JupyterLab's `docmanager:open` command so a
|
|
61
|
+
* `.ipynb` opens with the notebook factory and a `.md` opens in the
|
|
62
|
+
* editor. The anchor's `href` stays `"#"` because a populated `href`
|
|
63
|
+
* bypasses React's onClick on middle/Cmd-click, letting the browser
|
|
64
|
+
* navigate `/lab/<path>` with session cookies attached; the hover
|
|
65
|
+
* preview moves to `title` so the user still sees the intended
|
|
66
|
+
* target.
|
|
67
|
+
* - Everything else: handed to `SafeAnchor`, which enforces the
|
|
68
|
+
* `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
|
|
69
|
+
* `rel="noopener noreferrer"`.
|
|
70
|
+
*/
|
|
71
|
+
export function MarkdownLink({ app, baseDir, href, title, children }) {
|
|
72
|
+
if (typeof href === 'string') {
|
|
73
|
+
if (href.startsWith('#')) {
|
|
74
|
+
return React.createElement("span", null, children);
|
|
75
|
+
}
|
|
76
|
+
if (!SCHEME_PREFIX_RE.test(href) &&
|
|
77
|
+
!href.startsWith('/') &&
|
|
78
|
+
!href.startsWith('//')) {
|
|
79
|
+
// PathExt.join: plain concatenation + normalization. Resolve()
|
|
80
|
+
// would fall back to the browser process cwd when `baseDir` is
|
|
81
|
+
// relative, which gives nonsense like `/Users/.../notebooks/...`.
|
|
82
|
+
const resolvedPath = PathExt.join(baseDir, href);
|
|
83
|
+
// Re-validate post-join. Two attack/confusion shapes the pre-check
|
|
84
|
+
// alone misses: `[x](java\tscript:alert(1))` survives the scheme
|
|
85
|
+
// sniff because `\t` isn't a scheme char, then unmasks once the
|
|
86
|
+
// WHATWG parser sees the joined href; `[x](../../../etc/passwd)`
|
|
87
|
+
// looks workspace-relative but escapes the workspace root.
|
|
88
|
+
if (isUnsafeWorkspacePath(resolvedPath)) {
|
|
89
|
+
return (React.createElement(SafeAnchor, { href: null, title: undefined }, children));
|
|
90
|
+
}
|
|
91
|
+
// href="#" rather than href={resolvedPath}: a modifier-click on a
|
|
92
|
+
// populated href bypasses the React onClick, lets the browser
|
|
93
|
+
// navigate the chat sidebar to /lab/<path> in a new tab, and would
|
|
94
|
+
// ride along the user's Jupyter session cookies. The hover preview
|
|
95
|
+
// moves to `title` so the user still sees the intended target.
|
|
96
|
+
const safeTitleFromMd = typeof title === 'string' && !hasDangerousTextCodepoints(title)
|
|
97
|
+
? title
|
|
98
|
+
: undefined;
|
|
99
|
+
const hoverTitle = safeTitleFromMd !== null && safeTitleFromMd !== void 0 ? safeTitleFromMd : resolvedPath;
|
|
100
|
+
const onClick = (e) => {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
// ContentsManager rejects paths outside the Jupyter root with a
|
|
103
|
+
// promise rejection. Catch so the failure surfaces in logs instead
|
|
104
|
+
// of an unhandled rejection, and the user can see the rendered
|
|
105
|
+
// anchor was attempted even when the target doesn't exist.
|
|
106
|
+
Promise.resolve(app.commands.execute('docmanager:open', { path: resolvedPath })).catch(err => {
|
|
107
|
+
console.warn(`NBI: failed to open workspace path "${resolvedPath}":`, err);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
return (React.createElement("a", { href: "#", title: hoverTitle, onClick: onClick }, children));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return (React.createElement(SafeAnchor, { href: typeof href === 'string' ? href : null, title: typeof title === 'string' ? title : undefined }, children));
|
|
114
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
type SafeAnchorProps = {
|
|
3
|
+
href: string | undefined | null;
|
|
4
|
+
children: React.ReactNode;
|
|
5
|
+
title?: string;
|
|
6
|
+
className?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The single render path for anchor elements driven by LLM / tool output.
|
|
10
|
+
*
|
|
11
|
+
* Runs `href` through `safeAnchorUri`, which mirrors the server-side
|
|
12
|
+
* `safe_anchor_uri` allowlist (`http` / `https` / `mailto`) and rejects
|
|
13
|
+
* dangerous codepoints. On accept it renders a `_blank` anchor with
|
|
14
|
+
* `rel="noopener noreferrer"` and an SR-only "(opens in new tab)" suffix;
|
|
15
|
+
* on reject it falls through to plain text plus an SR-only "(link
|
|
16
|
+
* blocked)" note so screen readers can tell why the link disappeared.
|
|
17
|
+
*
|
|
18
|
+
* The `title` attribute is scrubbed for the same dangerous codepoints
|
|
19
|
+
* the URI check rejects, since react-markdown forwards CommonMark
|
|
20
|
+
* `[text](url "title")` titles to the rendered anchor and an LLM can
|
|
21
|
+
* smuggle bidi-override or zero-width characters there to visually
|
|
22
|
+
* impersonate the link target on hover.
|
|
23
|
+
*/
|
|
24
|
+
export declare function SafeAnchor({ href, children, title, className }: SafeAnchorProps): React.ReactElement;
|
|
25
|
+
export {};
|