@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/src/chat-sidebar.tsx
CHANGED
|
@@ -72,14 +72,16 @@ import type { Contents } from '@jupyterlab/services';
|
|
|
72
72
|
import {
|
|
73
73
|
extractLLMGeneratedCode,
|
|
74
74
|
isDarkTheme,
|
|
75
|
-
safeAnchorUri,
|
|
76
75
|
writeTextToClipboard
|
|
77
76
|
} from './utils';
|
|
78
77
|
import { CheckBoxItem } from './components/checkbox';
|
|
78
|
+
import { SafeAnchor } from './components/safe-anchor';
|
|
79
79
|
import { mcpServerSettingsToEnabledState } from './components/mcp-util';
|
|
80
80
|
import claudeSvgStr from '../style/icons/claude.svg';
|
|
81
81
|
import { AskUserQuestion } from './components/ask-user-question';
|
|
82
82
|
import { ClaudeSessionPicker } from './components/claude-session-picker';
|
|
83
|
+
import { ToolCallGroup } from './components/tool-call-group';
|
|
84
|
+
import { upsertToolCallContent } from './tool-call-stream';
|
|
83
85
|
import { TourOverlay } from './tour/tour-overlay';
|
|
84
86
|
import { TOUR_ANCHOR } from './tour/tour-anchors';
|
|
85
87
|
import { TOUR_START_EVENT, TOUR_STOP_EVENT } from './tour/tour-events';
|
|
@@ -522,6 +524,92 @@ function ChatResponseHTMLFrame(props: any) {
|
|
|
522
524
|
// Memoize ChatResponse for performance
|
|
523
525
|
function ChatResponse(props: any) {
|
|
524
526
|
const [renderCount, setRenderCount] = useState(0);
|
|
527
|
+
const shuffledOrder = useRef<number[]>([]);
|
|
528
|
+
const shufflePos = useRef(0);
|
|
529
|
+
|
|
530
|
+
const _spinnerVerbs = NBIAPI.config.isInClaudeCodeMode
|
|
531
|
+
? (NBIAPI.config.spinnerVerbs ?? null)
|
|
532
|
+
: null;
|
|
533
|
+
const hasCustomVerbs =
|
|
534
|
+
_spinnerVerbs?.mode === 'replace' &&
|
|
535
|
+
Array.isArray(_spinnerVerbs.verbs) &&
|
|
536
|
+
_spinnerVerbs.verbs.length > 0;
|
|
537
|
+
|
|
538
|
+
// Fisher-Yates shuffle. When `avoidFirst` is set, swap it out of
|
|
539
|
+
// position 0 so the new first verb is never the same as the last shown
|
|
540
|
+
// (prevents an identical repeat at the wrap point between passes).
|
|
541
|
+
const shuffleVerbs = (verbs: any[], avoidFirst?: number): number[] => {
|
|
542
|
+
const order = verbs.map((_, i) => i);
|
|
543
|
+
for (let i = order.length - 1; i > 0; i--) {
|
|
544
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
545
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
546
|
+
}
|
|
547
|
+
if (
|
|
548
|
+
avoidFirst !== undefined &&
|
|
549
|
+
order[0] === avoidFirst &&
|
|
550
|
+
order.length > 1
|
|
551
|
+
) {
|
|
552
|
+
[order[0], order[1]] = [order[1], order[0]];
|
|
553
|
+
}
|
|
554
|
+
return order;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// Initialize the shuffle synchronously in useState so the correct verb
|
|
558
|
+
// is shown on the very first paint. A useEffect-based init fires after
|
|
559
|
+
// paint and causes a single-frame flash of verbs[0] before correcting.
|
|
560
|
+
const [verbIndex, setVerbIndex] = useState(() => {
|
|
561
|
+
const sv = NBIAPI.config.isInClaudeCodeMode
|
|
562
|
+
? (NBIAPI.config.spinnerVerbs ?? null)
|
|
563
|
+
: null;
|
|
564
|
+
if (
|
|
565
|
+
!sv ||
|
|
566
|
+
sv.mode !== 'replace' ||
|
|
567
|
+
!Array.isArray(sv.verbs) ||
|
|
568
|
+
sv.verbs.length === 0
|
|
569
|
+
) {
|
|
570
|
+
return 0;
|
|
571
|
+
}
|
|
572
|
+
const order = shuffleVerbs(sv.verbs);
|
|
573
|
+
shuffledOrder.current = order;
|
|
574
|
+
shufflePos.current = 0;
|
|
575
|
+
return order[0];
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
useEffect(() => {
|
|
579
|
+
if (!props.showGenerating || !hasCustomVerbs) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const verbs = _spinnerVerbs!.verbs;
|
|
584
|
+
|
|
585
|
+
// Shuffle already initialized by useState on mount. Only re-initialize
|
|
586
|
+
// if hasCustomVerbs just became true after a capabilities refresh
|
|
587
|
+
// (shuffledOrder would be empty because the lazy init found no verbs).
|
|
588
|
+
if (shuffledOrder.current.length === 0) {
|
|
589
|
+
shuffledOrder.current = shuffleVerbs(verbs);
|
|
590
|
+
shufflePos.current = 0;
|
|
591
|
+
setVerbIndex(shuffledOrder.current[0]);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
let id: ReturnType<typeof setTimeout>;
|
|
595
|
+
const scheduleNext = () => {
|
|
596
|
+
const delay = 4000 + Math.random() * 3000;
|
|
597
|
+
id = setTimeout(() => {
|
|
598
|
+
shufflePos.current++;
|
|
599
|
+
if (shufflePos.current >= shuffledOrder.current.length) {
|
|
600
|
+
const lastShown =
|
|
601
|
+
shuffledOrder.current[shuffledOrder.current.length - 1];
|
|
602
|
+
shuffledOrder.current = shuffleVerbs(verbs, lastShown);
|
|
603
|
+
shufflePos.current = 0;
|
|
604
|
+
}
|
|
605
|
+
setVerbIndex(shuffledOrder.current[shufflePos.current]);
|
|
606
|
+
scheduleNext();
|
|
607
|
+
}, delay);
|
|
608
|
+
};
|
|
609
|
+
scheduleNext();
|
|
610
|
+
return () => clearTimeout(id);
|
|
611
|
+
}, [props.showGenerating, hasCustomVerbs]);
|
|
612
|
+
|
|
525
613
|
const msg: IChatMessage = props.message;
|
|
526
614
|
const timestamp = msg.date.toLocaleTimeString('en-US', { hour12: false });
|
|
527
615
|
|
|
@@ -630,6 +718,19 @@ function ChatResponse(props: any) {
|
|
|
630
718
|
if (item.reasoningFinished) {
|
|
631
719
|
lastItem.reasoningFinished = true;
|
|
632
720
|
}
|
|
721
|
+
} else if (
|
|
722
|
+
item.type === ResponseStreamDataType.ToolCall &&
|
|
723
|
+
lastItemType === ResponseStreamDataType.ToolCall
|
|
724
|
+
) {
|
|
725
|
+
// Bundle a run of consecutive tool calls into one group item so the
|
|
726
|
+
// renderer can collapse them; ToolCallGroup unwraps content.toolCalls.
|
|
727
|
+
const lastItem = groupedContents[groupedContents.length - 1];
|
|
728
|
+
lastItem.content.toolCalls.push(structuredClone(item.content));
|
|
729
|
+
} else if (item.type === ResponseStreamDataType.ToolCall) {
|
|
730
|
+
const grouped = structuredClone(item);
|
|
731
|
+
grouped.content = { toolCalls: [structuredClone(item.content)] };
|
|
732
|
+
groupedContents.push(grouped);
|
|
733
|
+
lastItemType = item.type;
|
|
633
734
|
} else {
|
|
634
735
|
groupedContents.push(structuredClone(item));
|
|
635
736
|
lastItemType = item.type;
|
|
@@ -712,18 +813,26 @@ function ChatResponse(props: any) {
|
|
|
712
813
|
}`}
|
|
713
814
|
aria-hidden="true"
|
|
714
815
|
/>
|
|
715
|
-
<div
|
|
716
|
-
className="generating-label"
|
|
717
|
-
aria-live="polite"
|
|
718
|
-
aria-atomic="true"
|
|
719
|
-
>
|
|
816
|
+
<div className="generating-label" aria-hidden="true">
|
|
720
817
|
{props.isStalled
|
|
721
818
|
? 'Still working, server may be slow'
|
|
722
|
-
:
|
|
819
|
+
: hasCustomVerbs
|
|
820
|
+
? _spinnerVerbs!.verbs[verbIndex]
|
|
821
|
+
: 'Generating'}
|
|
723
822
|
{props.showGenerating && props.elapsedSeconds > 0
|
|
724
823
|
? ` (${formatElapsedSeconds(props.elapsedSeconds)})`
|
|
725
824
|
: ''}
|
|
726
825
|
</div>
|
|
826
|
+
{/* aria-live region contains only the verb — no elapsed suffix —
|
|
827
|
+
so screen readers announce only on verb changes, not on every
|
|
828
|
+
elapsed-seconds tick. */}
|
|
829
|
+
<div className="nbi-sr-only" aria-live="polite" aria-atomic="true">
|
|
830
|
+
{props.isStalled
|
|
831
|
+
? 'Still working, server may be slow'
|
|
832
|
+
: hasCustomVerbs
|
|
833
|
+
? _spinnerVerbs!.verbs[verbIndex]
|
|
834
|
+
: 'Generating'}
|
|
835
|
+
</div>
|
|
727
836
|
</div>
|
|
728
837
|
</div>
|
|
729
838
|
<div className="chat-message-timestamp">{timestamp}</div>
|
|
@@ -836,23 +945,11 @@ function ChatResponse(props: any) {
|
|
|
836
945
|
</div>
|
|
837
946
|
);
|
|
838
947
|
case ResponseStreamDataType.Anchor: {
|
|
839
|
-
const safeUri = safeAnchorUri(item.content.uri);
|
|
840
|
-
if (!safeUri) {
|
|
841
|
-
return (
|
|
842
|
-
<div className="chat-response-anchor" key={`key-${index}`}>
|
|
843
|
-
<span>
|
|
844
|
-
{item.content.title}
|
|
845
|
-
<span className="nbi-sr-only"> (link blocked)</span>
|
|
846
|
-
</span>
|
|
847
|
-
</div>
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
948
|
return (
|
|
851
949
|
<div className="chat-response-anchor" key={`key-${index}`}>
|
|
852
|
-
<
|
|
950
|
+
<SafeAnchor href={item.content.uri}>
|
|
853
951
|
{item.content.title}
|
|
854
|
-
|
|
855
|
-
</a>
|
|
952
|
+
</SafeAnchor>
|
|
856
953
|
</div>
|
|
857
954
|
);
|
|
858
955
|
}
|
|
@@ -869,6 +966,17 @@ function ChatResponse(props: any) {
|
|
|
869
966
|
{item.content}
|
|
870
967
|
</div>
|
|
871
968
|
) : null;
|
|
969
|
+
case ResponseStreamDataType.ToolCall:
|
|
970
|
+
// Unlike Progress, tool-call cards persist after the turn ends,
|
|
971
|
+
// so they render regardless of `showGenerating`. The grouping
|
|
972
|
+
// pass bundled this run's calls into content.toolCalls; the
|
|
973
|
+
// group renders a single card or a collapsible group.
|
|
974
|
+
return (
|
|
975
|
+
<ToolCallGroup
|
|
976
|
+
key={`key-${index}`}
|
|
977
|
+
toolCalls={item.content.toolCalls}
|
|
978
|
+
/>
|
|
979
|
+
);
|
|
872
980
|
case ResponseStreamDataType.Confirmation:
|
|
873
981
|
return answeredForms.get(item.id) ===
|
|
874
982
|
'confirmed' ? null : answeredForms.get(item.id) ===
|
|
@@ -985,9 +1093,13 @@ function ChatResponse(props: any) {
|
|
|
985
1093
|
)}
|
|
986
1094
|
</div>
|
|
987
1095
|
{msg.from === 'copilot' &&
|
|
988
|
-
!props.showGenerating &&
|
|
1096
|
+
(NBIAPI.config.chatFeedbackAlwaysVisible || !props.showGenerating) &&
|
|
989
1097
|
NBIAPI.config.chatFeedbackEnabled && (
|
|
990
|
-
<div
|
|
1098
|
+
<div
|
|
1099
|
+
className={`chat-message-feedback${
|
|
1100
|
+
NBIAPI.config.chatFeedbackAlwaysVisible ? ' always-visible' : ''
|
|
1101
|
+
}`}
|
|
1102
|
+
>
|
|
991
1103
|
<button
|
|
992
1104
|
className={`chat-feedback-btn ${msg.feedback === 'positive' ? 'selected' : ''}`}
|
|
993
1105
|
onClick={() => {
|
|
@@ -1006,9 +1118,9 @@ function ChatResponse(props: any) {
|
|
|
1006
1118
|
});
|
|
1007
1119
|
}
|
|
1008
1120
|
}}
|
|
1009
|
-
aria-label="Rate as
|
|
1121
|
+
aria-label="Rate response as good"
|
|
1010
1122
|
aria-pressed={msg.feedback === 'positive'}
|
|
1011
|
-
title="
|
|
1123
|
+
title="Good response"
|
|
1012
1124
|
>
|
|
1013
1125
|
{msg.feedback === 'positive' ? (
|
|
1014
1126
|
<VscThumbsupFilled />
|
|
@@ -1034,9 +1146,9 @@ function ChatResponse(props: any) {
|
|
|
1034
1146
|
});
|
|
1035
1147
|
}
|
|
1036
1148
|
}}
|
|
1037
|
-
aria-label="Rate as
|
|
1149
|
+
aria-label="Rate response as bad"
|
|
1038
1150
|
aria-pressed={msg.feedback === 'negative'}
|
|
1039
|
-
title="
|
|
1151
|
+
title="Bad response"
|
|
1040
1152
|
>
|
|
1041
1153
|
{msg.feedback === 'negative' ? (
|
|
1042
1154
|
<VscThumbsdownFilled />
|
|
@@ -2932,22 +3044,33 @@ function SidebarComponent(props: any) {
|
|
|
2932
3044
|
}
|
|
2933
3045
|
if (delta['nbiContent']) {
|
|
2934
3046
|
const nbiContent = delta['nbiContent'];
|
|
2935
|
-
|
|
2936
|
-
id
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
3047
|
+
if (nbiContent.type === ResponseStreamDataType.ToolCall) {
|
|
3048
|
+
// A tool call streams twice under one id (start, then finish);
|
|
3049
|
+
// merge by id so it stays one persistent card. See
|
|
3050
|
+
// upsertToolCallContent.
|
|
3051
|
+
upsertToolCallContent(
|
|
3052
|
+
contents,
|
|
3053
|
+
nbiContent.content,
|
|
3054
|
+
new Date(response.created)
|
|
3055
|
+
);
|
|
3056
|
+
} else {
|
|
3057
|
+
contents.push({
|
|
3058
|
+
id: UUID.uuid4(),
|
|
3059
|
+
type: nbiContent.type,
|
|
3060
|
+
content: nbiContent.content || '',
|
|
3061
|
+
reasoningContent: nbiContent.reasoning_content || '',
|
|
3062
|
+
reasoningTag: nbiContent.reasoning_content
|
|
3063
|
+
? '<think>'
|
|
3064
|
+
: undefined,
|
|
3065
|
+
reasoningFinished:
|
|
3066
|
+
nbiContent.type === ResponseStreamDataType.Markdown &&
|
|
3067
|
+
nbiContent.reasoning_content
|
|
3068
|
+
? true
|
|
3069
|
+
: false,
|
|
3070
|
+
contentDetail: nbiContent.detail,
|
|
3071
|
+
created: new Date(response.created)
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
2951
3074
|
} else {
|
|
2952
3075
|
responseMessage =
|
|
2953
3076
|
response.data['choices']?.[0]?.['delta']?.['content'];
|
|
@@ -3368,22 +3491,33 @@ function SidebarComponent(props: any) {
|
|
|
3368
3491
|
|
|
3369
3492
|
if (delta['nbiContent']) {
|
|
3370
3493
|
const nbiContent = delta['nbiContent'];
|
|
3371
|
-
|
|
3372
|
-
id
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3494
|
+
if (nbiContent.type === ResponseStreamDataType.ToolCall) {
|
|
3495
|
+
// A tool call streams twice under one id (start, then finish);
|
|
3496
|
+
// merge by id so it stays one persistent card. See
|
|
3497
|
+
// upsertToolCallContent.
|
|
3498
|
+
upsertToolCallContent(
|
|
3499
|
+
contents,
|
|
3500
|
+
nbiContent.content,
|
|
3501
|
+
new Date(response.created)
|
|
3502
|
+
);
|
|
3503
|
+
} else {
|
|
3504
|
+
contents.push({
|
|
3505
|
+
id: UUID.uuid4(),
|
|
3506
|
+
type: nbiContent.type,
|
|
3507
|
+
content: nbiContent.content || '',
|
|
3508
|
+
reasoningContent: nbiContent.reasoning_content || '',
|
|
3509
|
+
reasoningTag: nbiContent.reasoning_content
|
|
3510
|
+
? '<think>'
|
|
3511
|
+
: undefined,
|
|
3512
|
+
reasoningFinished:
|
|
3513
|
+
nbiContent.type === ResponseStreamDataType.Markdown &&
|
|
3514
|
+
nbiContent.reasoning_content
|
|
3515
|
+
? true
|
|
3516
|
+
: false,
|
|
3517
|
+
contentDetail: nbiContent.detail,
|
|
3518
|
+
created: new Date(response.created)
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3387
3521
|
} else {
|
|
3388
3522
|
const responseMessage =
|
|
3389
3523
|
response.data['choices']?.[0]?.['delta']?.['content'];
|
|
@@ -4956,48 +5090,28 @@ function GitHubCopilotLoginDialogBodyComponent(props: any) {
|
|
|
4956
5090
|
memory.
|
|
4957
5091
|
</div>
|
|
4958
5092
|
<div>
|
|
4959
|
-
<
|
|
4960
|
-
href="https://github.com/features/copilot"
|
|
4961
|
-
target="_blank"
|
|
4962
|
-
rel="noopener noreferrer"
|
|
4963
|
-
>
|
|
5093
|
+
<SafeAnchor href="https://github.com/features/copilot">
|
|
4964
5094
|
GitHub Copilot
|
|
4965
|
-
|
|
4966
|
-
</a>{' '}
|
|
5095
|
+
</SafeAnchor>{' '}
|
|
4967
5096
|
requires a subscription and it has a free tier. GitHub Copilot is
|
|
4968
5097
|
subject to the{' '}
|
|
4969
|
-
<
|
|
4970
|
-
href="https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features"
|
|
4971
|
-
target="_blank"
|
|
4972
|
-
rel="noopener noreferrer"
|
|
4973
|
-
>
|
|
5098
|
+
<SafeAnchor href="https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features">
|
|
4974
5099
|
GitHub Terms for Additional Products and Features
|
|
4975
|
-
|
|
4976
|
-
</a>
|
|
5100
|
+
</SafeAnchor>
|
|
4977
5101
|
.
|
|
4978
5102
|
</div>
|
|
4979
5103
|
<div>
|
|
4980
5104
|
<h4>Privacy and terms</h4>
|
|
4981
5105
|
By using Notebook Intelligence with GitHub Copilot subscription you
|
|
4982
5106
|
agree to{' '}
|
|
4983
|
-
<
|
|
4984
|
-
href="https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide"
|
|
4985
|
-
target="_blank"
|
|
4986
|
-
rel="noopener noreferrer"
|
|
4987
|
-
>
|
|
5107
|
+
<SafeAnchor href="https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide">
|
|
4988
5108
|
GitHub Copilot chat terms
|
|
4989
|
-
|
|
4990
|
-
</a>
|
|
5109
|
+
</SafeAnchor>
|
|
4991
5110
|
. Review the terms to understand about usage, limitations and ways
|
|
4992
5111
|
to improve GitHub Copilot. Please review{' '}
|
|
4993
|
-
<
|
|
4994
|
-
href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
|
|
4995
|
-
target="_blank"
|
|
4996
|
-
rel="noopener noreferrer"
|
|
4997
|
-
>
|
|
5112
|
+
<SafeAnchor href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement">
|
|
4998
5113
|
Privacy Statement
|
|
4999
|
-
|
|
5000
|
-
</a>
|
|
5114
|
+
</SafeAnchor>
|
|
5001
5115
|
.
|
|
5002
5116
|
</div>
|
|
5003
5117
|
<div>
|
|
@@ -5046,13 +5160,9 @@ function GitHubCopilotLoginDialogBodyComponent(props: any) {
|
|
|
5046
5160
|
</b>
|
|
5047
5161
|
</span>{' '}
|
|
5048
5162
|
and enter at{' '}
|
|
5049
|
-
<
|
|
5050
|
-
href={deviceActivationURL}
|
|
5051
|
-
target="_blank"
|
|
5052
|
-
rel="noopener noreferrer"
|
|
5053
|
-
>
|
|
5163
|
+
<SafeAnchor href={deviceActivationURL}>
|
|
5054
5164
|
{deviceActivationURL}
|
|
5055
|
-
</
|
|
5165
|
+
</SafeAnchor>{' '}
|
|
5056
5166
|
to allow access to GitHub Copilot from this app. Activation could
|
|
5057
5167
|
take up to a minute after you enter the code.
|
|
5058
5168
|
</div>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
5
|
+
import { PathExt } from '@jupyterlab/coreutils';
|
|
6
|
+
import { SafeAnchor } from './safe-anchor';
|
|
7
|
+
import { hasDangerousTextCodepoints } from '../utils';
|
|
8
|
+
|
|
9
|
+
// Match an absolute URI by its scheme prefix so a workspace-relative path
|
|
10
|
+
// (`README.md`) is distinguished from a protocol-rooted URL (`http://...`).
|
|
11
|
+
// Mirrors the SCHEME_RE in utils.ts; kept local because this discriminant
|
|
12
|
+
// answers a different question (presence vs. allowlist).
|
|
13
|
+
const SCHEME_PREFIX_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True when a freshly-joined workspace path is *not* safe to hand to
|
|
17
|
+
* `docmanager:open` or expose on a rendered anchor. Rejects:
|
|
18
|
+
*
|
|
19
|
+
* - leading `..` segments or absolute paths: the join didn't anchor and
|
|
20
|
+
* the path escapes the Jupyter root (ContentsManager rejects too, but
|
|
21
|
+
* we want to fail closed visually as well so the status bar/title
|
|
22
|
+
* never previews a traversal target),
|
|
23
|
+
* - any embedded scheme: `PathExt.join('', 'java\tscript:alert(1)')`
|
|
24
|
+
* returns the input verbatim, so a path that looks workspace-relative
|
|
25
|
+
* pre-join can unmask into a `javascript:` href when `baseDir` is
|
|
26
|
+
* empty (any active doc at server root),
|
|
27
|
+
* - dangerous codepoints (bidi-override, zero-width, C0/C1/DEL, etc.):
|
|
28
|
+
* the WHATWG URL parser strips these from the scheme during
|
|
29
|
+
* recognition, and they also visually impersonate the link target on
|
|
30
|
+
* hover / in dev-tools logs.
|
|
31
|
+
*/
|
|
32
|
+
function isUnsafeWorkspacePath(path: string): boolean {
|
|
33
|
+
// Empty / cwd-only paths reach here when react-markdown's built-in
|
|
34
|
+
// `urlTransform` strips an unsafe scheme (`javascript:`, `data:`, ...)
|
|
35
|
+
// to an empty string before our override runs: the result joins to
|
|
36
|
+
// either `""` or `"."`, both of which would render as a dead
|
|
37
|
+
// `<a href="#">` that 404s on click. Surface them as blocked-link
|
|
38
|
+
// spans so the user sees why nothing happened.
|
|
39
|
+
if (path === '' || path === '.' || path === './') {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (path.startsWith('/') || path === '..' || path.startsWith('../')) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (SCHEME_PREFIX_RE.test(path)) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (hasDangerousTextCodepoints(path)) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type MarkdownLinkProps = {
|
|
55
|
+
app: JupyterFrontEnd;
|
|
56
|
+
// Directory the LLM-emitted relative link should resolve against. The
|
|
57
|
+
// active document's directory matches the user's mental model: a
|
|
58
|
+
// workspace-relative link like `[file](README.md)` in a chat scoped to
|
|
59
|
+
// `notebooks/proj/work.ipynb` lands at `notebooks/proj/README.md`, not
|
|
60
|
+
// at the server-root README. Empty string is treated as "server root".
|
|
61
|
+
baseDir: string;
|
|
62
|
+
href: unknown;
|
|
63
|
+
title?: unknown;
|
|
64
|
+
children?: React.ReactNode;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render an anchor node coming out of `react-markdown` so chat-sidebar
|
|
69
|
+
* links can never replace the JupyterLab shell or pivot through the
|
|
70
|
+
* lab origin.
|
|
71
|
+
*
|
|
72
|
+
* Three branches:
|
|
73
|
+
* - Fragment-only (`#section`): inert plain text. A new-tab open would
|
|
74
|
+
* navigate to `about:blank#section`, and a same-tab open would scroll
|
|
75
|
+
* the wrong document; neither matches what the LLM meant.
|
|
76
|
+
* - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
|
|
77
|
+
* resolved against the active document's directory, re-validated,
|
|
78
|
+
* and routed through JupyterLab's `docmanager:open` command so a
|
|
79
|
+
* `.ipynb` opens with the notebook factory and a `.md` opens in the
|
|
80
|
+
* editor. The anchor's `href` stays `"#"` because a populated `href`
|
|
81
|
+
* bypasses React's onClick on middle/Cmd-click, letting the browser
|
|
82
|
+
* navigate `/lab/<path>` with session cookies attached; the hover
|
|
83
|
+
* preview moves to `title` so the user still sees the intended
|
|
84
|
+
* target.
|
|
85
|
+
* - Everything else: handed to `SafeAnchor`, which enforces the
|
|
86
|
+
* `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
|
|
87
|
+
* `rel="noopener noreferrer"`.
|
|
88
|
+
*/
|
|
89
|
+
export function MarkdownLink({
|
|
90
|
+
app,
|
|
91
|
+
baseDir,
|
|
92
|
+
href,
|
|
93
|
+
title,
|
|
94
|
+
children
|
|
95
|
+
}: MarkdownLinkProps): React.ReactElement {
|
|
96
|
+
if (typeof href === 'string') {
|
|
97
|
+
if (href.startsWith('#')) {
|
|
98
|
+
return <span>{children}</span>;
|
|
99
|
+
}
|
|
100
|
+
if (
|
|
101
|
+
!SCHEME_PREFIX_RE.test(href) &&
|
|
102
|
+
!href.startsWith('/') &&
|
|
103
|
+
!href.startsWith('//')
|
|
104
|
+
) {
|
|
105
|
+
// PathExt.join: plain concatenation + normalization. Resolve()
|
|
106
|
+
// would fall back to the browser process cwd when `baseDir` is
|
|
107
|
+
// relative, which gives nonsense like `/Users/.../notebooks/...`.
|
|
108
|
+
const resolvedPath = PathExt.join(baseDir, href);
|
|
109
|
+
// Re-validate post-join. Two attack/confusion shapes the pre-check
|
|
110
|
+
// alone misses: `[x](java\tscript:alert(1))` survives the scheme
|
|
111
|
+
// sniff because `\t` isn't a scheme char, then unmasks once the
|
|
112
|
+
// WHATWG parser sees the joined href; `[x](../../../etc/passwd)`
|
|
113
|
+
// looks workspace-relative but escapes the workspace root.
|
|
114
|
+
if (isUnsafeWorkspacePath(resolvedPath)) {
|
|
115
|
+
return (
|
|
116
|
+
<SafeAnchor href={null} title={undefined}>
|
|
117
|
+
{children}
|
|
118
|
+
</SafeAnchor>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
// href="#" rather than href={resolvedPath}: a modifier-click on a
|
|
122
|
+
// populated href bypasses the React onClick, lets the browser
|
|
123
|
+
// navigate the chat sidebar to /lab/<path> in a new tab, and would
|
|
124
|
+
// ride along the user's Jupyter session cookies. The hover preview
|
|
125
|
+
// moves to `title` so the user still sees the intended target.
|
|
126
|
+
const safeTitleFromMd =
|
|
127
|
+
typeof title === 'string' && !hasDangerousTextCodepoints(title)
|
|
128
|
+
? title
|
|
129
|
+
: undefined;
|
|
130
|
+
const hoverTitle = safeTitleFromMd ?? resolvedPath;
|
|
131
|
+
const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
// ContentsManager rejects paths outside the Jupyter root with a
|
|
134
|
+
// promise rejection. Catch so the failure surfaces in logs instead
|
|
135
|
+
// of an unhandled rejection, and the user can see the rendered
|
|
136
|
+
// anchor was attempted even when the target doesn't exist.
|
|
137
|
+
Promise.resolve(
|
|
138
|
+
app.commands.execute('docmanager:open', { path: resolvedPath })
|
|
139
|
+
).catch(err => {
|
|
140
|
+
console.warn(
|
|
141
|
+
`NBI: failed to open workspace path "${resolvedPath}":`,
|
|
142
|
+
err
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
return (
|
|
147
|
+
<a href="#" title={hoverTitle} onClick={onClick}>
|
|
148
|
+
{children}
|
|
149
|
+
</a>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return (
|
|
154
|
+
<SafeAnchor
|
|
155
|
+
href={typeof href === 'string' ? href : null}
|
|
156
|
+
title={typeof title === 'string' ? title : undefined}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</SafeAnchor>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { hasDangerousTextCodepoints, safeAnchorUri } from '../utils';
|
|
5
|
+
|
|
6
|
+
type SafeAnchorProps = {
|
|
7
|
+
href: string | undefined | null;
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
title?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The single render path for anchor elements driven by LLM / tool output.
|
|
15
|
+
*
|
|
16
|
+
* Runs `href` through `safeAnchorUri`, which mirrors the server-side
|
|
17
|
+
* `safe_anchor_uri` allowlist (`http` / `https` / `mailto`) and rejects
|
|
18
|
+
* dangerous codepoints. On accept it renders a `_blank` anchor with
|
|
19
|
+
* `rel="noopener noreferrer"` and an SR-only "(opens in new tab)" suffix;
|
|
20
|
+
* on reject it falls through to plain text plus an SR-only "(link
|
|
21
|
+
* blocked)" note so screen readers can tell why the link disappeared.
|
|
22
|
+
*
|
|
23
|
+
* The `title` attribute is scrubbed for the same dangerous codepoints
|
|
24
|
+
* the URI check rejects, since react-markdown forwards CommonMark
|
|
25
|
+
* `[text](url "title")` titles to the rendered anchor and an LLM can
|
|
26
|
+
* smuggle bidi-override or zero-width characters there to visually
|
|
27
|
+
* impersonate the link target on hover.
|
|
28
|
+
*/
|
|
29
|
+
export function SafeAnchor({
|
|
30
|
+
href,
|
|
31
|
+
children,
|
|
32
|
+
title,
|
|
33
|
+
className
|
|
34
|
+
}: SafeAnchorProps): React.ReactElement {
|
|
35
|
+
const safeUri = safeAnchorUri(href ?? '');
|
|
36
|
+
if (!safeUri) {
|
|
37
|
+
return (
|
|
38
|
+
<span className={className}>
|
|
39
|
+
{children}
|
|
40
|
+
<span className="nbi-sr-only"> (link blocked)</span>
|
|
41
|
+
</span>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const safeTitle =
|
|
45
|
+
typeof title === 'string' && !hasDangerousTextCodepoints(title)
|
|
46
|
+
? title
|
|
47
|
+
: undefined;
|
|
48
|
+
return (
|
|
49
|
+
<a
|
|
50
|
+
href={safeUri}
|
|
51
|
+
target="_blank"
|
|
52
|
+
rel="noopener noreferrer"
|
|
53
|
+
title={safeTitle}
|
|
54
|
+
className={className}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
<span className="nbi-sr-only"> (opens in new tab)</span>
|
|
58
|
+
</a>
|
|
59
|
+
);
|
|
60
|
+
}
|