@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
@@ -0,0 +1,171 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AbstractService } from "../../../../core/abstracts/AbstractService";
3
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
4
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
5
+ import { AssistantAction } from "../AssistantAction";
6
+ import { AssistantActionService } from "../AssistantActionService";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Setup / teardown — the services resolve their endpoints through the real
10
+ // ModuleRegistry, so the two modules the feature touches must be registered.
11
+ // ---------------------------------------------------------------------------
12
+ const assistantActionModule: ApiRequestDataTypeInterface = {
13
+ name: "assistant-actions",
14
+ model: AssistantAction,
15
+ } as any;
16
+
17
+ const assistantMessageModule: ApiRequestDataTypeInterface = {
18
+ name: "assistant-messages",
19
+ model: class {},
20
+ } as any;
21
+
22
+ // `callApi` is protected static on AbstractService; spying on it there covers
23
+ // every subclass through the static prototype chain.
24
+ let callApiMock: ReturnType<typeof vi.fn>;
25
+
26
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
27
+ try {
28
+ ModuleRegistry.get(key as any);
29
+ } catch {
30
+ ModuleRegistry.register(key, module);
31
+ }
32
+ };
33
+
34
+ beforeAll(() => {
35
+ registerIfAbsent("AssistantAction", assistantActionModule);
36
+ registerIfAbsent("AssistantMessage", assistantMessageModule);
37
+ callApiMock = vi.spyOn(AbstractService as any, "callApi") as unknown as ReturnType<typeof vi.fn>;
38
+ });
39
+
40
+ afterAll(() => {
41
+ vi.restoreAllMocks();
42
+ });
43
+
44
+ function makeHydratedData(overrides: Record<string, any> = {}) {
45
+ return {
46
+ jsonApi: {
47
+ id: "action-1",
48
+ type: "assistant-actions",
49
+ attributes: {
50
+ status: "pending",
51
+ toolName: "createNpc",
52
+ toolArgs: '{"name":"A"}',
53
+ summary: "Create a new record",
54
+ threadId: "thread-1",
55
+ userModuleIds: ["module-1"],
56
+ expiresAt: "2026-06-10T12:00:00.000Z",
57
+ ...overrides,
58
+ },
59
+ meta: {},
60
+ relationships: {},
61
+ },
62
+ included: [],
63
+ };
64
+ }
65
+
66
+ describe("AssistantAction model", () => {
67
+ it("rehydrates status, toolName and summary", () => {
68
+ const action = new AssistantAction();
69
+ action.rehydrate(makeHydratedData() as any);
70
+
71
+ expect(action.id).toBe("action-1");
72
+ expect(action.status).toBe("pending");
73
+ expect(action.toolName).toBe("createNpc");
74
+ expect(action.summary).toBe("Create a new record");
75
+ });
76
+
77
+ it("parses expiresAt into a Date", () => {
78
+ const action = new AssistantAction();
79
+ action.rehydrate(makeHydratedData() as any);
80
+
81
+ expect(action.expiresAt).toBeInstanceOf(Date);
82
+ expect(action.expiresAt).toEqual(new Date("2026-06-10T12:00:00.000Z"));
83
+ });
84
+
85
+ it("parses resolvedAt into a Date when present", () => {
86
+ const action = new AssistantAction();
87
+ action.rehydrate(makeHydratedData({ status: "approved", resolvedAt: "2026-06-10T11:30:00.000Z" }) as any);
88
+
89
+ expect(action.status).toBe("approved");
90
+ expect(action.resolvedAt).toBeInstanceOf(Date);
91
+ expect(action.resolvedAt).toEqual(new Date("2026-06-10T11:30:00.000Z"));
92
+ });
93
+
94
+ it("leaves resolvedAt undefined when absent", () => {
95
+ const action = new AssistantAction();
96
+ action.rehydrate(makeHydratedData() as any);
97
+
98
+ expect(action.resolvedAt).toBeUndefined();
99
+ });
100
+
101
+ it("returns defaults for missing attributes", () => {
102
+ const action = new AssistantAction();
103
+ action.rehydrate({
104
+ jsonApi: {
105
+ id: "action-1",
106
+ type: "assistant-actions",
107
+ attributes: {},
108
+ meta: {},
109
+ relationships: {},
110
+ },
111
+ included: [],
112
+ } as any);
113
+
114
+ // Missing status defaults to the fail-safe, non-actionable "expired" — never the actionable "pending".
115
+ expect(action.status).toBe("expired");
116
+ expect(action.toolName).toBe("");
117
+ expect(action.summary).toBe("");
118
+ expect(action.resolvedAt).toBeUndefined();
119
+ expect(action.expiresAt).toBeUndefined();
120
+ });
121
+
122
+ it("creates a minimal JSON:API payload (id only)", () => {
123
+ const action = new AssistantAction();
124
+ const payload = action.createJsonApi({ id: "action-1" });
125
+
126
+ expect(payload.data.type).toBe("assistant-actions");
127
+ expect(payload.data.id).toBe("action-1");
128
+ expect(payload.data.attributes).toEqual({});
129
+ });
130
+ });
131
+
132
+ describe("AssistantActionService", () => {
133
+ beforeEach(() => {
134
+ // mockClear (not mockReset): on a spy, mockReset would restore the real
135
+ // callApi and the assertions would fire a network request.
136
+ callApiMock.mockClear();
137
+ callApiMock.mockResolvedValue({});
138
+ });
139
+
140
+ it("findOne GETs the action typed as Modules.AssistantAction", async () => {
141
+ await AssistantActionService.findOne({ id: "action-1" });
142
+
143
+ expect(callApiMock).toHaveBeenCalledTimes(1);
144
+ const call = callApiMock.mock.calls[0][0];
145
+ expect(call.type.name).toBe("assistant-actions");
146
+ expect(call.method).toBe("GET");
147
+ expect(call.endpoint).toBe("assistant-actions/action-1");
148
+ });
149
+
150
+ it("approve POSTs body-less to /approve typed as Modules.AssistantMessage", async () => {
151
+ await AssistantActionService.approve({ id: "action-1" });
152
+
153
+ expect(callApiMock).toHaveBeenCalledTimes(1);
154
+ const call = callApiMock.mock.calls[0][0];
155
+ expect(call.type.name).toBe("assistant-messages");
156
+ expect(call.method).toBe("POST");
157
+ expect(call.endpoint).toBe("assistant-actions/action-1/approve");
158
+ expect(call.input).toBeUndefined();
159
+ });
160
+
161
+ it("deny POSTs body-less to /deny typed as Modules.AssistantMessage", async () => {
162
+ await AssistantActionService.deny({ id: "action-1" });
163
+
164
+ expect(callApiMock).toHaveBeenCalledTimes(1);
165
+ const call = callApiMock.mock.calls[0][0];
166
+ expect(call.type.name).toBe("assistant-messages");
167
+ expect(call.method).toBe("POST");
168
+ expect(call.endpoint).toBe("assistant-actions/action-1/deny");
169
+ expect(call.input).toBeUndefined();
170
+ });
171
+ });
@@ -0,0 +1,3 @@
1
+ export * from "./AssistantAction";
2
+ export * from "./AssistantActionInterface";
3
+ export * from "./AssistantActionService";
@@ -0,0 +1,2 @@
1
+ export * from "./AssistantActionModule";
2
+ export * from "./data";
@@ -2,8 +2,8 @@
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
4
  import { Sparkles, AlertCircle } from "lucide-react";
5
- import type { ReactNode } from "react";
6
- import ReactMarkdown from "react-markdown";
5
+ import { useMemo, type ReactNode } from "react";
6
+ import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
7
7
  import remarkGfm from "remark-gfm";
8
8
  import type { AssistantMessageInterface } from "../data/AssistantMessageInterface";
9
9
  import { MessageSourcesContainer } from "./parts/MessageSourcesContainer";
@@ -15,6 +15,24 @@ import { MessageSourcesContainer } from "./parts/MessageSourcesContainer";
15
15
  */
16
16
  export type ApprovalActionRenderer = (message: AssistantMessageInterface) => ReactNode;
17
17
 
18
+ /**
19
+ * Render slot for `mention://<type>/<id>` links inside assistant markdown. The
20
+ * consuming app owns the entity chip (avatar, hovercard, navigation), so the
21
+ * chat renderer receives it as a function instead of importing it.
22
+ */
23
+ export type MentionRenderer = (p: { type: string; id: string; alias: string }) => ReactNode;
24
+
25
+ const MENTION_HREF = /^mention:\/\/([^/]+)\/(.+)$/;
26
+
27
+ /**
28
+ * `react-markdown` strips every href whose protocol is not in its safe list,
29
+ * which would empty `mention://…` before the custom `a` renderer ever sees it.
30
+ * Let that one scheme through and defer everything else to the default.
31
+ */
32
+ function mentionUrlTransform(url: string): string {
33
+ return MENTION_HREF.test(url) ? url : defaultUrlTransform(url);
34
+ }
35
+
18
36
  interface Props {
19
37
  message: AssistantMessageInterface;
20
38
  isLatestAssistant: boolean;
@@ -22,6 +40,7 @@ interface Props {
22
40
  failedMessageIds?: Set<string>;
23
41
  onRetry?: (tempId: string) => void;
24
42
  renderApprovalAction?: ApprovalActionRenderer;
43
+ renderMention?: MentionRenderer;
25
44
  }
26
45
 
27
46
  export function MessageItem({
@@ -31,16 +50,42 @@ export function MessageItem({
31
50
  failedMessageIds,
32
51
  onRetry,
33
52
  renderApprovalAction,
53
+ renderMention,
34
54
  }: Props) {
35
55
  const t = useTranslations();
36
56
  const isUser = message.role === "user";
37
57
  const isFailed = isUser && !!failedMessageIds?.has(message.id);
38
58
 
59
+ const markdownComponents = useMemo(
60
+ () => ({
61
+ a: ({ href, children }: { href?: string; children?: ReactNode }) => {
62
+ const match = href ? MENTION_HREF.exec(href) : null;
63
+ if (!match || !renderMention) return <a href={href}>{children}</a>;
64
+ return <>{renderMention({ type: match[1], id: match[2], alias: String(children ?? "") })}</>;
65
+ },
66
+ }),
67
+ [renderMention],
68
+ );
69
+
39
70
  if (isUser) {
40
71
  return (
41
72
  <div className="flex flex-col items-end gap-1">
42
- <div className="bg-primary text-primary-foreground max-w-[72%] rounded-2xl rounded-br-sm px-3.5 py-2 text-sm">
43
- {message.content}
73
+ {/*
74
+ Rendered as markdown, not raw text: a message composed with mentions
75
+ is stored as `[Alias](mention://<type>/<id>)`, which would otherwise
76
+ show the link markup and a bare uuid to the person who just wrote it.
77
+ `markdownComponents` routes those hrefs through `renderMention`.
78
+ */}
79
+ {/*
80
+ Links inside this bubble sit on `bg-primary`. The mention chip and any
81
+ markdown link default to `text-primary`, which is the same teal and
82
+ renders invisible here, so force the on-primary colour and lean on
83
+ weight for the affordance (links are never underlined in this UI).
84
+ */}
85
+ <div className="bg-primary text-primary-foreground max-w-[72%] rounded-2xl rounded-br-sm px-3.5 py-2 text-sm [&_a]:text-primary-foreground [&_a]:font-semibold [&_p]:m-0">
86
+ <ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={mentionUrlTransform} components={markdownComponents}>
87
+ {message.content}
88
+ </ReactMarkdown>
44
89
  </div>
45
90
  {isFailed && (
46
91
  <div className="text-destructive flex items-center gap-2 text-xs">
@@ -68,8 +113,14 @@ export function MessageItem({
68
113
  {isApprovalRequest ? (
69
114
  renderApprovalAction(message)
70
115
  ) : (
71
- <div className="bg-muted text-foreground rounded-2xl rounded-bl-sm px-3.5 py-2.5 text-sm leading-relaxed">
72
- <ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
116
+ // Markdown blocks arrive unstyled under Tailwind preflight (all margins
117
+ // zeroed): restore flow spacing between sibling blocks and give
118
+ // blockquotes a callout treatment — assistant answers use them for
119
+ // proposals and asides that must stand apart from factual prose.
120
+ <div className="bg-muted text-foreground rounded-2xl rounded-bl-sm px-3.5 py-2.5 text-sm leading-relaxed [&>*+*]:mt-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/50 [&_blockquote]:bg-primary/5 [&_blockquote]:rounded-md [&_blockquote]:px-3 [&_blockquote]:py-1.5">
121
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents} urlTransform={mentionUrlTransform}>
122
+ {message.content}
123
+ </ReactMarkdown>
73
124
  </div>
74
125
  )}
75
126
  <MessageSourcesContainer
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { AssistantMessageInterface } from "../data/AssistantMessageInterface";
4
- import { MessageItem, type ApprovalActionRenderer } from "./MessageItem";
4
+ import { MessageItem, type ApprovalActionRenderer, type MentionRenderer } from "./MessageItem";
5
5
 
6
6
  interface Props {
7
7
  messages: AssistantMessageInterface[];
@@ -9,9 +9,17 @@ interface Props {
9
9
  failedMessageIds?: Set<string>;
10
10
  onRetry?: (tempId: string) => void;
11
11
  renderApprovalAction?: ApprovalActionRenderer;
12
+ renderMention?: MentionRenderer;
12
13
  }
13
14
 
14
- export function MessageList({ messages, onSelectFollowUp, failedMessageIds, onRetry, renderApprovalAction }: Props) {
15
+ export function MessageList({
16
+ messages,
17
+ onSelectFollowUp,
18
+ failedMessageIds,
19
+ onRetry,
20
+ renderApprovalAction,
21
+ renderMention,
22
+ }: Props) {
15
23
  const ordered = [...messages].sort((a, b) => a.position - b.position);
16
24
 
17
25
  let lastAssistantIndex = -1;
@@ -33,6 +41,7 @@ export function MessageList({ messages, onSelectFollowUp, failedMessageIds, onRe
33
41
  failedMessageIds={failedMessageIds}
34
42
  onRetry={onRetry}
35
43
  renderApprovalAction={renderApprovalAction}
44
+ renderMention={renderMention}
36
45
  />
37
46
  ))}
38
47
  </div>
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { renderWithProviders, screen } from "../../../../testing";
3
+ import type { AssistantMessageInterface } from "../../data/AssistantMessageInterface";
4
+ import { MessageItem } from "../MessageItem";
5
+
6
+ const message = {
7
+ id: "m1",
8
+ type: "assistant-messages",
9
+ role: "assistant",
10
+ position: 1,
11
+ content: "Ask [The Quiet One](mention://npcs/npc-1) about it.",
12
+ references: [],
13
+ citations: [],
14
+ suggestedQuestions: [],
15
+ } as unknown as AssistantMessageInterface;
16
+
17
+ describe("MessageItem mention rendering", () => {
18
+ it("hands mention links to renderMention", () => {
19
+ renderWithProviders(
20
+ <MessageItem
21
+ message={message}
22
+ isLatestAssistant={false}
23
+ onSelectFollowUp={vi.fn()}
24
+ renderMention={({ type, id, alias }) => <span data-testid="mention">{`${type}:${id}:${alias}`}</span>}
25
+ />,
26
+ );
27
+ expect(screen.getByTestId("mention")).toHaveTextContent("npcs:npc-1:The Quiet One");
28
+ });
29
+
30
+ it("renders a plain link when no renderer is supplied", () => {
31
+ renderWithProviders(<MessageItem message={message} isLatestAssistant={false} onSelectFollowUp={vi.fn()} />);
32
+ expect(screen.getByText("The Quiet One")).toBeInTheDocument();
33
+ });
34
+ });
@@ -18,19 +18,36 @@ class SourcesFetcher extends ClientAbstractService {
18
18
  idsParam: string;
19
19
  ids: string[];
20
20
  }): Promise<ApiDataInterface[]> {
21
- const endpoint = new EndpointCreator({ endpoint: params.module });
22
- endpoint.addAdditionalParam(params.idsParam, params.ids.join(","));
23
- // The list endpoint paginates and does not honour the `<x>Ids` filter, so a cited
24
- // entity beyond the default first page never comes back and its citation falls back
25
- // to "<Type> <id>". Request the full set and resolve client-side by id.
26
- endpoint.addAdditionalParam("fetchAll", "true");
27
- if (params.module.inclusions?.lists?.fields) endpoint.limitToFields(params.module.inclusions.lists.fields);
28
- if (params.module.inclusions?.lists?.types) endpoint.limitToType(params.module.inclusions.lists.types);
29
- return this.callApi<ApiDataInterface[]>({
30
- type: params.module,
31
- method: ClientHttpMethod.GET,
32
- endpoint: endpoint.generate(),
33
- });
21
+ // Resolved one id at a time, deliberately.
22
+ //
23
+ // The obvious alternative a single `GET /<type>?<type>Ids=…&fetchAll=true`
24
+ // assumes every entity exposes a flat collection route. narr8 does not:
25
+ // it serves `/npcs/:id` and `/campaigns/:campaignId/npcs`, with no
26
+ // `GET /npcs`, so the bulk call 404s, every citation degrades to
27
+ // "<Type> <uuid>", and the browser logs a failed request for each source
28
+ // type on every message. Reading by id needs only the by-id route, which
29
+ // every entity has. Citation counts are small (a handful per message) and
30
+ // the reads run in parallel, so the extra round-trips are cheap.
31
+ return this.findEachById(params);
32
+ }
33
+
34
+ private static async findEachById(params: {
35
+ module: ApiRequestDataTypeInterface;
36
+ ids: string[];
37
+ }): Promise<ApiDataInterface[]> {
38
+ const settled = await Promise.allSettled(
39
+ params.ids.map((id) =>
40
+ this.callApi<ApiDataInterface>({
41
+ type: params.module,
42
+ method: ClientHttpMethod.GET,
43
+ endpoint: new EndpointCreator({ endpoint: params.module, id }).generate(),
44
+ }),
45
+ ),
46
+ );
47
+ // A deleted or unreadable source must not blank out the whole panel.
48
+ return settled
49
+ .filter((r): r is PromiseFulfilledResult<ApiDataInterface> => r.status === "fulfilled" && !!r.value)
50
+ .map((r) => r.value);
34
51
  }
35
52
  }
36
53
 
@@ -11,6 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
11
11
  import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../../shadcnui/ui/tooltip";
12
12
  import { cn } from "@/lib/utils";
13
13
  import { RelevanceMeter } from "../RelevanceMeter";
14
+ import { useEntityLabel } from "./useEntityLabel";
14
15
 
15
16
  interface Props {
16
17
  citations: (ChunkInterface & ChunkRelationshipMeta)[];
@@ -24,6 +25,7 @@ interface Props {
24
25
 
25
26
  export function CitationsTab({ citations, sources }: Props) {
26
27
  const t = useTranslations();
28
+ const entityLabel = useEntityLabel();
27
29
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
28
30
  if (citations.length === 0) return null;
29
31
 
@@ -50,9 +52,12 @@ export function CitationsTab({ citations, sources }: Props) {
50
52
  {sorted.map((chunk) => {
51
53
  const isOpen = expanded.has(chunk.id);
52
54
  const resolved = chunk.nodeId ? sources?.get(chunk.nodeId) : undefined;
53
- const fallbackName = chunk.nodeId
54
- ? `${chunk.nodeType ?? t("features.assistant.message.sources.source")} ${chunk.nodeId.slice(0, 8)}`
55
- : (chunk.nodeType ?? t("features.assistant.message.sources.source"));
55
+ // `nodeType` is a JSON:API wire type (`npcs`), not UI copy — translate
56
+ // it the same way the References tab does before showing it.
57
+ const typeLabel = chunk.nodeType
58
+ ? entityLabel(chunk.nodeType)
59
+ : t("features.assistant.message.sources.source");
60
+ const fallbackName = chunk.nodeId ? `${typeLabel} ${chunk.nodeId.slice(0, 8)}` : typeLabel;
56
61
  const sourceName = (resolved as any)?.name ?? fallbackName;
57
62
  return (
58
63
  <Fragment key={chunk.id}>
@@ -89,8 +94,21 @@ export function CitationsTab({ citations, sources }: Props) {
89
94
  </TableRow>
90
95
  {isOpen && (
91
96
  <TableRow>
92
- <TableCell colSpan={2} className="border-t-0 p-4">
93
- <div className="bg-card w-full max-w-full overflow-x-auto rounded border p-4 text-sm break-words">
97
+ {/*
98
+ `max-w-0` is the table-layout trick: without it the cell
99
+ sizes to its widest line, so the panel either scrolls
100
+ sideways or clips. Pinning the cell's intrinsic max width to
101
+ zero makes the browser take its width from the table instead,
102
+ which lets the content below actually wrap.
103
+ */}
104
+ <TableCell colSpan={2} className="w-full max-w-0 border-t-0 p-4">
105
+ {/*
106
+ Prose wraps; only genuinely wide children scroll. The
107
+ wrapper used to be `overflow-x-auto`, which turned every
108
+ long sentence into a horizontal scrollbar instead of
109
+ breaking onto the next line.
110
+ */}
111
+ <div className="bg-card w-full min-w-0 rounded border p-4 text-sm break-words whitespace-pre-wrap [overflow-wrap:anywhere] [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto">
94
112
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{chunk.content}</ReactMarkdown>
95
113
  </div>
96
114
  </TableCell>
@@ -7,6 +7,7 @@ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
7
7
  import { usePageUrlGenerator } from "../../../../../hooks";
8
8
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui/ui/table";
9
9
  import type { ChunkInterface, ChunkRelationshipMeta } from "../../../../chunk/data/ChunkInterface";
10
+ import { useEntityLabel } from "./useEntityLabel";
10
11
 
11
12
  interface Props {
12
13
  citations: (ChunkInterface & ChunkRelationshipMeta)[];
@@ -22,6 +23,7 @@ interface ContentRow {
22
23
 
23
24
  export function ContentsTab({ citations, sources }: Props) {
24
25
  const t = useTranslations();
26
+ const entityLabel = useEntityLabel();
25
27
  const generate = usePageUrlGenerator();
26
28
 
27
29
  // Group citations by nodeId, then materialise rows from the resolved sources
@@ -82,7 +84,7 @@ export function ContentsTab({ citations, sources }: Props) {
82
84
  </span>
83
85
  </Link>
84
86
  </TableCell>
85
- <TableCell className="text-muted-foreground text-xs">{module.name}</TableCell>
87
+ <TableCell className="text-muted-foreground text-xs">{entityLabel(module.name)}</TableCell>
86
88
  </TableRow>
87
89
  );
88
90
  })}
@@ -6,6 +6,7 @@ import type { ApiDataInterface } from "../../../../../core";
6
6
  import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
7
7
  import { usePageUrlGenerator } from "../../../../../hooks";
8
8
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui/ui/table";
9
+ import { useEntityLabel } from "./useEntityLabel";
9
10
 
10
11
  interface Props {
11
12
  references: ApiDataInterface[];
@@ -15,6 +16,8 @@ export function ReferencesTab({ references }: Props) {
15
16
  const t = useTranslations();
16
17
  const generate = usePageUrlGenerator();
17
18
 
19
+ const entityLabel = useEntityLabel();
20
+
18
21
  if (references.length === 0) return null;
19
22
 
20
23
  return (
@@ -43,7 +46,7 @@ export function ReferencesTab({ references }: Props) {
43
46
  {ref.identifier}
44
47
  </Link>
45
48
  </TableCell>
46
- <TableCell className="text-muted-foreground text-xs">{module.name}</TableCell>
49
+ <TableCell className="text-muted-foreground text-xs">{entityLabel(module.name)}</TableCell>
47
50
  </TableRow>
48
51
  );
49
52
  })}
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useCallback } from "react";
5
+
6
+ /**
7
+ * Turns a JSON:API type (`npcs`, `locations`) into UI copy.
8
+ *
9
+ * The source panels receive wire identifiers, not labels. Host apps translate
10
+ * entity names under `entities.<type>` with a plural count, so prefer that and
11
+ * fall back to the raw type when the app has no such key — the panel must
12
+ * still render for a type the app never localised.
13
+ */
14
+ export function useEntityLabel(): (type: string) => string {
15
+ const t = useTranslations();
16
+
17
+ return useCallback(
18
+ (type: string): string => {
19
+ const key = `entities.${type}`;
20
+ // `t.has` is the only non-throwing probe: a plain `t()` on a missing key
21
+ // either throws or returns the key path, depending on the host app's
22
+ // next-intl error handler, and the key path reads worse than the type.
23
+ if (typeof t.has === "function" && !t.has(key as never)) return type;
24
+ try {
25
+ // Cast through the untyped call signature: the key is computed, so the
26
+ // generated message-key union cannot describe it, and the `count`
27
+ // parameter is only accepted on keys the union knows about.
28
+ return (t as unknown as (k: string, values?: Record<string, unknown>) => string)(key, { count: 1 });
29
+ } catch {
30
+ return type;
31
+ }
32
+ },
33
+ [t],
34
+ );
35
+ }
@@ -125,12 +125,21 @@ export class AssistantMessage extends AbstractApiData implements AssistantMessag
125
125
  * the append-to-thread endpoint derives those server-side, so we only send `content` and
126
126
  * the optional retrieval-mode flags.
127
127
  */
128
- createAppendMessageJsonApi(params: { content: string; howToMode?: boolean; limitToHowToId?: string }) {
128
+ createAppendMessageJsonApi(params: {
129
+ content: string;
130
+ howToMode?: boolean;
131
+ limitToHowToId?: string;
132
+ contentBlocks?: unknown[];
133
+ }) {
129
134
  return {
130
135
  data: {
131
136
  type: Modules.AssistantMessage.name,
132
137
  attributes: {
133
- content: params.content,
138
+ // narr8 convention for BlockNote-backed fields: the document travels
139
+ // as a JSON string in the field's own string attribute (see
140
+ // Npc.description). The server detects the shape and derives the
141
+ // stored markdown from it.
142
+ content: params.contentBlocks !== undefined ? JSON.stringify(params.contentBlocks) : params.content,
134
143
  ...(params.howToMode !== undefined ? { howToMode: params.howToMode } : {}),
135
144
  ...(params.limitToHowToId !== undefined ? { limitToHowToId: params.limitToHowToId } : {}),
136
145
  },
@@ -14,6 +14,18 @@ interface TokenStatusIndicatorProps {
14
14
  showExtraPages?: boolean;
15
15
  }
16
16
 
17
+ /**
18
+ * Credit balances are consumed fractionally server-side, so a raw balance reads
19
+ * as noise in the UI ("999.48"). Every credit figure is shown as a whole number.
20
+ *
21
+ * Truncate rather than round: rounding 999.6 up to 1000 would advertise capacity
22
+ * the company does not have. Truncation is used instead of Math.floor because a
23
+ * balance can go negative (overdrawn extra credits), and floor(-1.09) is -2 —
24
+ * which overstates the debt. trunc keeps it at -1 and matches floor for
25
+ * non-negative values.
26
+ */
27
+ const asWholeCredits = (value: number): string => Math.trunc(value).toLocaleString();
28
+
17
29
  /**
18
30
  * TokenStatusIndicator displays the current status of available monthly and extra pages
19
31
  * using battery icons to represent the percentage of monthly pages remaining.
@@ -91,7 +103,7 @@ export function TokenStatusIndicator({ className, size = "md", showExtraPages =
91
103
  {t("billing.tokens.monthly", { defaultValue: "Monthly Pages" })}:
92
104
  </span>
93
105
  <span className={cn("font-medium", getStatusColor())}>
94
- {availableMonthlyCredits} / {monthlyCredits}
106
+ {asWholeCredits(availableMonthlyCredits)} / {asWholeCredits(monthlyCredits)}
95
107
  </span>
96
108
  </div>
97
109
  <div className="flex items-center justify-between gap-4">
@@ -108,7 +120,7 @@ export function TokenStatusIndicator({ className, size = "md", showExtraPages =
108
120
  <span className="text-muted-foreground">
109
121
  {t("billing.tokens.available_extra", { defaultValue: "Extra Pages" })}:
110
122
  </span>
111
- <span className="font-medium text-blue-500">{availableExtraCredits}</span>
123
+ <span className="font-medium text-blue-500">{asWholeCredits(availableExtraCredits)}</span>
112
124
  </div>
113
125
  <Link href="/settings/billing?action=subscribe" className="w-full flex justify-end my-4">
114
126
  <Button variant="outline" size="sm">
@@ -132,12 +144,14 @@ export function TokenStatusIndicator({ className, size = "md", showExtraPages =
132
144
  >
133
145
  {getBatteryIcon()}
134
146
  <span className={cn(textSize, "text-muted-foreground font-medium leading-none")}>
135
- {availableMonthlyCredits}
147
+ {asWholeCredits(availableMonthlyCredits)}
136
148
  </span>
137
149
  {showExtraPages && availableExtraCredits > 0 && (
138
150
  <span className="inline-flex items-center gap-0.5">
139
151
  <PlusCircle className={cn(smallIconSize, "text-blue-500")} />
140
- <span className={cn(textSize, "text-blue-500 font-medium leading-none")}>{availableExtraCredits}</span>
152
+ <span className={cn(textSize, "text-blue-500 font-medium leading-none")}>
153
+ {asWholeCredits(availableExtraCredits)}
154
+ </span>
141
155
  </span>
142
156
  )}
143
157
  </TooltipTrigger>
@@ -1,4 +1,5 @@
1
1
  // S3 feature exports
2
+ export * from "./assistant-action";
2
3
  export * from "./auth";
3
4
  export * from "./billing";
4
5
  export * from "./billing/stripe-customer";
@@ -1,16 +0,0 @@
1
- import { A as ApiDataInterface } from './ApiDataInterface-BcZeXy5X.mjs';
2
-
3
- type AssistantInput = {
4
- firstMessage: string;
5
- title?: string;
6
- howToMode?: boolean;
7
- limitToHowToId?: string;
8
- };
9
- interface AssistantInterface extends ApiDataInterface {
10
- get title(): string;
11
- get messageCount(): number;
12
- /** Agent engine the thread runs on — `"operator"` or undefined (responder). */
13
- get engine(): string | undefined;
14
- }
15
-
16
- export type { AssistantInput as A, AssistantInterface as a };