@carlonicora/nextjs-jsonapi 2.0.3 → 2.1.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.
Files changed (99) hide show
  1. package/dist/AssistantInterface-DMh-zApr.d.mts +34 -0
  2. package/dist/AssistantInterface-DwdBzS9f.d.ts +34 -0
  3. package/dist/{BlockNoteEditor-QMZLV62B.js → BlockNoteEditor-DTW2BWIU.js} +22 -20
  4. package/dist/BlockNoteEditor-DTW2BWIU.js.map +1 -0
  5. package/dist/{BlockNoteEditor-QGEOVS3T.mjs → BlockNoteEditor-T7TIA5KG.mjs} +7 -5
  6. package/dist/BlockNoteEditor-T7TIA5KG.mjs.map +1 -0
  7. package/dist/{BlockNoteViewer-UW5VZ5UF.js → BlockNoteViewer-6N2GQWTI.js} +4 -2
  8. package/dist/BlockNoteViewer-6N2GQWTI.js.map +1 -0
  9. package/dist/{BlockNoteViewer-OPSCTHKJ.mjs → BlockNoteViewer-IBQRJ3WM.mjs} +4 -2
  10. package/dist/BlockNoteViewer-IBQRJ3WM.mjs.map +1 -0
  11. package/dist/billing/index.js +357 -357
  12. package/dist/billing/index.mjs +3 -3
  13. package/dist/{chunk-DS4IBFWA.js → chunk-6YIZX26T.js} +185 -33
  14. package/dist/chunk-6YIZX26T.js.map +1 -0
  15. package/dist/{chunk-PBKZYJRJ.js → chunk-C5SETS2R.js} +1258 -895
  16. package/dist/chunk-C5SETS2R.js.map +1 -0
  17. package/dist/{chunk-UXJAS6C5.js → chunk-JG7MCYCW.js} +7 -7
  18. package/dist/{chunk-UXJAS6C5.js.map → chunk-JG7MCYCW.js.map} +1 -1
  19. package/dist/{chunk-HN5ED7OF.mjs → chunk-MAWTFEY3.mjs} +2 -2
  20. package/dist/{chunk-QHGSBZCL.mjs → chunk-MOMRJHT3.mjs} +1100 -737
  21. package/dist/chunk-MOMRJHT3.mjs.map +1 -0
  22. package/dist/{chunk-BIL2WQEV.mjs → chunk-P5TMD7GK.mjs} +158 -6
  23. package/dist/chunk-P5TMD7GK.mjs.map +1 -0
  24. package/dist/client/index.js +4 -4
  25. package/dist/client/index.mjs +3 -3
  26. package/dist/components/index.d.mts +102 -11
  27. package/dist/components/index.d.ts +102 -11
  28. package/dist/components/index.js +12 -4
  29. package/dist/components/index.js.map +1 -1
  30. package/dist/components/index.mjs +11 -3
  31. package/dist/contexts/index.d.mts +29 -3
  32. package/dist/contexts/index.d.ts +29 -3
  33. package/dist/contexts/index.js +4 -4
  34. package/dist/contexts/index.mjs +3 -3
  35. package/dist/core/index.d.mts +99 -2
  36. package/dist/core/index.d.ts +99 -2
  37. package/dist/core/index.js +8 -2
  38. package/dist/core/index.js.map +1 -1
  39. package/dist/core/index.mjs +7 -1
  40. package/dist/features/help/index.js +37 -37
  41. package/dist/features/help/index.mjs +3 -3
  42. package/dist/features/tokenusage/index.js +71 -71
  43. package/dist/features/tokenusage/index.mjs +3 -3
  44. package/dist/index.d.mts +2 -2
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +9 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/index.mjs +8 -2
  49. package/dist/server/index.js +3 -3
  50. package/dist/server/index.mjs +1 -1
  51. package/package.json +1 -1
  52. package/src/components/editors/BlockNoteEditor.tsx +5 -1
  53. package/src/components/editors/BlockNoteEditorSuggestionMenuController.tsx +14 -2
  54. package/src/components/index.ts +1 -0
  55. package/src/components/viewers/BlockNoteViewer.tsx +5 -1
  56. package/src/core/index.ts +2 -0
  57. package/src/core/registry/ModuleRegistry.ts +1 -0
  58. package/src/features/assistant/components/containers/AssistantPageContainer.tsx +123 -0
  59. package/src/features/assistant/components/index.ts +2 -0
  60. package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +135 -0
  61. package/src/features/assistant/components/parts/AssistantThread.tsx +4 -1
  62. package/src/features/assistant/contexts/AssistantContext.tsx +60 -13
  63. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +81 -0
  64. package/src/features/assistant/data/Assistant.ts +29 -1
  65. package/src/features/assistant/data/AssistantInterface.ts +15 -0
  66. package/src/features/assistant/data/AssistantService.ts +19 -2
  67. package/src/features/assistant/data/__tests__/Assistant.scope.spec.ts +64 -0
  68. package/src/features/assistant-action/AssistantActionModule.ts +13 -0
  69. package/src/features/assistant-action/components/ApprovalActionCard.tsx +127 -0
  70. package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +34 -0
  71. package/src/features/assistant-action/components/index.ts +2 -0
  72. package/src/features/assistant-action/data/AssistantAction.ts +58 -0
  73. package/src/features/assistant-action/data/AssistantActionInterface.ts +24 -0
  74. package/src/features/assistant-action/data/AssistantActionService.ts +51 -0
  75. package/src/features/assistant-action/data/__tests__/AssistantAction.spec.ts +171 -0
  76. package/src/features/assistant-action/data/index.ts +3 -0
  77. package/src/features/assistant-action/index.ts +2 -0
  78. package/src/features/assistant-message/components/MessageItem.tsx +57 -6
  79. package/src/features/assistant-message/components/MessageList.tsx +11 -2
  80. package/src/features/assistant-message/components/__tests__/MessageItem.mentions.spec.tsx +34 -0
  81. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +30 -13
  82. package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
  83. package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +3 -1
  84. package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +4 -1
  85. package/src/features/assistant-message/components/parts/tabs/useEntityLabel.ts +35 -0
  86. package/src/features/assistant-message/data/AssistantMessage.ts +11 -2
  87. package/src/features/company/components/details/TokenStatusIndicator.tsx +18 -4
  88. package/src/features/index.ts +1 -0
  89. package/dist/AssistantInterface-B56qznuH.d.mts +0 -16
  90. package/dist/AssistantInterface-CHiJIYDY.d.ts +0 -16
  91. package/dist/BlockNoteEditor-QGEOVS3T.mjs.map +0 -1
  92. package/dist/BlockNoteEditor-QMZLV62B.js.map +0 -1
  93. package/dist/BlockNoteViewer-OPSCTHKJ.mjs.map +0 -1
  94. package/dist/BlockNoteViewer-UW5VZ5UF.js.map +0 -1
  95. package/dist/chunk-BIL2WQEV.mjs.map +0 -1
  96. package/dist/chunk-DS4IBFWA.js.map +0 -1
  97. package/dist/chunk-PBKZYJRJ.js.map +0 -1
  98. package/dist/chunk-QHGSBZCL.mjs.map +0 -1
  99. /package/dist/{chunk-HN5ED7OF.mjs.map → chunk-MAWTFEY3.mjs.map} +0 -0
@@ -15,7 +15,7 @@ var _chunkIOLU4ASDjs = require('../chunk-IOLU4ASD.js');
15
15
 
16
16
 
17
17
 
18
- var _chunkDS4IBFWAjs = require('../chunk-DS4IBFWA.js');
18
+ var _chunk6YIZX26Tjs = require('../chunk-6YIZX26T.js');
19
19
  require('../chunk-LXKSUWAV.js');
20
20
  require('../chunk-IBS6NI7D.js');
21
21
 
@@ -86,7 +86,7 @@ var ServerSession = class {
86
86
  if (!rawModules) return false;
87
87
  const modules = JSON.parse(_pako.ungzip.call(void 0, Buffer.from(rawModules, "base64"), { toText: true }));
88
88
  const selectedModule = modules.find((module) => module.id === params.module.moduleId);
89
- return _chunkDS4IBFWAjs.checkPermissionsFromServer.call(void 0, {
89
+ return _chunk6YIZX26Tjs.checkPermissionsFromServer.call(void 0, {
90
90
  module: params.module,
91
91
  action: params.action,
92
92
  data: params.data,
@@ -296,5 +296,5 @@ _chunk7QVYU63Ejs.__name.call(void 0, ServerJsonApiDelete, "ServerJsonApiDelete")
296
296
 
297
297
 
298
298
 
299
- exports.ServerAuthService = _chunkDS4IBFWAjs.AuthService; exports.ServerCompanyService = _chunkDS4IBFWAjs.CompanyService; exports.ServerContentService = _chunkDS4IBFWAjs.ContentService; exports.ServerFeatureService = _chunkDS4IBFWAjs.FeatureService; exports.ServerJsonApiDelete = ServerJsonApiDelete; exports.ServerJsonApiGet = ServerJsonApiGet; exports.ServerJsonApiPatch = ServerJsonApiPatch; exports.ServerJsonApiPost = ServerJsonApiPost; exports.ServerJsonApiPut = ServerJsonApiPut; exports.ServerNotificationService = _chunkDS4IBFWAjs.NotificationService; exports.ServerPushService = _chunkDS4IBFWAjs.PushService; exports.ServerRoleService = _chunkDS4IBFWAjs.RoleService; exports.ServerS3Service = _chunkDS4IBFWAjs.S3Service; exports.ServerSession = ServerSession; exports.ServerUserService = _chunkDS4IBFWAjs.UserService; exports.configureServerJsonApi = configureServerJsonApi; exports.getServerApiUrl = getServerApiUrl; exports.getServerAppUrl = getServerAppUrl; exports.getServerToken = _chunkYUO55Q5Ajs.getServerToken; exports.getServerTrackablePages = getServerTrackablePages; exports.invalidateCacheTag = invalidateCacheTag; exports.invalidateCacheTags = invalidateCacheTags; exports.serverRequest = _chunkIOLU4ASDjs.serverRequest;
299
+ exports.ServerAuthService = _chunk6YIZX26Tjs.AuthService; exports.ServerCompanyService = _chunk6YIZX26Tjs.CompanyService; exports.ServerContentService = _chunk6YIZX26Tjs.ContentService; exports.ServerFeatureService = _chunk6YIZX26Tjs.FeatureService; exports.ServerJsonApiDelete = ServerJsonApiDelete; exports.ServerJsonApiGet = ServerJsonApiGet; exports.ServerJsonApiPatch = ServerJsonApiPatch; exports.ServerJsonApiPost = ServerJsonApiPost; exports.ServerJsonApiPut = ServerJsonApiPut; exports.ServerNotificationService = _chunk6YIZX26Tjs.NotificationService; exports.ServerPushService = _chunk6YIZX26Tjs.PushService; exports.ServerRoleService = _chunk6YIZX26Tjs.RoleService; exports.ServerS3Service = _chunk6YIZX26Tjs.S3Service; exports.ServerSession = ServerSession; exports.ServerUserService = _chunk6YIZX26Tjs.UserService; exports.configureServerJsonApi = configureServerJsonApi; exports.getServerApiUrl = getServerApiUrl; exports.getServerAppUrl = getServerAppUrl; exports.getServerToken = _chunkYUO55Q5Ajs.getServerToken; exports.getServerTrackablePages = getServerTrackablePages; exports.invalidateCacheTag = invalidateCacheTag; exports.invalidateCacheTags = invalidateCacheTags; exports.serverRequest = _chunkIOLU4ASDjs.serverRequest;
300
300
  //# sourceMappingURL=index.js.map
@@ -15,7 +15,7 @@ import {
15
15
  S3Service,
16
16
  UserService,
17
17
  checkPermissionsFromServer
18
- } from "../chunk-BIL2WQEV.mjs";
18
+ } from "../chunk-P5TMD7GK.mjs";
19
19
  import "../chunk-AUXK7QSA.mjs";
20
20
  import "../chunk-C7C7VY4F.mjs";
21
21
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "2.0.3",
3
+ "version": "2.1.1",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -36,6 +36,7 @@ import {
36
36
  XIcon,
37
37
  } from "lucide-react";
38
38
  import { useTranslations } from "next-intl";
39
+ import { useTheme } from "next-themes";
39
40
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
40
41
  import { getPublicApiUrl } from "../../client/config";
41
42
  import { getClientToken } from "../../client/token";
@@ -410,6 +411,7 @@ export default function BlockNoteEditor({
410
411
  }: BlockNoteEditorProps): React.JSX.Element {
411
412
  const t = useTranslations();
412
413
  const locale = useI18nLocale();
414
+ const { resolvedTheme } = useTheme();
413
415
  const { company } = useCurrentUserContext<UserInterface>();
414
416
 
415
417
  const [acceptedChanges, setAcceptedChanges] = useState<Set<string>>(new Set());
@@ -833,7 +835,9 @@ export default function BlockNoteEditor({
833
835
  editable={onChange !== undefined}
834
836
  formattingToolbar={false}
835
837
  slashMenu={!aiConfig}
836
- theme="light"
838
+ // Follow the app theme: hardcoding "light" leaves dark (light-theme)
839
+ // text on the dark card, which is nearly unreadable in dark mode.
840
+ theme={resolvedTheme === "dark" ? "dark" : "light"}
837
841
  // `className` is applied by BlockNote to both the main `.bn-container`
838
842
  // AND `editor.portalElement` (the floating-UI portal root). Gate `p-4`
839
843
  // on `.bn-container` so it doesn't add padding to the empty portal
@@ -12,6 +12,16 @@ export interface MentionItem {
12
12
  icon?: React.ReactNode;
13
13
  }
14
14
 
15
+ /**
16
+ * A suggestion item enriched with the entity it points at. Entity NAMES are not
17
+ * unique (two clues can both be called "New clue"), so a custom
18
+ * `suggestionMenuComponent` MUST key its rows on `id`, never on `title`.
19
+ */
20
+ export type MentionSuggestionItem = DefaultReactSuggestionItem & {
21
+ id?: string;
22
+ entityType?: string;
23
+ };
24
+
15
25
  export type MentionSearchFn = (query: string, params?: Record<string, string>) => Promise<MentionItem[]>;
16
26
 
17
27
  export type MentionInsertFn = (id: string, name: string, entityType: string) => void;
@@ -57,7 +67,9 @@ export function BlockNoteEditorMentionSuggestionMenu({
57
67
  const getItems = useCallback(
58
68
  async (query: string): Promise<DefaultReactSuggestionItem[]> => {
59
69
  const results = await mentionSearchFn(query, mentionSearchParams);
60
- const items: DefaultReactSuggestionItem[] = results.map((item) => ({
70
+ const items: MentionSuggestionItem[] = results.map((item) => ({
71
+ id: item.id,
72
+ entityType: item.entityType,
61
73
  title: item.name,
62
74
  subtext: item.entityType,
63
75
  icon: item.icon as React.ReactElement | undefined,
@@ -76,7 +88,7 @@ export function BlockNoteEditorMentionSuggestionMenu({
76
88
  },
77
89
  }));
78
90
  if (items.length === 0) {
79
- items.push({ title: KEEP_OPEN_SENTINEL_TITLE, onItemClick: () => {} });
91
+ items.push({ id: KEEP_OPEN_SENTINEL_TITLE, title: KEEP_OPEN_SENTINEL_TITLE, onItemClick: () => {} });
80
92
  }
81
93
  return items;
82
94
  },
@@ -26,6 +26,7 @@ export * from "../features/content/components";
26
26
  export * from "../features/how-to/components";
27
27
  export * from "../features/assistant/components";
28
28
  export * from "../features/assistant-message/components";
29
+ export * from "../features/assistant-action/components";
29
30
  export * from "../features/feature/components";
30
31
  export * from "../features/notification/components";
31
32
  export * from "../features/onboarding/components";
@@ -7,6 +7,7 @@ import type { PartialBlock } from "@blocknote/core";
7
7
  // this can render on auth-free public pages.
8
8
  import { BlockNoteView } from "@blocknote/shadcn";
9
9
  import "@blocknote/shadcn/style.css";
10
+ import { useTheme } from "next-themes";
10
11
 
11
12
  function normalize(content: unknown): PartialBlock[] | undefined {
12
13
  if (Array.isArray(content) && content.length > 0) return content as PartialBlock[];
@@ -23,5 +24,8 @@ function normalize(content: unknown): PartialBlock[] | undefined {
23
24
 
24
25
  export function BlockNoteViewer({ content }: { content: unknown }) {
25
26
  const editor = useCreateBlockNote({ initialContent: normalize(content) });
26
- return <BlockNoteView editor={editor} editable={false} theme="light" />;
27
+ // Outside a next-themes ThemeProvider (public pages) resolvedTheme is
28
+ // undefined, so this falls back to "light".
29
+ const { resolvedTheme } = useTheme();
30
+ return <BlockNoteView editor={editor} editable={false} theme={resolvedTheme === "dark" ? "dark" : "light"} />;
27
31
  }
package/src/core/index.ts CHANGED
@@ -65,6 +65,8 @@ export * from "../features/assistant/AssistantModule";
65
65
  export * from "../features/assistant/data";
66
66
  export * from "../features/assistant-message/AssistantMessageModule";
67
67
  export * from "../features/assistant-message/data";
68
+ export * from "../features/assistant-action/AssistantActionModule";
69
+ export * from "../features/assistant-action/data";
68
70
  export * from "../features/chunk/ChunkModule";
69
71
  export * from "../features/chunk/data";
70
72
  export * from "../features/feature/data";
@@ -18,6 +18,7 @@ export interface FoundationModuleDefinitions {
18
18
  HowTo: ModuleWithPermissions;
19
19
  Assistant: ModuleWithPermissions;
20
20
  AssistantMessage: ModuleWithPermissions;
21
+ AssistantAction: ModuleWithPermissions;
21
22
  Chunk: ModuleWithPermissions;
22
23
  // Billing modules - READ: all users, UPDATE: CompanyAdministrator, ADMIN: Administrator
23
24
  Billing: ModuleWithPermissions;
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ import type { DefaultReactSuggestionItem, SuggestionMenuProps } from "@blocknote/react";
4
+ import { Sparkles } from "lucide-react";
5
+ import { useTranslations } from "next-intl";
6
+ import type React from "react";
7
+ import { RoundPageContainer } from "../../../../components/containers/RoundPageContainer";
8
+ import type { MentionResolveFn } from "../../../../components/editors/BlockNoteEditorMentionInlineContent";
9
+ import type { MentionItem } from "../../../../components/editors/BlockNoteEditorSuggestionMenuController";
10
+ import { SectionHeader } from "../../../../components/typography";
11
+ import { Modules } from "../../../../core";
12
+ import type { ApprovalActionRenderer, MentionRenderer } from "../../../assistant-message/components/MessageItem";
13
+ import { useAssistantContext } from "../../contexts/AssistantContext";
14
+ import { AssistantBlockNoteComposer } from "../parts/AssistantBlockNoteComposer";
15
+ import { AssistantSidebar } from "../parts/AssistantSidebar";
16
+ import { AssistantThread } from "../parts/AssistantThread";
17
+ import { AssistantThreadHeader } from "../parts/AssistantThreadHeader";
18
+
19
+ interface Props {
20
+ renderApprovalAction?: ApprovalActionRenderer;
21
+ renderMention?: MentionRenderer;
22
+ mentionSearchFn?: (query: string, params?: Record<string, string>) => Promise<MentionItem[]>;
23
+ mentionSearchParams?: Record<string, string>;
24
+ mentionResolveFn?: MentionResolveFn;
25
+ /**
26
+ * Custom mention menu for the composer. Without one BlockNote renders its
27
+ * default menu, which keys rows on the item title — duplicate entity names
28
+ * then collide.
29
+ */
30
+ suggestionMenuComponent?: React.FC<SuggestionMenuProps<DefaultReactSuggestionItem>>;
31
+ /** Rendered inside the sidebar's details panel above the thread list. */
32
+ detailsTitle: string;
33
+ detailsIcon: React.ReactNode;
34
+ }
35
+
36
+ /**
37
+ * Full-page assistant: the thread list lives in the page's details panel and
38
+ * the conversation fills the card. Unlike `AssistantContainer` the composer is
39
+ * the BlockNote one, so the very first message of a thread can carry mentions.
40
+ */
41
+ export function AssistantPageContainer({
42
+ renderApprovalAction,
43
+ renderMention,
44
+ mentionSearchFn,
45
+ mentionSearchParams,
46
+ mentionResolveFn,
47
+ suggestionMenuComponent,
48
+ detailsTitle,
49
+ detailsIcon,
50
+ }: Props) {
51
+ const t = useTranslations();
52
+ const ctx = useAssistantContext();
53
+ const showThread = !!ctx.assistant || ctx.sending || ctx.messages.length > 0;
54
+
55
+ return (
56
+ <RoundPageContainer
57
+ module={Modules.Assistant}
58
+ fullWidth
59
+ forceHeader
60
+ defaultDetailsOpen
61
+ detailsTitle={detailsTitle}
62
+ detailsIcon={detailsIcon}
63
+ details={
64
+ // The panel supplies its own padding and border; cancel both so the
65
+ // sidebar reads as the panel body rather than a card inside it.
66
+ <div className="-m-4 flex h-full min-h-0 [&>aside]:w-full [&>aside]:border-r-0 [&>aside]:bg-transparent">
67
+ <AssistantSidebar
68
+ threads={ctx.threads}
69
+ activeId={ctx.assistant?.id}
70
+ onSelect={ctx.selectThread}
71
+ onNew={ctx.startNew}
72
+ />
73
+ </div>
74
+ }
75
+ >
76
+ <div className="bg-background flex h-full w-full flex-col overflow-hidden rounded-lg border">
77
+ {showThread ? (
78
+ <>
79
+ {ctx.assistant ? (
80
+ <AssistantThreadHeader
81
+ assistant={ctx.assistant}
82
+ onRename={(title) => ctx.renameThread(ctx.assistant!.id, title)}
83
+ onDelete={() => ctx.deleteThread(ctx.assistant!.id)}
84
+ />
85
+ ) : (
86
+ <div className="flex items-center justify-between border-b px-5 py-3" aria-hidden>
87
+ <div className="h-5" />
88
+ </div>
89
+ )}
90
+ <AssistantThread
91
+ messages={ctx.messages}
92
+ sending={ctx.sending}
93
+ status={ctx.status}
94
+ onSelectFollowUp={ctx.sendMessage}
95
+ failedMessageIds={ctx.failedMessageIds}
96
+ onRetry={ctx.retrySend}
97
+ renderApprovalAction={renderApprovalAction}
98
+ renderMention={renderMention}
99
+ />
100
+ </>
101
+ ) : (
102
+ <div className="flex flex-1 items-center justify-center p-10">
103
+ <div className="flex max-w-2xl flex-col items-center text-center">
104
+ <span className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-blue-400 to-violet-500 text-white">
105
+ <Sparkles className="h-5 w-5" />
106
+ </span>
107
+ <SectionHeader>{t("features.assistant.empty_state.title")}</SectionHeader>
108
+ <p className="text-muted-foreground mt-1 text-sm">{t("features.assistant.empty_state.subtitle")}</p>
109
+ </div>
110
+ </div>
111
+ )}
112
+ <AssistantBlockNoteComposer
113
+ onSend={(content, opts) => ctx.sendMessage(content, { contentBlocks: opts.contentBlocks })}
114
+ disabled={ctx.sending}
115
+ mentionSearchFn={mentionSearchFn}
116
+ mentionSearchParams={mentionSearchParams}
117
+ mentionResolveFn={mentionResolveFn}
118
+ suggestionMenuComponent={suggestionMenuComponent}
119
+ />
120
+ </div>
121
+ </RoundPageContainer>
122
+ );
123
+ }
@@ -1 +1,3 @@
1
1
  export * from "./containers/AssistantContainer";
2
+ export * from "./containers/AssistantPageContainer";
3
+ export * from "./parts/AssistantBlockNoteComposer";
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import { ArrowUp } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useCallback, useId, useState, type FC, type KeyboardEvent } from "react";
6
+ import type { DefaultReactSuggestionItem, SuggestionMenuProps } from "@blocknote/react";
7
+ import { BlockNoteEditorContainer } from "../../../../components/editors/BlockNoteEditorContainer";
8
+ import type { MentionResolveFn } from "../../../../components/editors/BlockNoteEditorMentionInlineContent";
9
+ import type { MentionItem } from "../../../../components/editors/BlockNoteEditorSuggestionMenuController";
10
+ import { Button } from "../../../../shadcnui";
11
+
12
+ interface Props {
13
+ onSend: (content: string, opts: { contentBlocks: unknown[] }) => Promise<void>;
14
+ disabled?: boolean;
15
+ mentionSearchFn?: (query: string, params?: Record<string, string>) => Promise<MentionItem[]>;
16
+ mentionSearchParams?: Record<string, string>;
17
+ mentionResolveFn?: MentionResolveFn;
18
+ /**
19
+ * Custom mention menu. Without one BlockNote renders its default menu, which
20
+ * keys rows on the item title — duplicate entity names then collide.
21
+ */
22
+ suggestionMenuComponent?: FC<SuggestionMenuProps<DefaultReactSuggestionItem>>;
23
+ }
24
+
25
+ /**
26
+ * Flattens a BlockNote document to a single plain-text string. This is only a
27
+ * FALLBACK for the server, which re-derives the stored text from the blocks it
28
+ * receives — the blocks are the source of truth, never this string.
29
+ */
30
+ function flattenBlocks(blocks: unknown[]): string {
31
+ const lines: string[] = [];
32
+
33
+ const readInline = (inline: unknown): string => {
34
+ if (typeof inline === "string") return inline;
35
+ if (!inline || typeof inline !== "object") return "";
36
+ const node = inline as { type?: string; text?: string; props?: { alias?: string }; content?: unknown };
37
+ if (node.type === "mention") return node.props?.alias ?? "";
38
+ if (typeof node.text === "string") return node.text;
39
+ if (Array.isArray(node.content)) return node.content.map(readInline).join("");
40
+ return "";
41
+ };
42
+
43
+ const readBlock = (block: unknown): void => {
44
+ if (!block || typeof block !== "object") return;
45
+ const node = block as { content?: unknown; children?: unknown };
46
+ if (Array.isArray(node.content)) {
47
+ const line = node.content.map(readInline).join("").trim();
48
+ if (line) lines.push(line);
49
+ } else if (typeof node.content === "string" && node.content.trim()) {
50
+ lines.push(node.content.trim());
51
+ }
52
+ if (Array.isArray(node.children)) node.children.forEach(readBlock);
53
+ };
54
+
55
+ blocks.forEach(readBlock);
56
+ return lines.join("\n");
57
+ }
58
+
59
+ /**
60
+ * Mention-capable chat composer. Unlike `AssistantComposer` (plain textarea) it
61
+ * sends the BlockNote document alongside the flattened text, so the server can
62
+ * resolve `@`-mentions to real entities and pin them into the agent's focus.
63
+ */
64
+ export function AssistantBlockNoteComposer({
65
+ onSend,
66
+ disabled,
67
+ mentionSearchFn,
68
+ mentionSearchParams,
69
+ mentionResolveFn,
70
+ suggestionMenuComponent,
71
+ }: Props) {
72
+ const t = useTranslations();
73
+ // React's generated ids carry delimiters (`:r0:` / `«r0»`) and this value ends
74
+ // up inside an S3 object key, so keep it to path-safe characters.
75
+ const editorId = useId().replace(/[^a-zA-Z0-9]/g, "");
76
+ const [blocks, setBlocks] = useState<unknown[]>([]);
77
+ const [isEmpty, setIsEmpty] = useState(true);
78
+ // Remounting the editor is the only reliable way to clear it: the editor
79
+ // ignores an `initialContent` that merely echoes what it last emitted.
80
+ const [resetKey, setResetKey] = useState(0);
81
+
82
+ const canSend = !isEmpty && !disabled;
83
+
84
+ const submit = useCallback(async () => {
85
+ if (isEmpty || disabled) return;
86
+ const payload = blocks;
87
+ const plainText = flattenBlocks(payload);
88
+ setBlocks([]);
89
+ setIsEmpty(true);
90
+ setResetKey((previous) => previous + 1);
91
+ await onSend(plainText, { contentBlocks: payload });
92
+ }, [blocks, disabled, isEmpty, onSend]);
93
+
94
+ const handleKeyDownCapture = useCallback(
95
+ (event: KeyboardEvent<HTMLDivElement>) => {
96
+ if (event.key !== "Enter" || event.shiftKey) return;
97
+ // The mention suggestion menu claims Enter with a CAPTURING document
98
+ // listener of its own. If we preventDefault here the highlighted mention
99
+ // is never inserted, so stand down whenever the menu is on screen.
100
+ if (typeof document !== "undefined" && document.querySelector(".bn-suggestion-menu")) return;
101
+ event.preventDefault();
102
+ void submit();
103
+ },
104
+ [submit],
105
+ );
106
+
107
+ return (
108
+ <div className="flex flex-col gap-1 border-t p-4">
109
+ <div className="bg-muted/30 flex items-end gap-2 rounded-lg border p-2">
110
+ <div className="max-h-40 min-w-0 flex-1 overflow-y-auto" onKeyDownCapture={handleKeyDownCapture}>
111
+ <BlockNoteEditorContainer
112
+ key={resetKey}
113
+ id={editorId}
114
+ type="assistants"
115
+ placeholder={t("features.assistant.composer_placeholder")}
116
+ enableMentions
117
+ mentionSearchFn={mentionSearchFn}
118
+ mentionSearchParams={mentionSearchParams}
119
+ mentionResolveFn={mentionResolveFn}
120
+ suggestionMenuComponent={suggestionMenuComponent}
121
+ onChange={(content: unknown, empty: boolean) => {
122
+ setBlocks(Array.isArray(content) ? content : []);
123
+ setIsEmpty(empty);
124
+ }}
125
+ className="[&_.bn-container]:!p-0"
126
+ />
127
+ </div>
128
+ <Button onClick={submit} disabled={!canSend} size="sm" className="h-8">
129
+ <ArrowUp className="mr-1 h-4 w-4" /> {t("features.assistant.send")}
130
+ </Button>
131
+ </div>
132
+ <div className="text-muted-foreground text-right text-xs">{t("features.assistant.keyboard_hint")}</div>
133
+ </div>
134
+ );
135
+ }
@@ -3,7 +3,7 @@
3
3
  import { useEffect, useRef } from "react";
4
4
  import type { AssistantMessageInterface } from "../../../assistant-message/data/AssistantMessageInterface";
5
5
  import { MessageList } from "../../../assistant-message/components/MessageList";
6
- import type { ApprovalActionRenderer } from "../../../assistant-message/components/MessageItem";
6
+ import type { ApprovalActionRenderer, MentionRenderer } from "../../../assistant-message/components/MessageItem";
7
7
  import { AssistantStatusLine } from "./AssistantStatusLine";
8
8
 
9
9
  interface Props {
@@ -14,6 +14,7 @@ interface Props {
14
14
  failedMessageIds?: Set<string>;
15
15
  onRetry?: (tempId: string) => void;
16
16
  renderApprovalAction?: ApprovalActionRenderer;
17
+ renderMention?: MentionRenderer;
17
18
  }
18
19
 
19
20
  export function AssistantThread({
@@ -24,6 +25,7 @@ export function AssistantThread({
24
25
  failedMessageIds,
25
26
  onRetry,
26
27
  renderApprovalAction,
28
+ renderMention,
27
29
  }: Props) {
28
30
  const endRef = useRef<HTMLDivElement>(null);
29
31
 
@@ -39,6 +41,7 @@ export function AssistantThread({
39
41
  failedMessageIds={failedMessageIds}
40
42
  onRetry={onRetry}
41
43
  renderApprovalAction={renderApprovalAction}
44
+ renderMention={renderMention}
42
45
  />
43
46
  {sending && <AssistantStatusLine status={status} />}
44
47
  <div ref={endRef} />
@@ -20,7 +20,10 @@ interface AssistantContextValue {
20
20
  sending: boolean;
21
21
  status?: string;
22
22
  failedMessageIds: Set<string>;
23
- sendMessage(content: string, opts?: { howToMode?: boolean; limitToHowToId?: string }): Promise<void>;
23
+ sendMessage(
24
+ content: string,
25
+ opts?: { howToMode?: boolean; limitToHowToId?: string; contentBlocks?: unknown[] },
26
+ ): Promise<void>;
24
27
  retrySend(tempId: string): Promise<void>;
25
28
  selectThread(id: string): Promise<void>;
26
29
  startNew(): void;
@@ -46,6 +49,28 @@ interface Props {
46
49
  * current route is preserved.
47
50
  */
48
51
  manageUrl?: boolean;
52
+ /**
53
+ * Confines the provider to one bound resource (e.g. a campaign): the thread
54
+ * list is filtered to that resource and newly created threads are bound to
55
+ * it, so a scoped surface never shows or creates cross-scope threads.
56
+ */
57
+ scope?: { type: string; id: string };
58
+ /**
59
+ * Builds the browser URL for a thread. Defaults to `/assistants/{id}` (or
60
+ * `/assistants` when no thread is active), which is only correct for the
61
+ * standalone assistant route; a scoped host passes its own builder.
62
+ */
63
+ threadUrl?: (id?: string) => string;
64
+ /**
65
+ * Page heading. Defaults to the "Assistants" entity label; a scoped host
66
+ * passes something meaningful for its own surface.
67
+ */
68
+ titleOverride?: string;
69
+ /**
70
+ * Breadcrumb trail. Defaults to a single crumb pointing at the global
71
+ * assistant list, which is wrong for a scoped surface.
72
+ */
73
+ breadcrumbsOverride?: BreadcrumbItemData[];
49
74
  }
50
75
 
51
76
  function stripOptimistic(list: AssistantMessageInterface[]): AssistantMessageInterface[] {
@@ -67,10 +92,24 @@ function withPatchedTitle(source: AssistantInterface, title: string): AssistantI
67
92
  });
68
93
  }
69
94
 
70
- export function AssistantProvider({ children, dehydratedAssistant, dehydratedMessages, manageUrl = true }: Props) {
95
+ export function AssistantProvider({
96
+ children,
97
+ dehydratedAssistant,
98
+ dehydratedMessages,
99
+ manageUrl = true,
100
+ titleOverride,
101
+ breadcrumbsOverride,
102
+ scope,
103
+ threadUrl,
104
+ }: Props) {
71
105
  const t = useTranslations();
72
106
  const generateUrl = usePageUrlGenerator();
73
107
 
108
+ const resolveThreadUrl = useCallback(
109
+ (id?: string) => (threadUrl ? threadUrl(id) : id ? `/assistants/${id}` : "/assistants"),
110
+ [threadUrl],
111
+ );
112
+
74
113
  const [assistant, setAssistant] = useState<AssistantInterface | undefined>(() =>
75
114
  dehydratedAssistant ? rehydrate<AssistantInterface>(Modules.Assistant, dehydratedAssistant) : undefined,
76
115
  );
@@ -91,7 +130,7 @@ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMes
91
130
  const { socket } = useSocketContext();
92
131
 
93
132
  const sendMessage = useCallback(
94
- async (content: string, opts?: { howToMode?: boolean; limitToHowToId?: string }) => {
133
+ async (content: string, opts?: { howToMode?: boolean; limitToHowToId?: string; contentBlocks?: unknown[] }) => {
95
134
  const trimmed = content.trim();
96
135
  if (!trimmed) return;
97
136
 
@@ -116,6 +155,8 @@ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMes
116
155
  firstMessage: trimmed,
117
156
  howToMode: opts?.howToMode,
118
157
  limitToHowToId: opts?.limitToHowToId,
158
+ contentBlocks: opts?.contentBlocks,
159
+ boundContent: scope,
119
160
  };
120
161
  const created = operatorMode
121
162
  ? await AssistantService.createOperator(input)
@@ -125,19 +166,21 @@ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMes
125
166
  setMessages(msgs);
126
167
  setThreads((prev) => [created, ...prev]);
127
168
  if (manageUrl && typeof window !== "undefined") {
128
- window.history.replaceState(null, "", `/assistants/${created.id}`);
169
+ window.history.replaceState(null, "", resolveThreadUrl(created.id));
129
170
  }
130
171
  } else {
131
172
  const result = operatorMode
132
173
  ? await AssistantService.appendMessageOperator({
133
174
  assistantId: assistant.id,
134
175
  content: trimmed,
176
+ contentBlocks: opts?.contentBlocks,
135
177
  })
136
178
  : await AssistantService.appendMessage({
137
179
  assistantId: assistant.id,
138
180
  content: trimmed,
139
181
  howToMode: opts?.howToMode,
140
182
  limitToHowToId: opts?.limitToHowToId,
183
+ contentBlocks: opts?.contentBlocks,
141
184
  });
142
185
  setMessages((prev) => [...stripOptimistic(prev), ...result]);
143
186
  }
@@ -153,7 +196,7 @@ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMes
153
196
  setStatus(undefined);
154
197
  }
155
198
  },
156
- [assistant, messages, socket, operatorMode],
199
+ [assistant, messages, socket, operatorMode, scope, manageUrl, resolveThreadUrl],
157
200
  );
158
201
 
159
202
  const appendResolvedMessage = useCallback((message: AssistantMessageInterface) => {
@@ -191,10 +234,10 @@ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMes
191
234
  // absent) keeps the default responder flow.
192
235
  setOperatorMode(target.engine === "operator");
193
236
  if (manageUrl && typeof window !== "undefined") {
194
- window.history.replaceState(null, "", `/assistants/${id}`);
237
+ window.history.replaceState(null, "", resolveThreadUrl(id));
195
238
  }
196
239
  },
197
- [manageUrl],
240
+ [manageUrl, resolveThreadUrl],
198
241
  );
199
242
 
200
243
  const renameThread = useCallback(async (id: string, title: string) => {
@@ -209,9 +252,9 @@ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMes
209
252
  setFailedMessageIds(new Set());
210
253
  setOperatorMode(false);
211
254
  if (manageUrl && typeof window !== "undefined") {
212
- window.history.replaceState(null, "", "/assistants");
255
+ window.history.replaceState(null, "", resolveThreadUrl());
213
256
  }
214
- }, [manageUrl]);
257
+ }, [manageUrl, resolveThreadUrl]);
215
258
 
216
259
  const deleteThread = useCallback(async (id: string) => {
217
260
  await AssistantService.delete({ id });
@@ -230,7 +273,7 @@ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMes
230
273
  (async () => {
231
274
  setThreadsLoading(true);
232
275
  try {
233
- const loaded = await AssistantService.findMany();
276
+ const loaded = await AssistantService.findMany({ boundType: scope?.type, boundId: scope?.id });
234
277
  if (!cancelled) setThreads(loaded);
235
278
  } finally {
236
279
  if (!cancelled) setThreadsLoading(false);
@@ -239,7 +282,7 @@ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMes
239
282
  return () => {
240
283
  cancelled = true;
241
284
  };
242
- }, []);
285
+ }, [scope?.type, scope?.id]);
243
286
 
244
287
  const value = useMemo<AssistantContextValue>(
245
288
  () => ({
@@ -279,15 +322,19 @@ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMes
279
322
  ],
280
323
  );
281
324
 
282
- const breadcrumbs: BreadcrumbItemData[] = [
325
+ // A scoped assistant lives under its host resource, so the default trail to
326
+ // the global /assistants list is wrong there — the consumer supplies its own.
327
+ const breadcrumbs: BreadcrumbItemData[] = breadcrumbsOverride ?? [
283
328
  {
284
329
  name: t("entities.assistants", { count: 2 }),
285
330
  href: generateUrl({ page: Modules.Assistant }),
286
331
  },
287
332
  ];
288
333
 
334
+ // `entities.tasks` was a copy-paste from another feature and rendered
335
+ // "Tasks" as the page heading of every assistant page.
289
336
  const title = {
290
- type: t("entities.tasks", { count: 2 }),
337
+ type: titleOverride ?? t("entities.assistants", { count: 2 }),
291
338
  };
292
339
 
293
340
  return (