@carlonicora/nextjs-jsonapi 6.9.0 → 6.10.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/dist/{BlockNoteEditor-WN73IHXR.mjs → BlockNoteEditor-GK3DRTOP.mjs} +49 -15
- package/dist/BlockNoteEditor-GK3DRTOP.mjs.map +1 -0
- package/dist/{BlockNoteEditor-XXH3VBZ7.js → BlockNoteEditor-YOKJOVV3.js} +64 -30
- package/dist/BlockNoteEditor-YOKJOVV3.js.map +1 -0
- package/dist/billing/index.js +436 -435
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +8 -7
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-ZCM4JLM7.js → chunk-4UFMLTL2.js} +73 -71
- package/dist/chunk-4UFMLTL2.js.map +1 -0
- package/dist/{chunk-Q4XLE3QO.mjs → chunk-THZAK7S3.mjs} +9 -7
- package/dist/{chunk-Q4XLE3QO.mjs.map → chunk-THZAK7S3.mjs.map} +1 -1
- package/dist/client/index.js +2 -2
- package/dist/client/index.mjs +1 -1
- package/dist/components/index.d.mts +12 -5
- package/dist/components/index.d.ts +12 -5
- package/dist/components/index.js +2 -2
- package/dist/components/index.mjs +1 -1
- package/dist/contexts/index.js +2 -2
- package/dist/contexts/index.mjs +1 -1
- package/dist/email-automations/index.js +58 -58
- package/dist/email-automations/index.mjs +1 -1
- package/dist/features/analytics/index.js +87 -87
- package/dist/features/analytics/index.mjs +1 -1
- package/dist/features/blog/index.js +93 -93
- package/dist/features/blog/index.mjs +1 -1
- package/dist/features/help/index.js +31 -31
- package/dist/features/help/index.mjs +1 -1
- package/dist/features/tokenusage/index.js +77 -77
- package/dist/features/tokenusage/index.mjs +1 -1
- package/messages/en.json +4 -2
- package/messages/it.json +4 -2
- package/package.json +1 -1
- package/src/components/editors/BlockNoteEditor.tsx +69 -14
- package/src/components/editors/__tests__/BlockNoteEditor.ai.spec.tsx +295 -0
- package/src/features/assistant/components/containers/AssistantPageContainer.tsx +20 -9
- package/src/features/assistant/components/containers/__tests__/AssistantPageContainer.spec.tsx +116 -0
- package/src/features/assistant/components/parts/AssistantThread.tsx +2 -1
- package/src/features/assistant-message/components/MessageItem.tsx +1 -1
- package/src/features/assistant-message/components/MessageList.tsx +1 -1
- package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +1 -1
- package/src/features/assistant-message/components/parts/MessageSourcesPanel.tsx +1 -1
- package/src/features/assistant-message/components/parts/__tests__/MessageSourcesPanel.spec.tsx +17 -1
- package/src/features/assistant-message/components/parts/__tests__/SuggestedQuestionsTab.spec.tsx +22 -0
- package/src/features/assistant-message/components/parts/tabs/SuggestedQuestionsTab.tsx +5 -3
- package/src/features/billing/i18n-keys.ts +2 -0
- package/src/features/billing/stripe-subscription/components/widgets/ProrationPreview.tsx +1 -2
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +16 -5
- package/dist/BlockNoteEditor-WN73IHXR.mjs.map +0 -1
- package/dist/BlockNoteEditor-XXH3VBZ7.js.map +0 -1
- package/dist/chunk-ZCM4JLM7.js.map +0 -1
package/messages/en.json
CHANGED
|
@@ -782,7 +782,7 @@
|
|
|
782
782
|
},
|
|
783
783
|
"proration": {
|
|
784
784
|
"net_due_today": "Net Due Today",
|
|
785
|
-
"next_invoice": "
|
|
785
|
+
"next_invoice": "Charged today: {amount}",
|
|
786
786
|
"title": "Proration Breakdown"
|
|
787
787
|
},
|
|
788
788
|
"subscription": {
|
|
@@ -859,6 +859,7 @@
|
|
|
859
859
|
"amount_off": "{amount} off",
|
|
860
860
|
"card_on_file": "{brand} ending in {last4}",
|
|
861
861
|
"charge_now": "Amount to charge now:",
|
|
862
|
+
"credit_now": "Credit to your account:",
|
|
862
863
|
"confirm_change": "Confirm Plan Change",
|
|
863
864
|
"due_now": "Amount due now:",
|
|
864
865
|
"interval_monthly": "monthly",
|
|
@@ -870,7 +871,8 @@
|
|
|
870
871
|
"percent_off": "{value}% off",
|
|
871
872
|
"processing": "Processing...",
|
|
872
873
|
"promotion_code": "Promotion Code",
|
|
873
|
-
"
|
|
874
|
+
"proration_credit_description": "The unused time becomes account credit, used on your next invoices.",
|
|
875
|
+
"proration_description": "The difference is charged today on a new invoice.",
|
|
874
876
|
"proration_summary": "Proration Summary",
|
|
875
877
|
"selected_plan": "Selected Plan",
|
|
876
878
|
"subscribe_now": "Subscribe Now",
|
package/messages/it.json
CHANGED
|
@@ -782,7 +782,7 @@
|
|
|
782
782
|
},
|
|
783
783
|
"proration": {
|
|
784
784
|
"net_due_today": "Netto dovuto oggi",
|
|
785
|
-
"next_invoice": "
|
|
785
|
+
"next_invoice": "Addebito oggi: {amount}",
|
|
786
786
|
"title": "Dettaglio del conguaglio"
|
|
787
787
|
},
|
|
788
788
|
"subscription": {
|
|
@@ -859,6 +859,7 @@
|
|
|
859
859
|
"amount_off": "{amount} di sconto",
|
|
860
860
|
"card_on_file": "{brand} che termina con {last4}",
|
|
861
861
|
"charge_now": "Importo da addebitare ora:",
|
|
862
|
+
"credit_now": "Credito sul tuo account:",
|
|
862
863
|
"confirm_change": "Conferma il cambio di piano",
|
|
863
864
|
"due_now": "Importo dovuto ora:",
|
|
864
865
|
"interval_monthly": "mensile",
|
|
@@ -870,7 +871,8 @@
|
|
|
870
871
|
"percent_off": "{value}% di sconto",
|
|
871
872
|
"processing": "Elaborazione...",
|
|
872
873
|
"promotion_code": "Codice promozionale",
|
|
873
|
-
"
|
|
874
|
+
"proration_credit_description": "Il tempo non usato diventa credito sul tuo account, usato nelle prossime fatture.",
|
|
875
|
+
"proration_description": "La differenza viene addebitata oggi con una nuova fattura.",
|
|
874
876
|
"proration_summary": "Riepilogo del conguaglio",
|
|
875
877
|
"selected_plan": "Piano selezionato",
|
|
876
878
|
"subscribe_now": "Abbonati ora",
|
package/package.json
CHANGED
|
@@ -106,8 +106,10 @@ export type BlockNoteEditorProps = {
|
|
|
106
106
|
stretch?: boolean;
|
|
107
107
|
// Gates AI on top of `aiConfig` (credit availability, etc.). Defaults to
|
|
108
108
|
// true so existing callers that only pass `aiConfig` are unaffected. When
|
|
109
|
-
// false,
|
|
110
|
-
//
|
|
109
|
+
// false, the AI toolbar button and AI slash items are hidden, the editor
|
|
110
|
+
// falls back to the plain slash menu, an open AI menu or run is closed, and
|
|
111
|
+
// no AI request is sent. The AI extension itself stays: toggling this never
|
|
112
|
+
// rebuilds the editor, so unsaved text survives credits running out.
|
|
111
113
|
aiEnabled?: boolean;
|
|
112
114
|
// Filled with a {@link BlockNoteAiHandle} while the editor is mounted and AI
|
|
113
115
|
// is on, so a control outside the editor can run a whole-document AI action.
|
|
@@ -508,11 +510,22 @@ export default function BlockNoteEditor({
|
|
|
508
510
|
// instance exists; reads the current BlockNote selection on every send.
|
|
509
511
|
const selectionRef = useRef<{ getSelectedBlocks: () => any[] }>({ getSelectedBlocks: () => [] });
|
|
510
512
|
|
|
511
|
-
|
|
513
|
+
// Callers pass `aiConfig` as an inline object, the company arrives after the
|
|
514
|
+
// first render and `aiEnabled` flips with credits, so none of them may key
|
|
515
|
+
// the AI extension: every new extension means a new editor, which restarts
|
|
516
|
+
// from `initialContent` and drops unsaved text. The transport and the outside
|
|
517
|
+
// handle read them through this ref at send time.
|
|
518
|
+
const aiRequestRef = useRef({ aiConfig, companyId: company?.id, aiEnabled });
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
aiRequestRef.current = { aiConfig, companyId: company?.id, aiEnabled };
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const hasAiConfig = !!aiConfig;
|
|
524
|
+
const aiEndpoint = aiConfig?.endpoint;
|
|
512
525
|
const aiExtension = useMemo(() => {
|
|
513
|
-
if (!
|
|
526
|
+
if (!hasAiConfig || !aiEndpoint) return undefined;
|
|
514
527
|
const base = getPublicApiUrl();
|
|
515
|
-
const url = new URL(
|
|
528
|
+
const url = new URL(aiEndpoint, base.endsWith("/") ? base : base + "/").toString();
|
|
516
529
|
return AIExtension({
|
|
517
530
|
transport: new DefaultChatTransport({
|
|
518
531
|
api: url,
|
|
@@ -521,10 +534,16 @@ export default function BlockNoteEditor({
|
|
|
521
534
|
const headers: Record<string, string> = { "x-language": locale };
|
|
522
535
|
const token = await getClientToken();
|
|
523
536
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
537
|
+
const companyId = aiRequestRef.current.companyId;
|
|
524
538
|
if (companyId) headers["x-companyid"] = companyId;
|
|
525
539
|
return headers;
|
|
526
540
|
},
|
|
527
541
|
prepareSendMessagesRequest: ({ messages, body }: any) => {
|
|
542
|
+
// Last guard before the network: AI switched off while a request was
|
|
543
|
+
// being started never reaches the endpoint. Throwing here puts the
|
|
544
|
+
// AI menu in its error state instead of sending.
|
|
545
|
+
if (!aiRequestRef.current.aiEnabled) throw new Error("AI is disabled");
|
|
546
|
+
const latestAiConfig = aiRequestRef.current.aiConfig;
|
|
528
547
|
let lastUserIdx = -1;
|
|
529
548
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
530
549
|
if (messages[i]?.role === "user") {
|
|
@@ -539,8 +558,8 @@ export default function BlockNoteEditor({
|
|
|
539
558
|
...m,
|
|
540
559
|
metadata: {
|
|
541
560
|
...(m.metadata ?? {}),
|
|
542
|
-
entityType:
|
|
543
|
-
entityId:
|
|
561
|
+
entityType: latestAiConfig?.entityType,
|
|
562
|
+
entityId: latestAiConfig?.entityId,
|
|
544
563
|
blocks: docRef.current.getDoc(),
|
|
545
564
|
selectionBlocks: selectedBlocks,
|
|
546
565
|
},
|
|
@@ -551,7 +570,7 @@ export default function BlockNoteEditor({
|
|
|
551
570
|
},
|
|
552
571
|
}),
|
|
553
572
|
});
|
|
554
|
-
}, [
|
|
573
|
+
}, [hasAiConfig, aiEndpoint, locale]);
|
|
555
574
|
|
|
556
575
|
const uploadImage = useCallback(
|
|
557
576
|
async (file: File): Promise<string> => {
|
|
@@ -697,8 +716,42 @@ export default function BlockNoteEditor({
|
|
|
697
716
|
}),
|
|
698
717
|
[placeholder, t, schema, validatedInitialContent, uploadImage, aiExtension, coreDictionary, aiDictionary],
|
|
699
718
|
),
|
|
719
|
+
// useCreateBlockNote memoises on THIS list, not on the options' identity
|
|
720
|
+
// (@blocknote/react: useMemo(() => BlockNoteEditor.create(options), deps),
|
|
721
|
+
// deps defaulting to []). Without it the editor keeps the extensions of its
|
|
722
|
+
// first render: AI config arriving later (user loaded after mount)
|
|
723
|
+
// rendered the AI UI over an editor with no AIExtension, and BlockNote's
|
|
724
|
+
// useExtension threw "Extension not found". The other options keep their
|
|
725
|
+
// first-render values, as before. `aiEnabled` is deliberately not here.
|
|
726
|
+
[aiExtension],
|
|
700
727
|
);
|
|
701
728
|
|
|
729
|
+
// Every AI surface keys off the editor actually rendered, never off the
|
|
730
|
+
// props: those can disagree with the options that editor was created with.
|
|
731
|
+
// `aiEnabled` then hides that UI without touching the editor.
|
|
732
|
+
const hasAiExtension = !!editor.getExtension(AIExtension);
|
|
733
|
+
const aiActive = hasAiExtension && aiEnabled;
|
|
734
|
+
|
|
735
|
+
// AI switched off while its menu is open or a run is streaming: hiding the
|
|
736
|
+
// AIMenuController alone would leave the editor read-only (openAIMenuAtBlock
|
|
737
|
+
// sets isEditable = false) and the AI suggestions in the document. Stop the
|
|
738
|
+
// request, then reject the suggestions, or just close an idle prompt menu.
|
|
739
|
+
useEffect(() => {
|
|
740
|
+
if (aiEnabled || !hasAiExtension) return;
|
|
741
|
+
const ai = editor.getExtension(AIExtension);
|
|
742
|
+
if (!ai) return;
|
|
743
|
+
const dismiss = async () => {
|
|
744
|
+
const menu = ai.store.state.aiMenuState;
|
|
745
|
+
if (menu === "closed") return;
|
|
746
|
+
if (menu.status === "thinking" || menu.status === "ai-writing") await ai.abort("AI disabled");
|
|
747
|
+
const after = ai.store.state.aiMenuState;
|
|
748
|
+
if (after === "closed") return;
|
|
749
|
+
if (after.status === "user-input") ai.closeAIMenu();
|
|
750
|
+
else ai.rejectChanges();
|
|
751
|
+
};
|
|
752
|
+
dismiss().catch((error) => console.error("Closing the AI menu failed:", error));
|
|
753
|
+
}, [aiEnabled, hasAiExtension, editor]);
|
|
754
|
+
|
|
702
755
|
// Publish the outside-the-editor AI trigger. `editor.getExtension(AIExtension)`
|
|
703
756
|
// is BlockNote 0.54's documented lookup by extension factory (core's
|
|
704
757
|
// `getExtension` resolves a function argument through `extensionFactories`),
|
|
@@ -709,12 +762,14 @@ export default function BlockNoteEditor({
|
|
|
709
762
|
// the request runs but the accept / reject UI never appears.
|
|
710
763
|
useEffect(() => {
|
|
711
764
|
if (!aiHandleRef) return;
|
|
712
|
-
if (!
|
|
765
|
+
if (!aiActive) {
|
|
713
766
|
aiHandleRef.current = null;
|
|
714
767
|
return;
|
|
715
768
|
}
|
|
716
769
|
aiHandleRef.current = {
|
|
717
770
|
invokeAi: (type: BlockNoteAiGenerateAction) => {
|
|
771
|
+
// A caller may hold the handle across the render that turns AI off.
|
|
772
|
+
if (!aiRequestRef.current.aiEnabled) return;
|
|
718
773
|
const ai = editor.getExtension(AIExtension);
|
|
719
774
|
if (!ai) return;
|
|
720
775
|
editor.focus();
|
|
@@ -731,7 +786,7 @@ export default function BlockNoteEditor({
|
|
|
731
786
|
return () => {
|
|
732
787
|
aiHandleRef.current = null;
|
|
733
788
|
};
|
|
734
|
-
}, [aiHandleRef,
|
|
789
|
+
}, [aiHandleRef, aiActive, editor]);
|
|
735
790
|
|
|
736
791
|
// Tracks the hash of the document the editor itself just emitted via onChange.
|
|
737
792
|
// The sync effect below uses it to skip replaceBlocks when the parent's
|
|
@@ -912,7 +967,7 @@ export default function BlockNoteEditor({
|
|
|
912
967
|
onChange={handleChange}
|
|
913
968
|
editable={onChange !== undefined}
|
|
914
969
|
formattingToolbar={false}
|
|
915
|
-
slashMenu={!
|
|
970
|
+
slashMenu={!aiActive}
|
|
916
971
|
// Follow the app theme: hardcoding "light" leaves dark (light-theme)
|
|
917
972
|
// text on the dark card, which is nearly unreadable in dark mode.
|
|
918
973
|
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
|
@@ -939,7 +994,7 @@ export default function BlockNoteEditor({
|
|
|
939
994
|
size === "sm" && "small",
|
|
940
995
|
)}
|
|
941
996
|
>
|
|
942
|
-
<BlockNoteEditorFormattingToolbar showAI={
|
|
997
|
+
<BlockNoteEditorFormattingToolbar showAI={aiActive} />
|
|
943
998
|
{enableMentions && mentionSearchFn && (
|
|
944
999
|
<BlockNoteEditorMentionSuggestionMenu
|
|
945
1000
|
editor={editor}
|
|
@@ -952,7 +1007,7 @@ export default function BlockNoteEditor({
|
|
|
952
1007
|
{enableMentions && mentionResolveFn && (
|
|
953
1008
|
<BlockNoteEditorMentionHoverCard containerRef={editorRef} mentionResolveFn={mentionResolveFn} />
|
|
954
1009
|
)}
|
|
955
|
-
{
|
|
1010
|
+
{aiActive && (
|
|
956
1011
|
<SuggestionMenuController
|
|
957
1012
|
triggerCharacter="/"
|
|
958
1013
|
getItems={async (query: string) =>
|
|
@@ -960,7 +1015,7 @@ export default function BlockNoteEditor({
|
|
|
960
1015
|
}
|
|
961
1016
|
/>
|
|
962
1017
|
)}
|
|
963
|
-
{
|
|
1018
|
+
{aiActive && <AIMenuController aiMenu={() => <NarrAIMenu actions={aiConfig?.actions} />} />}
|
|
964
1019
|
</BlockNoteView>
|
|
965
1020
|
</div>
|
|
966
1021
|
);
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The AI menu, AI toolbar button and AI slash items call BlockNote's
|
|
7
|
+
* useExtension(AIExtension), which throws "Extension not found" when the
|
|
8
|
+
* editor in context was created without the extension. These stubs keep that
|
|
9
|
+
* contract: useCreateBlockNote memoises on its deps exactly like
|
|
10
|
+
* @blocknote/react 0.55 (useMemo(() => BlockNoteEditor.create(options), deps)),
|
|
11
|
+
* and the AI controllers throw the same error BlockNote throws.
|
|
12
|
+
*/
|
|
13
|
+
const h = vi.hoisted(() => {
|
|
14
|
+
const AIExtension = vi.fn((options: unknown) => ({ aiFactory: true, options }));
|
|
15
|
+
return {
|
|
16
|
+
AIExtension,
|
|
17
|
+
editorsCreated: 0,
|
|
18
|
+
company: undefined as { id: string } | undefined,
|
|
19
|
+
// The last editor useCreateBlockNote built, and its AI extension instance.
|
|
20
|
+
editor: undefined as any,
|
|
21
|
+
ai: undefined as any,
|
|
22
|
+
// Config the editor handed to DefaultChatTransport (the AI request path).
|
|
23
|
+
transportConfig: undefined as any,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
|
28
|
+
vi.mock("next-themes", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
|
|
29
|
+
vi.mock("../../../i18n/config", () => ({ useI18nLocale: () => "en" }));
|
|
30
|
+
vi.mock("../../../client/config", () => ({ getPublicApiUrl: () => "http://api.test/" }));
|
|
31
|
+
vi.mock("../../../client/token", () => ({ getClientToken: async () => "token" }));
|
|
32
|
+
vi.mock("../../../contexts", () => ({
|
|
33
|
+
useCurrentUserContextOptional: () => (h.company ? { company: h.company } : undefined),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock("@blocknote/react", async () => {
|
|
37
|
+
const React = await import("react");
|
|
38
|
+
const EditorContext = React.createContext<any>(null);
|
|
39
|
+
const useBlockNoteEditor = () => React.useContext(EditorContext);
|
|
40
|
+
const requireAI = (editor: any) => {
|
|
41
|
+
if (!editor?.getExtension(h.AIExtension)) throw new Error("Extension not found");
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
EditorContext,
|
|
45
|
+
createReactInlineContentSpec: (config: unknown) => ({ config }),
|
|
46
|
+
getDefaultReactSlashMenuItems: () => [],
|
|
47
|
+
SuggestionMenuController: () => null,
|
|
48
|
+
useBlockNoteEditor,
|
|
49
|
+
useExtension: () => {
|
|
50
|
+
const editor = useBlockNoteEditor();
|
|
51
|
+
requireAI(editor);
|
|
52
|
+
return editor.getExtension(h.AIExtension);
|
|
53
|
+
},
|
|
54
|
+
useExtensionState: () => "closed",
|
|
55
|
+
useCreateBlockNote: (options: any, deps: React.DependencyList = []) =>
|
|
56
|
+
React.useMemo(() => {
|
|
57
|
+
h.editorsCreated += 1;
|
|
58
|
+
const extensions: any[] = options?.extensions ?? [];
|
|
59
|
+
// Mirrors xl-ai 0.55: a store holding aiMenuState, and the calls that
|
|
60
|
+
// move it back to "closed".
|
|
61
|
+
const store = { state: { aiMenuState: "closed" as any } };
|
|
62
|
+
const ai = {
|
|
63
|
+
store,
|
|
64
|
+
invokeAI: vi.fn(),
|
|
65
|
+
openAIMenuAtBlock: vi.fn((blockId: string) => {
|
|
66
|
+
store.state.aiMenuState = { blockId, status: "user-input" };
|
|
67
|
+
}),
|
|
68
|
+
abort: vi.fn(async () => {
|
|
69
|
+
const menu = store.state.aiMenuState;
|
|
70
|
+
if (menu !== "closed") store.state.aiMenuState = { ...menu, status: "user-reviewing" };
|
|
71
|
+
}),
|
|
72
|
+
closeAIMenu: vi.fn(() => {
|
|
73
|
+
store.state.aiMenuState = "closed";
|
|
74
|
+
}),
|
|
75
|
+
rejectChanges: vi.fn(() => {
|
|
76
|
+
store.state.aiMenuState = "closed";
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
const hasAI = extensions.some((e) => e?.aiFactory);
|
|
80
|
+
const editor = {
|
|
81
|
+
document: [] as any[],
|
|
82
|
+
focus: () => undefined,
|
|
83
|
+
getSelection: () => undefined,
|
|
84
|
+
getExtension: (factory: unknown) => (factory === h.AIExtension && hasAI ? ai : undefined),
|
|
85
|
+
};
|
|
86
|
+
h.editor = editor;
|
|
87
|
+
h.ai = hasAI ? ai : undefined;
|
|
88
|
+
return editor;
|
|
89
|
+
}, deps),
|
|
90
|
+
BasicTextStyleButton: () => null,
|
|
91
|
+
BlockTypeSelect: () => null,
|
|
92
|
+
CreateLinkButton: () => null,
|
|
93
|
+
FileCaptionButton: () => null,
|
|
94
|
+
FileReplaceButton: () => null,
|
|
95
|
+
TextAlignButton: () => null,
|
|
96
|
+
FormattingToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
97
|
+
FormattingToolbarController: ({ formattingToolbar }: { formattingToolbar: () => React.ReactNode }) => (
|
|
98
|
+
<>{formattingToolbar()}</>
|
|
99
|
+
),
|
|
100
|
+
// Shared with the xl-ai stubs below.
|
|
101
|
+
__requireAI: requireAI,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
vi.mock("@blocknote/shadcn", async () => {
|
|
106
|
+
const { EditorContext } = (await import("@blocknote/react")) as any;
|
|
107
|
+
return {
|
|
108
|
+
BlockNoteView: ({ editor, children }: { editor: unknown; children: React.ReactNode }) => (
|
|
109
|
+
<EditorContext.Provider value={editor}>{children}</EditorContext.Provider>
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
vi.mock("@blocknote/xl-ai", async () => {
|
|
115
|
+
const { useBlockNoteEditor, __requireAI } = (await import("@blocknote/react")) as any;
|
|
116
|
+
return {
|
|
117
|
+
AIExtension: h.AIExtension,
|
|
118
|
+
AIMenuController: () => {
|
|
119
|
+
__requireAI(useBlockNoteEditor());
|
|
120
|
+
return <div data-testid="ai-menu" />;
|
|
121
|
+
},
|
|
122
|
+
AIToolbarButton: () => {
|
|
123
|
+
__requireAI(useBlockNoteEditor());
|
|
124
|
+
return <div data-testid="ai-toolbar-button" />;
|
|
125
|
+
},
|
|
126
|
+
getAISlashMenuItems: () => [],
|
|
127
|
+
getDefaultAIMenuItems: () => [],
|
|
128
|
+
PromptSuggestionMenu: () => null,
|
|
129
|
+
useAIDictionary: () => ({}),
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
vi.mock("ai", () => ({
|
|
134
|
+
DefaultChatTransport: function (this: any, config: unknown) {
|
|
135
|
+
h.transportConfig = config;
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
138
|
+
vi.mock("@blocknote/core", () => ({
|
|
139
|
+
BlockNoteSchema: { create: (config: unknown) => ({ config }) },
|
|
140
|
+
defaultInlineContentSpecs: {},
|
|
141
|
+
filterSuggestionItems: (items: unknown[]) => items,
|
|
142
|
+
}));
|
|
143
|
+
vi.mock("@blocknote/core/locales", () => ({ en: { core: "en" } }));
|
|
144
|
+
vi.mock("@blocknote/xl-ai/locales", () => ({ en: { ai: "en" } }));
|
|
145
|
+
vi.mock("@blocknote/shadcn/style.css", () => ({}));
|
|
146
|
+
vi.mock("@blocknote/xl-ai/style.css", () => ({}));
|
|
147
|
+
vi.mock("../BlockNoteEditorMentionInlineContent", () => ({ createMentionInlineContentSpec: () => ({}) }));
|
|
148
|
+
|
|
149
|
+
import BlockNoteEditor, { type BlockNoteAiHandle } from "../BlockNoteEditor";
|
|
150
|
+
|
|
151
|
+
const onChange = vi.fn();
|
|
152
|
+
|
|
153
|
+
// Callers write aiConfig inline, so every parent render passes a new object.
|
|
154
|
+
const renderEditor = (
|
|
155
|
+
aiEnabled: boolean,
|
|
156
|
+
opts: { withConfig?: boolean; aiHandleRef?: React.MutableRefObject<BlockNoteAiHandle | null> } = {},
|
|
157
|
+
) => (
|
|
158
|
+
<BlockNoteEditor
|
|
159
|
+
id="npc-1"
|
|
160
|
+
type="npcs"
|
|
161
|
+
onChange={onChange}
|
|
162
|
+
aiEnabled={aiEnabled}
|
|
163
|
+
aiHandleRef={opts.aiHandleRef}
|
|
164
|
+
aiConfig={
|
|
165
|
+
opts.withConfig === false
|
|
166
|
+
? undefined
|
|
167
|
+
: { endpoint: "text-generation/blocknote", entityType: "npcs", actions: ["fill-template"] }
|
|
168
|
+
}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
173
|
+
|
|
174
|
+
describe("BlockNoteEditor AI wiring", () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
h.AIExtension.mockClear();
|
|
177
|
+
h.editorsCreated = 0;
|
|
178
|
+
h.company = { id: "company-1" };
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("does not rebuild the AI extension or the editor when a new but equal aiConfig object arrives", () => {
|
|
182
|
+
const { rerender } = render(renderEditor(true));
|
|
183
|
+
rerender(renderEditor(true));
|
|
184
|
+
rerender(renderEditor(true));
|
|
185
|
+
|
|
186
|
+
expect(h.AIExtension).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(h.editorsCreated).toBe(1);
|
|
188
|
+
expect(screen.getByTestId("ai-menu")).toBeInTheDocument();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("does not rebuild the AI extension when only the company loads", () => {
|
|
192
|
+
h.company = undefined;
|
|
193
|
+
const { rerender } = render(renderEditor(true));
|
|
194
|
+
h.company = { id: "company-1" };
|
|
195
|
+
rerender(renderEditor(true));
|
|
196
|
+
|
|
197
|
+
expect(h.AIExtension).toHaveBeenCalledTimes(1);
|
|
198
|
+
expect(h.editorsCreated).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("renders no AI UI while the editor has no AI config", () => {
|
|
202
|
+
render(renderEditor(true, { withConfig: false }));
|
|
203
|
+
|
|
204
|
+
expect(h.AIExtension).not.toHaveBeenCalled();
|
|
205
|
+
expect(screen.queryByTestId("ai-menu")).not.toBeInTheDocument();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("builds the AI extension but renders no AI UI while AI is disabled", () => {
|
|
209
|
+
render(renderEditor(false));
|
|
210
|
+
|
|
211
|
+
expect(h.AIExtension).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(screen.queryByTestId("ai-menu")).not.toBeInTheDocument();
|
|
213
|
+
expect(screen.queryByTestId("ai-toolbar-button")).not.toBeInTheDocument();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does not crash when the AI config arrives after mount, and then shows the AI menu", () => {
|
|
217
|
+
h.company = undefined;
|
|
218
|
+
const { rerender } = render(renderEditor(true, { withConfig: false }));
|
|
219
|
+
expect(screen.queryByTestId("ai-menu")).not.toBeInTheDocument();
|
|
220
|
+
|
|
221
|
+
h.company = { id: "company-1" };
|
|
222
|
+
expect(() => rerender(renderEditor(true))).not.toThrow();
|
|
223
|
+
expect(screen.getByTestId("ai-menu")).toBeInTheDocument();
|
|
224
|
+
expect(h.AIExtension).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(h.editorsCreated).toBe(2);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("never rebuilds the editor when aiEnabled toggles, keeps typed content, and hides/shows the AI UI", () => {
|
|
229
|
+
const { rerender } = render(renderEditor(true));
|
|
230
|
+
const editor = h.editor;
|
|
231
|
+
expect(screen.getByTestId("ai-menu")).toBeInTheDocument();
|
|
232
|
+
|
|
233
|
+
// Text typed before credits run out.
|
|
234
|
+
editor.document.push({ type: "paragraph", content: "unsaved draft" });
|
|
235
|
+
|
|
236
|
+
rerender(renderEditor(false));
|
|
237
|
+
expect(screen.queryByTestId("ai-menu")).not.toBeInTheDocument();
|
|
238
|
+
|
|
239
|
+
rerender(renderEditor(true));
|
|
240
|
+
expect(screen.getByTestId("ai-menu")).toBeInTheDocument();
|
|
241
|
+
|
|
242
|
+
expect(h.editorsCreated).toBe(1);
|
|
243
|
+
expect(h.AIExtension).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(h.editor).toBe(editor);
|
|
245
|
+
expect(h.editor.document).toEqual([{ type: "paragraph", content: "unsaved draft" }]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("closes an open AI prompt menu without throwing when AI turns off", async () => {
|
|
249
|
+
const { rerender } = render(renderEditor(true));
|
|
250
|
+
const ai = h.ai;
|
|
251
|
+
ai.openAIMenuAtBlock("block-1");
|
|
252
|
+
|
|
253
|
+
expect(() => rerender(renderEditor(false))).not.toThrow();
|
|
254
|
+
await flush();
|
|
255
|
+
|
|
256
|
+
expect(ai.closeAIMenu).toHaveBeenCalledTimes(1);
|
|
257
|
+
expect(ai.rejectChanges).not.toHaveBeenCalled();
|
|
258
|
+
expect(ai.store.state.aiMenuState).toBe("closed");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("aborts a running AI request and rejects its changes when AI turns off", async () => {
|
|
262
|
+
const { rerender } = render(renderEditor(true));
|
|
263
|
+
const ai = h.ai;
|
|
264
|
+
ai.store.state.aiMenuState = { blockId: "block-1", status: "ai-writing" };
|
|
265
|
+
|
|
266
|
+
expect(() => rerender(renderEditor(false))).not.toThrow();
|
|
267
|
+
await flush();
|
|
268
|
+
|
|
269
|
+
expect(ai.abort).toHaveBeenCalledTimes(1);
|
|
270
|
+
expect(ai.rejectChanges).toHaveBeenCalledTimes(1);
|
|
271
|
+
expect(ai.store.state.aiMenuState).toBe("closed");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("sends no AI request while AI is disabled", () => {
|
|
275
|
+
const aiHandleRef: React.MutableRefObject<BlockNoteAiHandle | null> = { current: null };
|
|
276
|
+
const { rerender } = render(renderEditor(true, { aiHandleRef }));
|
|
277
|
+
const heldHandle = aiHandleRef.current;
|
|
278
|
+
expect(heldHandle).not.toBeNull();
|
|
279
|
+
|
|
280
|
+
rerender(renderEditor(false, { aiHandleRef }));
|
|
281
|
+
expect(aiHandleRef.current).toBeNull();
|
|
282
|
+
|
|
283
|
+
// A handle kept across the toggle does nothing.
|
|
284
|
+
heldHandle!.invokeAi("fill-template");
|
|
285
|
+
expect(h.ai.invokeAI).not.toHaveBeenCalled();
|
|
286
|
+
|
|
287
|
+
// The transport refuses to build a request.
|
|
288
|
+
const prepare = h.transportConfig.prepareSendMessagesRequest;
|
|
289
|
+
expect(() => prepare({ messages: [{ role: "user", parts: [] }], body: {} })).toThrow("AI is disabled");
|
|
290
|
+
|
|
291
|
+
rerender(renderEditor(true, { aiHandleRef }));
|
|
292
|
+
const prepared = prepare({ messages: [{ role: "user", parts: [] }], body: {} });
|
|
293
|
+
expect(prepared.body.messages[0].metadata.entityType).toBe("npcs");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -37,6 +37,12 @@ interface Props {
|
|
|
37
37
|
* disable, never replacing it.
|
|
38
38
|
*/
|
|
39
39
|
disabled?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Rendered in place of the composer: the threads stay readable and no
|
|
42
|
+
* message can be sent — the suggested questions render disabled too (e.g.
|
|
43
|
+
* the consuming app's plan gate). Omitted, the composer renders as before.
|
|
44
|
+
*/
|
|
45
|
+
composerNotice?: React.ReactNode;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/**
|
|
@@ -54,6 +60,7 @@ export function AssistantPageContainer({
|
|
|
54
60
|
detailsTitle,
|
|
55
61
|
detailsIcon,
|
|
56
62
|
disabled,
|
|
63
|
+
composerNotice,
|
|
57
64
|
}: Props) {
|
|
58
65
|
const t = useTranslations();
|
|
59
66
|
const ctx = useAssistantContext();
|
|
@@ -98,7 +105,7 @@ export function AssistantPageContainer({
|
|
|
98
105
|
messages={ctx.messages}
|
|
99
106
|
sending={ctx.sending}
|
|
100
107
|
status={ctx.status}
|
|
101
|
-
onSelectFollowUp={ctx.sendMessage}
|
|
108
|
+
onSelectFollowUp={composerNotice ? undefined : ctx.sendMessage}
|
|
102
109
|
failedMessageIds={ctx.failedMessageIds}
|
|
103
110
|
onRetry={ctx.retrySend}
|
|
104
111
|
renderApprovalAction={renderApprovalAction}
|
|
@@ -116,14 +123,18 @@ export function AssistantPageContainer({
|
|
|
116
123
|
</div>
|
|
117
124
|
</div>
|
|
118
125
|
)}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
{composerNotice ? (
|
|
127
|
+
<div className="border-t p-4">{composerNotice}</div>
|
|
128
|
+
) : (
|
|
129
|
+
<AssistantBlockNoteComposer
|
|
130
|
+
onSend={(content, opts) => ctx.sendMessage(content, { contentBlocks: opts.contentBlocks })}
|
|
131
|
+
disabled={ctx.sending || disabled}
|
|
132
|
+
mentionSearchFn={mentionSearchFn}
|
|
133
|
+
mentionSearchParams={mentionSearchParams}
|
|
134
|
+
mentionResolveFn={mentionResolveFn}
|
|
135
|
+
suggestionMenuComponent={suggestionMenuComponent}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
127
138
|
</div>
|
|
128
139
|
</RoundPageContainer>
|
|
129
140
|
);
|