@carlonicora/nextjs-jsonapi 1.79.0 → 1.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/{AssistantMessageInterface-DWnbd6J7.d.ts → AssistantMessageInterface-BpEhx2pC.d.ts} +18 -1
  2. package/dist/{AssistantMessageInterface-Mla6kgPe.d.mts → AssistantMessageInterface-DJ3Me16Y.d.mts} +18 -1
  3. package/dist/{BlockNoteEditor-6CBDTVKV.mjs → BlockNoteEditor-3M5PD3BZ.mjs} +4 -4
  4. package/dist/{BlockNoteEditor-EH4HWI7H.js → BlockNoteEditor-YLTPJPTV.js} +14 -14
  5. package/dist/{BlockNoteEditor-EH4HWI7H.js.map → BlockNoteEditor-YLTPJPTV.js.map} +1 -1
  6. package/dist/billing/index.js +346 -346
  7. package/dist/billing/index.mjs +3 -3
  8. package/dist/{chunk-ZDP3MBUI.js → chunk-4NOQNTFI.js} +1151 -740
  9. package/dist/chunk-4NOQNTFI.js.map +1 -0
  10. package/dist/{chunk-BKM5U3DE.mjs → chunk-6UMB5LTQ.mjs} +98 -7
  11. package/dist/chunk-6UMB5LTQ.mjs.map +1 -0
  12. package/dist/{chunk-5IEWLLLD.js → chunk-N4YZ45SK.js} +115 -24
  13. package/dist/chunk-N4YZ45SK.js.map +1 -0
  14. package/dist/{chunk-ENRSFVOS.mjs → chunk-NQV5RDCK.mjs} +1112 -701
  15. package/dist/chunk-NQV5RDCK.mjs.map +1 -0
  16. package/dist/{chunk-MEWXQEVE.mjs → chunk-PV5V6CVW.mjs} +2 -2
  17. package/dist/{chunk-TWDSDTHU.js → chunk-ZEJSPTHS.js} +7 -7
  18. package/dist/{chunk-TWDSDTHU.js.map → chunk-ZEJSPTHS.js.map} +1 -1
  19. package/dist/client/index.js +4 -4
  20. package/dist/client/index.mjs +3 -3
  21. package/dist/components/index.d.mts +25 -7
  22. package/dist/components/index.d.ts +25 -7
  23. package/dist/components/index.js +6 -4
  24. package/dist/components/index.js.map +1 -1
  25. package/dist/components/index.mjs +5 -3
  26. package/dist/contexts/index.d.mts +1 -1
  27. package/dist/contexts/index.d.ts +1 -1
  28. package/dist/contexts/index.js +4 -4
  29. package/dist/contexts/index.mjs +3 -3
  30. package/dist/core/index.d.mts +35 -3
  31. package/dist/core/index.d.ts +35 -3
  32. package/dist/core/index.js +6 -2
  33. package/dist/core/index.js.map +1 -1
  34. package/dist/core/index.mjs +5 -1
  35. package/dist/index.d.mts +2 -2
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +7 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +6 -2
  40. package/dist/server/index.js +3 -3
  41. package/dist/server/index.mjs +1 -1
  42. package/package.json +1 -1
  43. package/src/core/index.ts +2 -0
  44. package/src/core/registry/ModuleRegistry.ts +1 -0
  45. package/src/features/assistant/components/parts/AssistantThread.tsx +1 -1
  46. package/src/features/assistant-message/AssistantMessageModule.ts +4 -0
  47. package/src/features/assistant-message/components/MessageItem.tsx +7 -7
  48. package/src/features/assistant-message/components/MessageList.tsx +1 -1
  49. package/src/features/assistant-message/components/__tests__/MessageItem.spec.tsx +11 -7
  50. package/src/features/assistant-message/components/index.ts +1 -0
  51. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +135 -0
  52. package/src/features/assistant-message/components/parts/MessageSourcesPanel.tsx +151 -0
  53. package/src/features/assistant-message/components/parts/RelevanceMeter.tsx +29 -0
  54. package/src/features/assistant-message/components/parts/__tests__/MessageSourcesPanel.spec.tsx +70 -0
  55. package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +105 -0
  56. package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +88 -0
  57. package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +51 -0
  58. package/src/features/assistant-message/components/parts/tabs/SuggestedQuestionsTab.tsx +24 -0
  59. package/src/features/assistant-message/components/parts/tabs/UsersTab.tsx +142 -0
  60. package/src/features/assistant-message/data/AssistantMessage.ts +20 -0
  61. package/src/features/assistant-message/data/AssistantMessageInterface.ts +2 -0
  62. package/src/features/assistant-message/data/AssistantMessageService.ts +13 -4
  63. package/src/features/assistant-message/data/__tests__/AssistantMessage.citations.spec.ts +65 -0
  64. package/src/features/assistant-message/data/__tests__/AssistantMessage.spec.ts +8 -0
  65. package/src/features/chunk/ChunkModule.ts +18 -0
  66. package/src/features/chunk/data/Chunk.ts +49 -0
  67. package/src/features/chunk/data/ChunkInput.ts +3 -0
  68. package/src/features/chunk/data/ChunkInterface.ts +18 -0
  69. package/src/features/chunk/data/__tests__/Chunk.spec.ts +83 -0
  70. package/src/features/chunk/data/index.ts +3 -0
  71. package/src/features/chunk/index.ts +2 -0
  72. package/dist/chunk-5IEWLLLD.js.map +0 -1
  73. package/dist/chunk-BKM5U3DE.mjs.map +0 -1
  74. package/dist/chunk-ENRSFVOS.mjs.map +0 -1
  75. package/dist/chunk-ZDP3MBUI.js.map +0 -1
  76. package/src/features/assistant-message/components/parts/ReferenceBadges.tsx +0 -46
  77. package/src/features/assistant-message/components/parts/SuggestedFollowUps.tsx +0 -52
  78. package/src/features/assistant-message/components/parts/__tests__/ReferenceBadges.spec.tsx +0 -59
  79. package/src/features/assistant-message/components/parts/__tests__/SuggestedFollowUps.spec.tsx +0 -29
  80. /package/dist/{BlockNoteEditor-6CBDTVKV.mjs.map → BlockNoteEditor-3M5PD3BZ.mjs.map} +0 -0
  81. /package/dist/{chunk-MEWXQEVE.mjs.map → chunk-PV5V6CVW.mjs.map} +0 -0
package/dist/index.mjs CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  getWaitlistConfig,
18
18
  isReferralEnabled,
19
19
  isRolesConfigured
20
- } from "./chunk-MEWXQEVE.mjs";
20
+ } from "./chunk-PV5V6CVW.mjs";
21
21
  import {
22
22
  AVAILABLE_OAUTH_SCOPES,
23
23
  AbstractApiData,
@@ -44,6 +44,8 @@ import {
44
44
  BillingService,
45
45
  BlockNoteDiffUtil,
46
46
  BlockNoteWordDiffRendererUtil,
47
+ Chunk,
48
+ ChunkModule,
47
49
  ClientAbstractService,
48
50
  ClientHttpMethod,
49
51
  Company,
@@ -195,7 +197,7 @@ import {
195
197
  useComposedRefs,
196
198
  useIsMobile,
197
199
  userObjectSchema
198
- } from "./chunk-BKM5U3DE.mjs";
200
+ } from "./chunk-6UMB5LTQ.mjs";
199
201
  import "./chunk-AUXK7QSA.mjs";
200
202
  import "./chunk-C7C7VY4F.mjs";
201
203
  import {
@@ -238,6 +240,8 @@ export {
238
240
  BlockNoteDiffUtil,
239
241
  BlockNoteWordDiffRendererUtil,
240
242
  COMPANY_ADMINISTRATOR_ROLE_ID,
243
+ Chunk,
244
+ ChunkModule,
241
245
  ClientAbstractService,
242
246
  ClientHttpMethod,
243
247
  Company,
@@ -15,7 +15,7 @@ var _chunk3ZPK4QOBjs = require('../chunk-3ZPK4QOB.js');
15
15
 
16
16
 
17
17
 
18
- var _chunk5IEWLLLDjs = require('../chunk-5IEWLLLD.js');
18
+ var _chunkN4YZ45SKjs = require('../chunk-N4YZ45SK.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(_pako2.default.ungzip(Buffer.from(rawModules, "base64"), { to: "string" }));
88
88
  const selectedModule = modules.find((module) => module.id === params.module.moduleId);
89
- return _chunk5IEWLLLDjs.checkPermissionsFromServer.call(void 0, {
89
+ return _chunkN4YZ45SKjs.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 = _chunk5IEWLLLDjs.AuthService; exports.ServerCompanyService = _chunk5IEWLLLDjs.CompanyService; exports.ServerContentService = _chunk5IEWLLLDjs.ContentService; exports.ServerFeatureService = _chunk5IEWLLLDjs.FeatureService; exports.ServerJsonApiDelete = ServerJsonApiDelete; exports.ServerJsonApiGet = ServerJsonApiGet; exports.ServerJsonApiPatch = ServerJsonApiPatch; exports.ServerJsonApiPost = ServerJsonApiPost; exports.ServerJsonApiPut = ServerJsonApiPut; exports.ServerNotificationService = _chunk5IEWLLLDjs.NotificationService; exports.ServerPushService = _chunk5IEWLLLDjs.PushService; exports.ServerRoleService = _chunk5IEWLLLDjs.RoleService; exports.ServerS3Service = _chunk5IEWLLLDjs.S3Service; exports.ServerSession = ServerSession; exports.ServerUserService = _chunk5IEWLLLDjs.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 = _chunk3ZPK4QOBjs.serverRequest;
299
+ exports.ServerAuthService = _chunkN4YZ45SKjs.AuthService; exports.ServerCompanyService = _chunkN4YZ45SKjs.CompanyService; exports.ServerContentService = _chunkN4YZ45SKjs.ContentService; exports.ServerFeatureService = _chunkN4YZ45SKjs.FeatureService; exports.ServerJsonApiDelete = ServerJsonApiDelete; exports.ServerJsonApiGet = ServerJsonApiGet; exports.ServerJsonApiPatch = ServerJsonApiPatch; exports.ServerJsonApiPost = ServerJsonApiPost; exports.ServerJsonApiPut = ServerJsonApiPut; exports.ServerNotificationService = _chunkN4YZ45SKjs.NotificationService; exports.ServerPushService = _chunkN4YZ45SKjs.PushService; exports.ServerRoleService = _chunkN4YZ45SKjs.RoleService; exports.ServerS3Service = _chunkN4YZ45SKjs.S3Service; exports.ServerSession = ServerSession; exports.ServerUserService = _chunkN4YZ45SKjs.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 = _chunk3ZPK4QOBjs.serverRequest;
300
300
  //# sourceMappingURL=index.js.map
@@ -15,7 +15,7 @@ import {
15
15
  S3Service,
16
16
  UserService,
17
17
  checkPermissionsFromServer
18
- } from "../chunk-BKM5U3DE.mjs";
18
+ } from "../chunk-6UMB5LTQ.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": "1.79.0",
3
+ "version": "1.80.0",
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",
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/chunk/ChunkModule";
69
+ export * from "../features/chunk/data";
68
70
  export * from "../features/feature/data";
69
71
  export * from "../features/feature/feature.module";
70
72
  export * from "../features/module";
@@ -18,6 +18,7 @@ export interface FoundationModuleDefinitions {
18
18
  HowTo: ModuleWithPermissions;
19
19
  Assistant: ModuleWithPermissions;
20
20
  AssistantMessage: ModuleWithPermissions;
21
+ Chunk: ModuleWithPermissions;
21
22
  // Billing modules - READ: all users, UPDATE: CompanyAdministrator, ADMIN: Administrator
22
23
  Billing: ModuleWithPermissions;
23
24
  StripeCustomer: ModuleWithPermissions;
@@ -22,7 +22,7 @@ export function AssistantThread({ messages, sending, status, onSelectFollowUp, f
22
22
  }, [messages.length, sending]);
23
23
 
24
24
  return (
25
- <div className="flex-1 overflow-y-auto px-6 py-5">
25
+ <div className="flex-1 min-w-0 overflow-x-hidden overflow-y-auto px-6 py-5">
26
26
  <MessageList
27
27
  messages={messages}
28
28
  onSelectFollowUp={onSelectFollowUp}
@@ -12,6 +12,7 @@ export const AssistantMessageModule = (factory: ModuleFactory) =>
12
12
  icon: MessageSquareIcon,
13
13
  inclusions: {
14
14
  lists: {
15
+ types: [`references`, `citations`, `citations.source`, `citations.source.author`],
15
16
  fields: [
16
17
  createJsonApiInclusion("assistant-messages", [
17
18
  `role`,
@@ -21,7 +22,10 @@ export const AssistantMessageModule = (factory: ModuleFactory) =>
21
22
  `inputTokens`,
22
23
  `outputTokens`,
23
24
  `references`,
25
+ `citations`,
24
26
  ]),
27
+ createJsonApiInclusion("chunks", [`content`, `nodeId`, `nodeType`, `imagePath`, `source`]),
28
+ createJsonApiInclusion("users", [`name`, `avatar`]),
25
29
  ],
26
30
  },
27
31
  },
@@ -5,8 +5,7 @@ import { Sparkles, AlertCircle } from "lucide-react";
5
5
  import ReactMarkdown from "react-markdown";
6
6
  import remarkGfm from "remark-gfm";
7
7
  import type { AssistantMessageInterface } from "../data/AssistantMessageInterface";
8
- import { ReferenceBadges } from "./parts/ReferenceBadges";
9
- import { SuggestedFollowUps } from "./parts/SuggestedFollowUps";
8
+ import { MessageSourcesContainer } from "./parts/MessageSourcesContainer";
10
9
 
11
10
  interface Props {
12
11
  message: AssistantMessageInterface;
@@ -41,7 +40,7 @@ export function MessageItem({ message, isLatestAssistant, onSelectFollowUp, fail
41
40
  }
42
41
 
43
42
  return (
44
- <div className="flex max-w-[78%] flex-col gap-1.5">
43
+ <div className="flex min-w-0 max-w-[78%] flex-col gap-1.5">
45
44
  <div className="text-muted-foreground flex items-center gap-2 pl-1 text-xs">
46
45
  <span className="flex h-4 w-4 items-center justify-center rounded-full bg-gradient-to-br from-blue-400 to-violet-500 text-white">
47
46
  <Sparkles className="h-2.5 w-2.5" />
@@ -51,10 +50,11 @@ export function MessageItem({ message, isLatestAssistant, onSelectFollowUp, fail
51
50
  <div className="bg-muted text-foreground rounded-2xl rounded-bl-sm px-3.5 py-2.5 text-sm leading-relaxed">
52
51
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
53
52
  </div>
54
- <ReferenceBadges references={message.references} />
55
- {isLatestAssistant && (
56
- <SuggestedFollowUps questions={message.suggestedQuestions ?? []} onSelect={onSelectFollowUp} />
57
- )}
53
+ <MessageSourcesContainer
54
+ message={message}
55
+ isLatestAssistant={isLatestAssistant}
56
+ onSelectFollowUp={onSelectFollowUp}
57
+ />
58
58
  </div>
59
59
  );
60
60
  }
@@ -22,7 +22,7 @@ export function MessageList({ messages, onSelectFollowUp, failedMessageIds, onRe
22
22
  }
23
23
 
24
24
  return (
25
- <div className="flex flex-col gap-y-3">
25
+ <div className="flex min-w-0 flex-col gap-y-3">
26
26
  {ordered.map((m, i) => (
27
27
  <MessageItem
28
28
  key={m.id}
@@ -8,7 +8,7 @@ import type { ApiDataInterface } from "../../../../core";
8
8
  import type { AssistantMessageInterface } from "../../data/AssistantMessageInterface";
9
9
  import { MessageItem } from "../MessageItem";
10
10
 
11
- // Test-only account model to exercise ReferenceBadges
11
+ // Test-only account model to exercise the sources panel references list
12
12
  class TestAccount extends AbstractApiData {
13
13
  static identifierFields: string[] = ["name"];
14
14
  rehydrate(data: any): this {
@@ -51,6 +51,7 @@ function buildMessageStub(p: {
51
51
  content: p.content ?? "",
52
52
  position: 0,
53
53
  references: p.references ?? [],
54
+ citations: [],
54
55
  suggestedQuestions: p.suggestedQuestions ?? [],
55
56
  createdAt: new Date(),
56
57
  updatedAt: new Date(),
@@ -66,7 +67,7 @@ describe("MessageItem", () => {
66
67
  expect(screen.queryByText("features.assistant.agent_name")).not.toBeInTheDocument();
67
68
  });
68
69
 
69
- it("assistant message renders bubble, references, and suggestions toggle when latest", () => {
70
+ it("assistant message renders agent label and the sources panel toggle when sources exist", () => {
70
71
  const msg = buildMessageStub({
71
72
  role: "assistant",
72
73
  content: "**bold** reply",
@@ -75,14 +76,17 @@ describe("MessageItem", () => {
75
76
  });
76
77
  render(<MessageItem message={msg} isLatestAssistant={true} onSelectFollowUp={vi.fn()} />);
77
78
  expect(screen.getByText("features.assistant.agent_name")).toBeInTheDocument();
78
- expect(screen.getByRole("link", { name: /acme/i })).toBeInTheDocument();
79
- expect(screen.getByRole("button", { name: /show_suggestions/ })).toBeInTheDocument();
79
+ // The MessageSourcesPanel toggle uses the message.sources.toggle translation key.
80
+ expect(screen.getByRole("button", { name: /sources\.toggle/i })).toBeInTheDocument();
80
81
  });
81
82
 
82
- it("assistant message NOT latest: no suggestions toggle", () => {
83
+ it("assistant message NOT latest with only suggestions: no sources panel rendered", () => {
83
84
  const msg = buildMessageStub({ role: "assistant", content: "prior", suggestedQuestions: ["x"] });
84
- render(<MessageItem message={msg} isLatestAssistant={false} onSelectFollowUp={vi.fn()} />);
85
- expect(screen.queryByRole("button", { name: /show_suggestions/ })).not.toBeInTheDocument();
85
+ const { container } = render(<MessageItem message={msg} isLatestAssistant={false} onSelectFollowUp={vi.fn()} />);
86
+ // No sources/citations/references and suggestions are gated by isLatestAssistant -> panel renders nothing.
87
+ expect(screen.queryByRole("button", { name: /sources\.toggle/i })).not.toBeInTheDocument();
88
+ // Sanity: the assistant bubble itself still rendered.
89
+ expect(container).not.toBeEmptyDOMElement();
86
90
  });
87
91
 
88
92
  it("failed user message: renders retry control and calls onRetry with the id", async () => {
@@ -1,2 +1,3 @@
1
1
  export * from "./MessageItem";
2
2
  export * from "./MessageList";
3
+ export { MessageSourcesPanel } from "./parts/MessageSourcesPanel";
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { ClientAbstractService, ClientHttpMethod, EndpointCreator, ModuleRegistry } from "../../../../core";
5
+ import type { ApiDataInterface, ApiRequestDataTypeInterface } from "../../../../core";
6
+ import type { AssistantMessageInterface } from "../../data/AssistantMessageInterface";
7
+ import { MessageSourcesPanel } from "./MessageSourcesPanel";
8
+
9
+ interface Props {
10
+ message: AssistantMessageInterface;
11
+ isLatestAssistant: boolean;
12
+ onSelectFollowUp: (q: string) => void;
13
+ }
14
+
15
+ class SourcesFetcher extends ClientAbstractService {
16
+ static async findManyByIds(params: {
17
+ module: ApiRequestDataTypeInterface;
18
+ idsParam: string;
19
+ ids: string[];
20
+ }): Promise<ApiDataInterface[]> {
21
+ const endpoint = new EndpointCreator({ endpoint: params.module });
22
+ endpoint.addAdditionalParam(params.idsParam, params.ids.join(","));
23
+ if (params.module.inclusions?.lists?.fields) endpoint.limitToFields(params.module.inclusions.lists.fields);
24
+ if (params.module.inclusions?.lists?.types) endpoint.limitToType(params.module.inclusions.lists.types);
25
+ return this.callApi<ApiDataInterface[]>({
26
+ type: params.module,
27
+ method: ClientHttpMethod.GET,
28
+ endpoint: endpoint.generate(),
29
+ });
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Library-level data wrapper around `MessageSourcesPanel`. Reads
35
+ * `chunk.nodeId` / `chunk.nodeType` from the message's citations, dynamically
36
+ * resolves the matching module via `ModuleRegistry.findByModelName(nodeType)`,
37
+ * fetches the entities in one request per type, and supplies them (plus their
38
+ * deduplicated `author` users) to the presentational panel.
39
+ */
40
+ export function MessageSourcesContainer({ message, isLatestAssistant, onSelectFollowUp }: Props) {
41
+ // Group nodeIds per nodeType so we issue one request per source type.
42
+ const groups = useMemo(() => {
43
+ const map = new Map<string, Set<string>>();
44
+ for (const chunk of message.citations) {
45
+ if (!chunk.nodeType || !chunk.nodeId) continue;
46
+ let bucket = map.get(chunk.nodeType);
47
+ if (!bucket) {
48
+ bucket = new Set<string>();
49
+ map.set(chunk.nodeType, bucket);
50
+ }
51
+ bucket.add(chunk.nodeId);
52
+ }
53
+ return map;
54
+ }, [message.citations]);
55
+
56
+ // Flatten to a deterministic key so useEffect re-runs when ids change.
57
+ const groupsKey = useMemo(() => {
58
+ const parts: string[] = [];
59
+ for (const [nodeType, ids] of groups) {
60
+ parts.push(`${nodeType}:${Array.from(ids).sort().join(",")}`);
61
+ }
62
+ return parts.sort().join("|");
63
+ }, [groups]);
64
+
65
+ const [resolved, setResolved] = useState<ApiDataInterface[]>([]);
66
+
67
+ useEffect(() => {
68
+ if (groups.size === 0) {
69
+ setResolved([]);
70
+ return;
71
+ }
72
+ let cancelled = false;
73
+ const requests: Promise<ApiDataInterface[]>[] = [];
74
+ for (const [nodeType, idSet] of groups) {
75
+ let module: ApiRequestDataTypeInterface | undefined;
76
+ try {
77
+ module = ModuleRegistry.findByModelName(nodeType);
78
+ } catch {
79
+ continue; // No registered module for this nodeType — skip silently.
80
+ }
81
+ const idsParam = `${(module as any).nodeName ?? module.name.replace(/s$/, "")}Ids`;
82
+ requests.push(
83
+ SourcesFetcher.findManyByIds({
84
+ module,
85
+ idsParam,
86
+ ids: Array.from(idSet),
87
+ }).catch((error) => {
88
+ console.error(`Failed to load sources for ${nodeType}:`, error);
89
+ return [] as ApiDataInterface[];
90
+ }),
91
+ );
92
+ }
93
+ Promise.all(requests).then((results) => {
94
+ if (cancelled) return;
95
+ setResolved(results.flat());
96
+ });
97
+ return () => {
98
+ cancelled = true;
99
+ };
100
+ }, [groupsKey, groups]);
101
+
102
+ const sources = useMemo(() => {
103
+ const map = new Map<string, ApiDataInterface>();
104
+ for (const entity of resolved) map.set(entity.id, entity);
105
+ return map;
106
+ }, [resolved]);
107
+
108
+ const users = useMemo(() => {
109
+ const userMap = new Map<string, ApiDataInterface>();
110
+ for (const entity of resolved) {
111
+ // Content.author getter throws when missing; tolerate either shape.
112
+ const author =
113
+ (entity as any)._author ??
114
+ (() => {
115
+ try {
116
+ return (entity as any).author;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ })();
121
+ if (author?.id) userMap.set(author.id, author);
122
+ }
123
+ return Array.from(userMap.values());
124
+ }, [resolved]);
125
+
126
+ return (
127
+ <MessageSourcesPanel
128
+ message={message}
129
+ isLatestAssistant={isLatestAssistant}
130
+ onSelectFollowUp={onSelectFollowUp}
131
+ sources={sources}
132
+ users={users}
133
+ />
134
+ );
135
+ }
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { useTranslations } from "next-intl";
5
+ import { ChevronDown, ChevronRight } from "lucide-react";
6
+ import type { ApiDataInterface } from "../../../../core";
7
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
8
+ import type { AssistantMessageInterface } from "../../data/AssistantMessageInterface";
9
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../../shadcnui/ui/tabs";
10
+ import { ReferencesTab } from "./tabs/ReferencesTab";
11
+ import { CitationsTab } from "./tabs/CitationsTab";
12
+ import { ContentsTab } from "./tabs/ContentsTab";
13
+ import { UsersTab } from "./tabs/UsersTab";
14
+ import { SuggestedQuestionsTab } from "./tabs/SuggestedQuestionsTab";
15
+
16
+ interface Props {
17
+ message: AssistantMessageInterface;
18
+ isLatestAssistant: boolean;
19
+ onSelectFollowUp: (q: string) => void;
20
+ /**
21
+ * Map of resolved source entities keyed by `chunk.nodeId`. When provided, the
22
+ * Contents and Citations tabs use these to render real entity names. The
23
+ * library is presentational; the app fetches and supplies these.
24
+ */
25
+ sources?: Map<string, ApiDataInterface>;
26
+ /**
27
+ * Deduplicated list of authors derived from the resolved sources. When
28
+ * provided, the Users tab is populated from this list.
29
+ */
30
+ users?: ApiDataInterface[];
31
+ }
32
+
33
+ type TabKey = "suggested" | "references" | "citations" | "contents" | "users";
34
+
35
+ export function MessageSourcesPanel({ message, isLatestAssistant, onSelectFollowUp, sources, users }: Props) {
36
+ const t = useTranslations();
37
+
38
+ // Filter out references whose module has no pageUrl: those are
39
+ // intermediary/relational nodes (e.g., BomItem, BomTreeNode) that the user
40
+ // shouldn't navigate to directly.
41
+ const visibleReferences = useMemo(
42
+ () =>
43
+ message.references.filter((ref) => {
44
+ try {
45
+ return !!ModuleRegistry.findByName(ref.type).pageUrl;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }),
50
+ [message.references],
51
+ );
52
+
53
+ const refsCount = visibleReferences.length;
54
+ const citationsCount = message.citations.length;
55
+ const suggestionsCount = isLatestAssistant ? message.suggestedQuestions.length : 0;
56
+
57
+ const contentsCount = useMemo(() => {
58
+ if (sources) return sources.size;
59
+ // Fallback: derive from unique nodeId on chunks when sources haven't been
60
+ // supplied yet (e.g., during the initial fetch).
61
+ const ids = new Set<string>();
62
+ for (const c of message.citations) {
63
+ if (c.nodeId) ids.add(c.nodeId);
64
+ }
65
+ return ids.size;
66
+ }, [message.citations, sources]);
67
+
68
+ const usersCount = users?.length ?? 0;
69
+
70
+ const total = refsCount + citationsCount + contentsCount + usersCount + suggestionsCount;
71
+
72
+ const visibleTabs: TabKey[] = [];
73
+ if (suggestionsCount > 0) visibleTabs.push("suggested");
74
+ if (refsCount > 0) visibleTabs.push("references");
75
+ if (citationsCount > 0) visibleTabs.push("citations");
76
+ if (contentsCount > 0) visibleTabs.push("contents");
77
+ if (usersCount > 0) visibleTabs.push("users");
78
+
79
+ const autoOpen = isLatestAssistant && suggestionsCount > 0;
80
+ const initialTab: TabKey | undefined = autoOpen ? "suggested" : visibleTabs[0];
81
+ const [open, setOpen] = useState(autoOpen);
82
+ const [active, setActive] = useState<TabKey | undefined>(initialTab);
83
+
84
+ if (total === 0) return null;
85
+
86
+ const panelId = `sources-panel-${message.id}`;
87
+
88
+ return (
89
+ <div className="mt-2 w-full min-w-0">
90
+ <button
91
+ type="button"
92
+ onClick={() => setOpen((v) => !v)}
93
+ aria-expanded={open}
94
+ aria-controls={panelId}
95
+ className="text-primary inline-flex items-center gap-1 text-xs font-medium"
96
+ >
97
+ {open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
98
+ {open
99
+ ? t("features.assistant.message.sources.toggle_hide")
100
+ : t("features.assistant.message.sources.toggle", { count: total })}
101
+ </button>
102
+
103
+ {open && (
104
+ <div id={panelId} className="mt-2 w-full min-w-0">
105
+ <Tabs value={active} onValueChange={(v) => setActive(v as TabKey)}>
106
+ <TabsList>
107
+ {visibleTabs.map((key) => (
108
+ <TabsTrigger key={key} value={key}>
109
+ {t(`features.assistant.message.sources.tabs.${key === "suggested" ? "suggested_questions" : key}`)}
110
+ <span className="text-muted-foreground ml-1.5 text-[10px]">
111
+ {key === "suggested" && suggestionsCount}
112
+ {key === "references" && refsCount}
113
+ {key === "citations" && citationsCount}
114
+ {key === "contents" && contentsCount}
115
+ {key === "users" && usersCount}
116
+ </span>
117
+ </TabsTrigger>
118
+ ))}
119
+ </TabsList>
120
+
121
+ {suggestionsCount > 0 && (
122
+ <TabsContent value="suggested">
123
+ <SuggestedQuestionsTab questions={message.suggestedQuestions} onSelect={onSelectFollowUp} />
124
+ </TabsContent>
125
+ )}
126
+ {refsCount > 0 && (
127
+ <TabsContent value="references">
128
+ <ReferencesTab references={visibleReferences} />
129
+ </TabsContent>
130
+ )}
131
+ {citationsCount > 0 && (
132
+ <TabsContent value="citations">
133
+ <CitationsTab citations={message.citations} sources={sources} />
134
+ </TabsContent>
135
+ )}
136
+ {contentsCount > 0 && (
137
+ <TabsContent value="contents">
138
+ <ContentsTab citations={message.citations} sources={sources} />
139
+ </TabsContent>
140
+ )}
141
+ {usersCount > 0 && (
142
+ <TabsContent value="users">
143
+ <UsersTab users={users ?? []} citations={message.citations} sources={sources} />
144
+ </TabsContent>
145
+ )}
146
+ </Tabs>
147
+ </div>
148
+ )}
149
+ </div>
150
+ );
151
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ interface Props {
4
+ /** 0-1 (decimal) or 0-100 (percent). Auto-detected. */
5
+ value: number;
6
+ className?: string;
7
+ }
8
+
9
+ export function RelevanceMeter({ value, className = "" }: Props) {
10
+ const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
11
+ const label = `${Math.round(pct)}%`;
12
+ return (
13
+ <div
14
+ role="meter"
15
+ aria-valuenow={Math.round(pct)}
16
+ aria-valuemin={0}
17
+ aria-valuemax={100}
18
+ aria-label={`Relevance ${label}`}
19
+ className={`bg-muted relative mx-auto flex h-5 w-20 items-center justify-center overflow-hidden rounded border ${className}`}
20
+ >
21
+ <div className="bg-accent absolute top-0 left-0 h-full" style={{ width: `${pct}%` }} />
22
+ <span
23
+ className={`relative text-xs ${pct < 40 ? "text-muted-foreground" : "text-accent-foreground font-semibold"}`}
24
+ >
25
+ {label}
26
+ </span>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,70 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
4
+ import { MessageSourcesPanel } from "../MessageSourcesPanel";
5
+
6
+ const baseMessage: any = {
7
+ id: "m1",
8
+ role: "assistant",
9
+ content: "x",
10
+ references: [],
11
+ citations: [],
12
+ suggestedQuestions: [],
13
+ };
14
+
15
+ beforeAll(() => {
16
+ // Register a stub Order module so reference filtering by `pageUrl` succeeds in tests.
17
+ ModuleRegistry.register("Order" as any, { name: "orders", pageUrl: "/orders" } as any);
18
+ });
19
+
20
+ afterAll(() => {
21
+ // No public unregister; safe to leave the stub since other suites do not collide on this name.
22
+ });
23
+
24
+ describe("MessageSourcesPanel", () => {
25
+ it("renders nothing when message has no sources", () => {
26
+ const { container } = render(
27
+ <MessageSourcesPanel message={baseMessage} isLatestAssistant={false} onSelectFollowUp={vi.fn()} />,
28
+ );
29
+ expect(container).toBeEmptyDOMElement();
30
+ });
31
+
32
+ it("renders the toggle when at least one source exists", () => {
33
+ const msg = {
34
+ ...baseMessage,
35
+ references: [{ id: "r1", type: "orders", identifier: "ORD-001" }],
36
+ };
37
+ render(<MessageSourcesPanel message={msg} isLatestAssistant={false} onSelectFollowUp={vi.fn()} />);
38
+ // Mocked next-intl returns the translation key verbatim,
39
+ // so the toggle button name contains "sources.toggle".
40
+ expect(screen.getByRole("button", { name: /sources\.toggle/i })).toBeInTheDocument();
41
+ });
42
+
43
+ it("hides references whose module has no pageUrl (middle entities)", () => {
44
+ ModuleRegistry.register("BomItem" as any, { name: "bom-items" } as any);
45
+ const msg = {
46
+ ...baseMessage,
47
+ references: [{ id: "b1", type: "bom-items", identifier: "BI-1" }],
48
+ };
49
+ const { container } = render(
50
+ <MessageSourcesPanel message={msg} isLatestAssistant={false} onSelectFollowUp={vi.fn()} />,
51
+ );
52
+ expect(container).toBeEmptyDOMElement();
53
+ });
54
+
55
+ it("auto-opens with Suggested questions tab when isLatestAssistant and questions exist", () => {
56
+ const msg = { ...baseMessage, suggestedQuestions: ["Why?"] };
57
+ render(<MessageSourcesPanel message={msg} isLatestAssistant={true} onSelectFollowUp={vi.fn()} />);
58
+ const tab = screen.getByRole("tab", { name: /suggested/i });
59
+ expect(tab).toBeInTheDocument();
60
+ // Base UI uses data-active on the active tab/panel; aria-selected reflects active state too.
61
+ expect(tab.getAttribute("aria-selected")).toBe("true");
62
+ expect(screen.getByText("Why?")).toBeInTheDocument();
63
+ });
64
+
65
+ it("does not show Suggested questions tab on non-latest message", () => {
66
+ const msg = { ...baseMessage, suggestedQuestions: ["Why?"] };
67
+ render(<MessageSourcesPanel message={msg} isLatestAssistant={false} onSelectFollowUp={vi.fn()} />);
68
+ expect(screen.queryByRole("tab", { name: /suggested/i })).not.toBeInTheDocument();
69
+ });
70
+ });