@carlonicora/nextjs-jsonapi 3.12.0 → 3.13.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 (143) hide show
  1. package/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
  2. package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
  3. package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
  4. package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
  5. package/dist/{BlockNoteEditor-6G27S6P3.js → BlockNoteEditor-CFVSHS46.js} +19 -19
  6. package/dist/{BlockNoteEditor-6G27S6P3.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
  7. package/dist/{BlockNoteEditor-CR5KH4AI.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
  8. package/dist/{BlockNoteViewer-5MITJ7AD.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
  9. package/dist/{BlockNoteViewer-5MITJ7AD.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
  10. package/dist/{BlockNoteViewer-NJKFUNXP.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
  11. package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
  12. package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
  13. package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
  14. package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
  15. package/dist/billing/index.js +401 -401
  16. package/dist/billing/index.mjs +3 -3
  17. package/dist/{chunk-L7DZSTFU.js → chunk-IB7DTXWG.js} +846 -129
  18. package/dist/chunk-IB7DTXWG.js.map +1 -0
  19. package/dist/{chunk-OKGGYCO5.js → chunk-LVE3BJ6K.js} +2088 -1134
  20. package/dist/chunk-LVE3BJ6K.js.map +1 -0
  21. package/dist/{chunk-Q2BREXH3.mjs → chunk-OSFYCSTW.mjs} +724 -7
  22. package/dist/chunk-OSFYCSTW.mjs.map +1 -0
  23. package/dist/{chunk-JEGL7KHK.mjs → chunk-UQXE3SME.mjs} +2081 -1127
  24. package/dist/chunk-UQXE3SME.mjs.map +1 -0
  25. package/dist/{chunk-HVNEWDRF.mjs → chunk-VZKJIAND.mjs} +2 -2
  26. package/dist/{chunk-4ETVCRQD.js → chunk-XH4IURUD.js} +7 -7
  27. package/dist/{chunk-4ETVCRQD.js.map → chunk-XH4IURUD.js.map} +1 -1
  28. package/dist/client/index.d.mts +2 -2
  29. package/dist/client/index.d.ts +2 -2
  30. package/dist/client/index.js +4 -4
  31. package/dist/client/index.mjs +3 -3
  32. package/dist/components/index.d.mts +258 -7
  33. package/dist/components/index.d.ts +258 -7
  34. package/dist/components/index.js +30 -4
  35. package/dist/components/index.js.map +1 -1
  36. package/dist/components/index.mjs +29 -3
  37. package/dist/contexts/index.d.mts +17 -5
  38. package/dist/contexts/index.d.ts +17 -5
  39. package/dist/contexts/index.js +4 -4
  40. package/dist/contexts/index.mjs +3 -3
  41. package/dist/core/index.d.mts +491 -11
  42. package/dist/core/index.d.ts +491 -11
  43. package/dist/core/index.js +38 -2
  44. package/dist/core/index.js.map +1 -1
  45. package/dist/core/index.mjs +37 -1
  46. package/dist/features/help/index.js +37 -37
  47. package/dist/features/help/index.mjs +3 -3
  48. package/dist/features/tokenusage/index.js +101 -101
  49. package/dist/features/tokenusage/index.mjs +3 -3
  50. package/dist/index.d.mts +7 -7
  51. package/dist/index.d.ts +7 -7
  52. package/dist/index.js +39 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.mjs +38 -2
  55. package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
  56. package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
  57. package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
  58. package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
  59. package/dist/server/index.d.mts +3 -3
  60. package/dist/server/index.d.ts +3 -3
  61. package/dist/server/index.js +3 -3
  62. package/dist/server/index.mjs +1 -1
  63. package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
  64. package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
  65. package/package.json +1 -1
  66. package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
  67. package/src/components/index.ts +1 -0
  68. package/src/core/index.ts +5 -0
  69. package/src/core/registry/ModuleRegistry.ts +4 -0
  70. package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
  71. package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
  72. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
  73. package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
  74. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
  75. package/src/features/assistant/data/Assistant.ts +2 -0
  76. package/src/features/assistant/data/AssistantInterface.ts +2 -0
  77. package/src/features/assistant/data/AssistantService.ts +4 -0
  78. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
  79. package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
  80. package/src/features/content/data/content.interface.ts +1 -1
  81. package/src/features/handbook/HandbookPageModule.ts +41 -0
  82. package/src/features/handbook/HandbookSectionModule.ts +35 -0
  83. package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
  84. package/src/features/handbook/HandbookThreadModule.ts +29 -0
  85. package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
  86. package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
  87. package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
  88. package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
  89. package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
  90. package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
  91. package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
  92. package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
  93. package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
  94. package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
  95. package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
  96. package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
  97. package/src/features/handbook/components/index.ts +12 -0
  98. package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
  99. package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
  100. package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
  101. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
  102. package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
  103. package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
  104. package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
  105. package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
  106. package/src/features/handbook/data/HandbookPage.ts +99 -0
  107. package/src/features/handbook/data/HandbookPageFields.ts +31 -0
  108. package/src/features/handbook/data/HandbookPageInput.ts +8 -0
  109. package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
  110. package/src/features/handbook/data/HandbookPageService.ts +60 -0
  111. package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
  112. package/src/features/handbook/data/HandbookSection.ts +73 -0
  113. package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
  114. package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
  115. package/src/features/handbook/data/HandbookSectionService.ts +26 -0
  116. package/src/features/handbook/data/HandbookThread.ts +114 -0
  117. package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
  118. package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
  119. package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
  120. package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
  121. package/src/features/handbook/data/HandbookThreadService.ts +86 -0
  122. package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
  123. package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
  124. package/src/features/handbook/data/handbookDisplay.ts +55 -0
  125. package/src/features/handbook/data/index.ts +16 -0
  126. package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
  127. package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
  128. package/src/features/handbook/i18n-keys.ts +46 -0
  129. package/src/features/handbook/index.ts +7 -0
  130. package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
  131. package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
  132. package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
  133. package/src/features/how-to/components/index.ts +1 -0
  134. package/src/features/how-to/components/lists/HowToList.tsx +5 -1
  135. package/src/features/how-to/data/HowTo.spec.ts +61 -1
  136. package/src/features/how-to/data/HowToService.ts +13 -0
  137. package/dist/chunk-JEGL7KHK.mjs.map +0 -1
  138. package/dist/chunk-L7DZSTFU.js.map +0 -1
  139. package/dist/chunk-OKGGYCO5.js.map +0 -1
  140. package/dist/chunk-Q2BREXH3.mjs.map +0 -1
  141. /package/dist/{BlockNoteEditor-CR5KH4AI.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
  142. /package/dist/{BlockNoteViewer-NJKFUNXP.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
  143. /package/dist/{chunk-HVNEWDRF.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
@@ -0,0 +1,55 @@
1
+ import { HandbookPageInterface } from "./HandbookPageInterface";
2
+ import { HandbookSectionInterface } from "./HandbookSectionInterface";
3
+
4
+ /**
5
+ * The handbook is INDEXED in English and SHOWN translated.
6
+ *
7
+ * A page's translation lives on the same node as its English original — matched
8
+ * by `path` at ingest time — and arrives as ordinary attributes beside it.
9
+ * There is no second id, no locale in any URL and no second resource, so
10
+ * nothing about ordering, grouping or section membership changes: `path`,
11
+ * `order` and `section` stay English and are still what every sort reads. Only
12
+ * the rendered words are resolved here.
13
+ *
14
+ * These five functions are the ONE place that resolution happens. A component
15
+ * reaching for `page.title` directly renders English to an Italian reader, and
16
+ * a per-component `??` would be five copies of the same rule to keep in step.
17
+ *
18
+ * Each parameter is a `Pick` of the interface rather than the whole of it, so a
19
+ * caller holding a projection — a row from a sparse fieldset, a spec fixture —
20
+ * passes it without a cast, while a rename on either interface still breaks
21
+ * this file rather than drifting from it.
22
+ */
23
+
24
+ /** The page title to render: the translation when there is one, else English. */
25
+ export function handbookPageTitle(page: Pick<HandbookPageInterface, "title" | "displayTitle">): string {
26
+ return page.displayTitle ?? page.title;
27
+ }
28
+
29
+ /** The page's one-line description, translated when the ingest matched one. */
30
+ export function handbookPageSummary(
31
+ page: Pick<HandbookPageInterface, "summary" | "displaySummary">,
32
+ ): string | undefined {
33
+ return page.displaySummary ?? page.summary;
34
+ }
35
+
36
+ /**
37
+ * The markdown body to render. Detail-only, exactly as `content` is:
38
+ * `displayContent` is the whole file and is absent from every list payload, so
39
+ * this is never called on a row that came out of a list read.
40
+ */
41
+ export function handbookPageContent(page: Pick<HandbookPageInterface, "content" | "displayContent">): string {
42
+ return page.displayContent ?? page.content;
43
+ }
44
+
45
+ /** The section heading to render, translated when the ingest matched one. */
46
+ export function handbookSectionTitle(section: Pick<HandbookSectionInterface, "title" | "displayTitle">): string {
47
+ return section.displayTitle ?? section.title;
48
+ }
49
+
50
+ /** The section blurb to render, translated when the ingest matched one. */
51
+ export function handbookSectionSummary(
52
+ section: Pick<HandbookSectionInterface, "summary" | "displaySummary">,
53
+ ): string | undefined {
54
+ return section.displaySummary ?? section.summary;
55
+ }
@@ -0,0 +1,16 @@
1
+ export * from "./handbookDisplay";
2
+ export * from "./HandbookPage";
3
+ export * from "./HandbookPageFields";
4
+ export * from "./HandbookPageInput";
5
+ export * from "./HandbookPageInterface";
6
+ export * from "./HandbookPageService";
7
+ export * from "./HandbookSection";
8
+ export * from "./HandbookSectionInput";
9
+ export * from "./HandbookSectionInterface";
10
+ export * from "./HandbookSectionService";
11
+ export * from "./HandbookThread";
12
+ export * from "./HandbookThreadInterface";
13
+ export * from "./HandbookThreadMessage";
14
+ export * from "./HandbookThreadMessageInterface";
15
+ export * from "./HandbookThreadMessageService";
16
+ export * from "./HandbookThreadService";
@@ -0,0 +1,198 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
5
+ import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
6
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
7
+ import { HandbookThread } from "../../data/HandbookThread";
8
+ import { HandbookThreadMessage } from "../../data/HandbookThreadMessage";
9
+ import { HandbookThreadMessageService } from "../../data/HandbookThreadMessageService";
10
+ import { HandbookThreadService } from "../../data/HandbookThreadService";
11
+ import { useHandbookAsk } from "../useHandbookAsk";
12
+
13
+ vi.mock("../../data/HandbookThreadService", () => ({
14
+ HandbookThreadService: {
15
+ findMany: vi.fn(),
16
+ findOne: vi.fn(),
17
+ create: vi.fn(),
18
+ rename: vi.fn(),
19
+ delete: vi.fn(),
20
+ },
21
+ }));
22
+
23
+ vi.mock("../../data/HandbookThreadMessageService", () => ({
24
+ HandbookThreadMessageService: { ask: vi.fn() },
25
+ }));
26
+
27
+ // The models resolve Modules.HandbookThread / Modules.HandbookThreadMessage
28
+ // lazily through ModuleRegistry, and `_readIncluded` resolves the CONSTRUCTOR
29
+ // through DataClassRegistry. Registering both is the package's spec convention
30
+ // (see HandbookAskContainer.spec.tsx) and is preferred to mocking the core
31
+ // barrel the hook also reads `rehydrate` from.
32
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
33
+ try {
34
+ ModuleRegistry.get(key as any);
35
+ } catch {
36
+ ModuleRegistry.register(key, module);
37
+ }
38
+ };
39
+
40
+ beforeAll(() => {
41
+ registerIfAbsent("HandbookPage", { name: "handbookpages", pageUrl: "/administration/handbook" } as any);
42
+ const threadModule = { name: "handbookthreads", model: HandbookThread } as any;
43
+ const messageModule = { name: "handbookthreadmessages", model: HandbookThreadMessage } as any;
44
+ registerIfAbsent("HandbookThread", threadModule);
45
+ registerIfAbsent("HandbookThreadMessage", messageModule);
46
+ DataClassRegistry.registerObjectClass(threadModule, HandbookThread as any);
47
+ DataClassRegistry.registerObjectClass(messageModule, HandbookThreadMessage as any);
48
+ });
49
+
50
+ function buildThread(params: {
51
+ id: string;
52
+ title: string;
53
+ messages?: { id: string; role: "user" | "assistant"; content: string; position: number; sources?: string[] }[];
54
+ }): HandbookThread {
55
+ const messages = params.messages ?? [];
56
+ return new HandbookThread().rehydrate({
57
+ jsonApi: {
58
+ id: params.id,
59
+ type: "handbookthreads",
60
+ attributes: { title: params.title },
61
+ meta: { updatedAt: new Date().toISOString() },
62
+ relationships: {
63
+ messages: { data: messages.map((m) => ({ id: m.id, type: "handbookthreadmessages" })) },
64
+ },
65
+ },
66
+ included: messages.map((m) => ({
67
+ id: m.id,
68
+ type: "handbookthreadmessages",
69
+ attributes: { role: m.role, content: m.content, position: m.position, sources: m.sources ?? [] },
70
+ })),
71
+ } as any);
72
+ }
73
+
74
+ const answered = (id: string) =>
75
+ buildThread({
76
+ id,
77
+ title: "Filtri di azienda",
78
+ messages: [
79
+ { id: "m1", role: "user", content: "Perché?", position: 0 },
80
+ { id: "m2", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 },
81
+ ],
82
+ });
83
+
84
+ describe("useHandbookAsk", () => {
85
+ beforeEach(() => {
86
+ vi.mocked(HandbookThreadService.findMany).mockReset().mockResolvedValue([]);
87
+ vi.mocked(HandbookThreadService.findOne).mockReset().mockResolvedValue(answered("t1"));
88
+ vi.mocked(HandbookThreadService.create)
89
+ .mockReset()
90
+ .mockResolvedValue(buildThread({ id: "t1", title: "" }));
91
+ vi.mocked(HandbookThreadMessageService.ask).mockReset().mockResolvedValue(undefined);
92
+ });
93
+
94
+ it("adds an optimistic user message then replaces the transcript from the read-back", async () => {
95
+ const { result } = renderHook(() => useHandbookAsk());
96
+
97
+ await act(async () => {
98
+ await result.current.ask("How do migrations run?");
99
+ });
100
+
101
+ await waitFor(() => expect(result.current.messages).toHaveLength(2));
102
+ expect(HandbookThreadService.findOne).toHaveBeenCalledWith({ id: "t1" });
103
+ expect(result.current.thread?.id).toBe("t1");
104
+ });
105
+
106
+ it("passes handbookPageId to the create call when one is supplied", async () => {
107
+ const { result } = renderHook(() => useHandbookAsk({ syncUrl: false }));
108
+
109
+ await act(async () => {
110
+ await result.current.ask("Why?", { handbookPageId: "p2" });
111
+ });
112
+
113
+ expect(HandbookThreadService.create).toHaveBeenCalledWith(expect.objectContaining({ handbookPageId: "p2" }));
114
+ });
115
+
116
+ it("passes handbookPageId to the append call on an open thread", async () => {
117
+ const { result } = renderHook(() => useHandbookAsk({ syncUrl: false, handbookThreadId: "t1" }));
118
+ await waitFor(() => expect(result.current.thread?.id).toBe("t1"));
119
+
120
+ await act(async () => {
121
+ await result.current.ask("Why?", { handbookPageId: "p2" });
122
+ });
123
+
124
+ expect(HandbookThreadMessageService.ask).toHaveBeenCalledWith(
125
+ expect.objectContaining({ threadId: "t1", handbookPageId: "p2" }),
126
+ );
127
+ expect(HandbookThreadService.create).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("keeps the url on the open thread by default", async () => {
131
+ const replaceState = vi.spyOn(window.history, "replaceState");
132
+ const { result } = renderHook(() => useHandbookAsk());
133
+
134
+ await act(async () => {
135
+ await result.current.ask("Why?");
136
+ });
137
+
138
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/administration/handbook/chat/t1");
139
+ replaceState.mockRestore();
140
+ });
141
+
142
+ it("does not touch the URL when syncUrl is false", async () => {
143
+ const replaceState = vi.spyOn(window.history, "replaceState");
144
+ const { result } = renderHook(() => useHandbookAsk({ syncUrl: false }));
145
+
146
+ await act(async () => {
147
+ await result.current.ask("Why?");
148
+ });
149
+
150
+ expect(replaceState).not.toHaveBeenCalled();
151
+ replaceState.mockRestore();
152
+ });
153
+
154
+ it("appends one assistant-side failure message when the API rejects", async () => {
155
+ vi.mocked(HandbookThreadService.create).mockRejectedValueOnce(new Error("400"));
156
+ const { result } = renderHook(() => useHandbookAsk());
157
+
158
+ await act(async () => {
159
+ await result.current.ask("Why?");
160
+ });
161
+
162
+ await waitFor(() => expect(result.current.messages).toHaveLength(2));
163
+ expect(result.current.messages.at(-1)?.role).toBe("assistant");
164
+ expect(result.current.messages.at(-1)?.content).toBe("handbook.chat.failed");
165
+ // The asked question stays in the transcript.
166
+ expect(result.current.messages[0].content).toBe("Why?");
167
+ });
168
+
169
+ it("opens the routed thread on mount without rewriting the url", async () => {
170
+ const replaceState = vi.spyOn(window.history, "replaceState");
171
+
172
+ const { result } = renderHook(() => useHandbookAsk({ handbookThreadId: "t1" }));
173
+
174
+ await waitFor(() => expect(result.current.messages).toHaveLength(2));
175
+ expect(HandbookThreadService.findOne).toHaveBeenCalledWith({ id: "t1" });
176
+ expect(replaceState).not.toHaveBeenCalled();
177
+ replaceState.mockRestore();
178
+ });
179
+
180
+ it("loads the thread list and reloads the transcript on select", async () => {
181
+ vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
182
+ buildThread({ id: "t1", title: "Filtri di azienda" }),
183
+ ]);
184
+ const { result } = renderHook(() => useHandbookAsk());
185
+
186
+ await waitFor(() => expect(result.current.threads).toHaveLength(1));
187
+
188
+ await act(async () => {
189
+ await result.current.selectThread("t1");
190
+ });
191
+
192
+ expect(result.current.messages).toHaveLength(2);
193
+
194
+ act(() => result.current.startNew());
195
+ expect(result.current.thread).toBeUndefined();
196
+ expect(result.current.messages).toHaveLength(0);
197
+ });
198
+ });
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { v4 } from "uuid";
6
+
7
+ import { Modules, rehydrate } from "../../../core";
8
+ import { usePageUrlGenerator } from "../../../hooks";
9
+ import { HandbookThreadMessage } from "../data/HandbookThreadMessage";
10
+ import type { HandbookThreadInterface } from "../data/HandbookThreadInterface";
11
+ import type { HandbookThreadMessageInterface } from "../data/HandbookThreadMessageInterface";
12
+ import { HandbookThreadMessageService } from "../data/HandbookThreadMessageService";
13
+ import { HandbookThreadService } from "../data/HandbookThreadService";
14
+
15
+ /**
16
+ * The state of one handbook conversation, extracted verbatim from
17
+ * `HandbookAskContainer`.
18
+ *
19
+ * Two surfaces now hold a conversation — the full `/chat` route and the side
20
+ * sheet reachable from contents and reader — and they must behave identically:
21
+ * same optimistic user message, same read-back through
22
+ * `HandbookThreadService.findOne`, same error branch. Duplicating that in two
23
+ * components is how the two drift, so it lives here and both consume it.
24
+ *
25
+ * The one difference is the address bar. The route keeps the url on the open
26
+ * thread; opening a sheet is not a navigation, so the sheet passes
27
+ * `syncUrl: false` and nothing is written to history.
28
+ */
29
+
30
+ /**
31
+ * Sources ride in the markdown rather than in a bespoke block: the message
32
+ * renderer already renders markdown, and a list of file paths is exactly that.
33
+ *
34
+ * The message is rebuilt through `dehydrate()` + `rehydrate()` rather than a
35
+ * fresh `buildLocal`, so it keeps its id and React does not remount the bubble
36
+ * on every render. Mirrors `withPatchedTitle` in `AssistantContext`.
37
+ */
38
+ function withSources(messages: HandbookThreadMessageInterface[], label: string): HandbookThreadMessageInterface[] {
39
+ return messages.map((message) => withSourcesInContent(message, label));
40
+ }
41
+
42
+ function withSourcesInContent(message: HandbookThreadMessageInterface, label: string): HandbookThreadMessageInterface {
43
+ if (message.role !== "assistant" || message.sources.length === 0) return message;
44
+
45
+ const dehydrated = message.dehydrate();
46
+ const content = `${message.content}\n\n**${label}**\n\n${message.sources.map((source) => `- \`${source}\``).join("\n")}`;
47
+
48
+ return rehydrate<HandbookThreadMessageInterface>(Modules.HandbookThreadMessage, {
49
+ jsonApi: {
50
+ ...dehydrated.jsonApi,
51
+ attributes: { ...(dehydrated.jsonApi?.attributes ?? {}), content },
52
+ },
53
+ included: dehydrated.included,
54
+ });
55
+ }
56
+
57
+ export type UseHandbookAskParams = {
58
+ /**
59
+ * Keep the address bar on the open thread. Defaults to `true`, the route's
60
+ * behaviour; the sheet passes `false`.
61
+ */
62
+ syncUrl?: boolean;
63
+ /** Thread to open on mount, when the route carries one. */
64
+ handbookThreadId?: string;
65
+ };
66
+
67
+ export type UseHandbookAskResult = {
68
+ threads: HandbookThreadInterface[];
69
+ thread: HandbookThreadInterface | undefined;
70
+ messages: HandbookThreadMessageInterface[];
71
+ sending: boolean;
72
+ ask: (question: string, options?: { handbookPageId?: string }) => Promise<void>;
73
+ selectThread: (id: string) => Promise<void>;
74
+ startNew: () => void;
75
+ };
76
+
77
+ export function useHandbookAsk(params?: UseHandbookAskParams): UseHandbookAskResult {
78
+ const t = useTranslations();
79
+ const generateUrl = usePageUrlGenerator();
80
+
81
+ const shouldSyncUrl = params?.syncUrl ?? true;
82
+ const handbookThreadId = params?.handbookThreadId;
83
+
84
+ const [threads, setThreads] = useState<HandbookThreadInterface[]>([]);
85
+ const [thread, setThread] = useState<HandbookThreadInterface | undefined>(undefined);
86
+ const [messages, setMessages] = useState<HandbookThreadMessageInterface[]>([]);
87
+ const [sending, setSending] = useState(false);
88
+
89
+ const sourcesLabel = t("handbook.chat.sources");
90
+
91
+ useEffect(() => {
92
+ let cancelled = false;
93
+ (async () => {
94
+ const loaded = await HandbookThreadService.findMany();
95
+ if (!cancelled) setThreads(loaded);
96
+ })();
97
+ return () => {
98
+ cancelled = true;
99
+ };
100
+ }, []);
101
+
102
+ // A routed thread id opens that thread on mount, so the url survives a reload
103
+ // or a pasted link. Runs once per id; selecting from the list takes the other
104
+ // path and only replaces the url.
105
+ useEffect(() => {
106
+ if (!handbookThreadId) return;
107
+ let cancelled = false;
108
+ (async () => {
109
+ const selected = await HandbookThreadService.findOne({ id: handbookThreadId });
110
+ if (cancelled) return;
111
+ setThread(selected);
112
+ setMessages(withSources(selected.messages, sourcesLabel));
113
+ })();
114
+ return () => {
115
+ cancelled = true;
116
+ };
117
+ }, [handbookThreadId]);
118
+
119
+ /**
120
+ * Keep the address bar on the open thread, the way ConversationListItem does
121
+ * (`window.history.replaceState` with the generated url) — selecting a thread
122
+ * is not a navigation, so this replaces rather than pushes.
123
+ *
124
+ * A no-op when the caller asked for no url sync: the sheet floats over a page
125
+ * that is itself the navigation, and rewriting the url under it would send a
126
+ * reader who closes the sheet somewhere they never went.
127
+ */
128
+ const syncUrl = useCallback(
129
+ (id?: string) => {
130
+ if (!shouldSyncUrl) return;
131
+ const base = `${generateUrl({ page: Modules.HandbookPage })}/chat`;
132
+ window.history.replaceState(null, "", id ? `${base}/${id}` : base);
133
+ },
134
+ [generateUrl, shouldSyncUrl],
135
+ );
136
+
137
+ const selectThread = useCallback(
138
+ async (id: string) => {
139
+ const selected = await HandbookThreadService.findOne({ id });
140
+ setThread(selected);
141
+ setMessages(withSources(selected.messages, sourcesLabel));
142
+ syncUrl(id);
143
+ },
144
+ [syncUrl],
145
+ );
146
+
147
+ const startNew = useCallback(() => {
148
+ setThread(undefined);
149
+ setMessages([]);
150
+ syncUrl(undefined);
151
+ }, [syncUrl]);
152
+
153
+ const ask = useCallback(
154
+ async (question: string, options?: { handbookPageId?: string }) => {
155
+ const trimmed = question.trim();
156
+ if (!trimmed || sending) return;
157
+
158
+ const position = messages.length;
159
+ setMessages((current) => [
160
+ ...current,
161
+ HandbookThreadMessage.buildLocal({ role: "user", content: trimmed, position, isOptimistic: true }),
162
+ ]);
163
+ setSending(true);
164
+
165
+ try {
166
+ // Both branches end in the same read: only the single-thread endpoint
167
+ // side-loads the messages, so the transcript always comes from there.
168
+ let id = thread?.id;
169
+ if (id === undefined) {
170
+ const created = await HandbookThreadService.create({
171
+ id: v4(),
172
+ question: trimmed,
173
+ handbookPageId: options?.handbookPageId,
174
+ });
175
+ id = created.id;
176
+ setThreads((current) => [created, ...current]);
177
+ } else {
178
+ await HandbookThreadMessageService.ask({
179
+ id: v4(),
180
+ threadId: id,
181
+ question: trimmed,
182
+ handbookPageId: options?.handbookPageId,
183
+ });
184
+ }
185
+
186
+ const loaded = await HandbookThreadService.findOne({ id });
187
+ setThread(loaded);
188
+ setMessages(withSources(loaded.messages, sourcesLabel));
189
+ // The server derives the title from the first question, so the list row
190
+ // created a moment ago has to pick it up.
191
+ setThreads((current) => current.map((item) => (item.id === loaded.id ? loaded : item)));
192
+ syncUrl(loaded.id);
193
+ } catch {
194
+ // 400 when the installation has no usable AI configuration, 403 for a
195
+ // non-administrator. Neither is actionable from here, so both surface
196
+ // as one assistant-side message rather than a bespoke error pane.
197
+ setMessages((current) => [
198
+ ...current,
199
+ HandbookThreadMessage.buildLocal({
200
+ role: "assistant",
201
+ content: t("handbook.chat.failed"),
202
+ position: position + 1,
203
+ }),
204
+ ]);
205
+ } finally {
206
+ setSending(false);
207
+ }
208
+ },
209
+ [messages.length, sending, syncUrl, t, thread?.id],
210
+ );
211
+
212
+ return { threads, thread, messages, sending, ask, selectThread, startNew };
213
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The fixed namespace of i18n keys the handbook feature reads via
3
+ * useTranslations. Consuming apps must define each entry in their
4
+ * messages/<locale>.json — this list is the contract between the package and
5
+ * the app, and the names are frozen: an app that wants different wording
6
+ * changes its own translation, never the key.
7
+ *
8
+ * Mirrors the contract shipped by the administration feature
9
+ * (features/administration/i18n-keys.ts).
10
+ */
11
+ export const handbookI18nKeys = [
12
+ "handbook.title",
13
+ "handbook.subtitle",
14
+ "handbook.sync",
15
+ "handbook.syncing",
16
+ "handbook.synced",
17
+ "handbook.syncFailed",
18
+ "handbook.empty",
19
+ "handbook.notConfigured",
20
+ "handbook.chat.title",
21
+ "handbook.chat.placeholder",
22
+ "handbook.chat.ask",
23
+ "handbook.chat.pending",
24
+ "handbook.chat.failed",
25
+ "handbook.chat.sources",
26
+ "handbook.chat.list_title",
27
+ "handbook.chat.new",
28
+ "handbook.chat.empty_sidebar",
29
+ "handbook.chat.rename_placeholder",
30
+ "handbook.chat.delete_confirm",
31
+ "handbook.chat.empty_state.title",
32
+ "handbook.chat.empty_state.subtitle",
33
+ "handbook.contents.title",
34
+ "handbook.contents.status",
35
+ "handbook.contents.failures",
36
+ "handbook.reader.contents",
37
+ "handbook.reader.onThisPage",
38
+ "handbook.reader.previous",
39
+ "handbook.reader.next",
40
+ "handbook.ask.open",
41
+ "handbook.ask.recent",
42
+ "handbook.ask.all",
43
+ "handbook.ask.expand",
44
+ "handbook.ask.thisPageOnly",
45
+ "handbook.sections.untitled",
46
+ ] as const;
@@ -0,0 +1,7 @@
1
+ export * from "./HandbookPageModule";
2
+ export * from "./HandbookSectionModule";
3
+ export * from "./HandbookThreadMessageModule";
4
+ export * from "./HandbookThreadModule";
5
+ export * from "./components";
6
+ export * from "./contexts/HandbookContext";
7
+ export * from "./data";
@@ -239,7 +239,6 @@ function HowToEditorInternal({
239
239
  const payload: HowToInput = {
240
240
  id: values.id,
241
241
  name: values.name,
242
- authorId: "",
243
242
  description: values.description,
244
243
  pages: HowTo.serializePagesToString(values.pages),
245
244
  howToType: values.howToType,
@@ -0,0 +1,84 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { showError, showToast } from "../../../../utils/toast";
6
+ import { HowToService } from "../../data/HowToService";
7
+ import HowToReindexButton from "./HowToReindexButton";
8
+
9
+ vi.mock("../../data/HowToService", () => ({
10
+ HowToService: { reindex: vi.fn() },
11
+ }));
12
+
13
+ vi.mock("../../../../utils/toast", () => ({
14
+ showToast: vi.fn(),
15
+ showError: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("next-intl", () => ({
19
+ useTranslations: () => (key: string) => key,
20
+ }));
21
+
22
+ describe("HowToReindexButton", () => {
23
+ beforeEach(() => {
24
+ vi.mocked(HowToService.reindex).mockReset().mockResolvedValue(undefined);
25
+ vi.mocked(showToast).mockReset();
26
+ vi.mocked(showError).mockReset();
27
+ });
28
+
29
+ it("renders the action label", () => {
30
+ render(<HowToReindexButton refresh={vi.fn().mockResolvedValue(undefined)} />);
31
+
32
+ expect(screen.getByRole("button")).toHaveTextContent("howto.reindex.action");
33
+ });
34
+
35
+ it("reindexes, then refreshes the list", async () => {
36
+ const refresh = vi.fn().mockResolvedValue(undefined);
37
+ render(<HowToReindexButton refresh={refresh} />);
38
+
39
+ await userEvent.click(screen.getByRole("button"));
40
+
41
+ await waitFor(() => expect(HowToService.reindex).toHaveBeenCalledTimes(1));
42
+ await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
43
+ });
44
+
45
+ it("shows the running state while the reindex is in flight", async () => {
46
+ let resolveReindex: () => void = () => {};
47
+ vi.mocked(HowToService.reindex).mockReturnValue(
48
+ new Promise<void>((resolve) => {
49
+ resolveReindex = resolve;
50
+ }),
51
+ );
52
+ render(<HowToReindexButton refresh={vi.fn().mockResolvedValue(undefined)} />);
53
+
54
+ await userEvent.click(screen.getByRole("button"));
55
+
56
+ await waitFor(() => expect(screen.getByRole("button")).toHaveTextContent("howto.reindex.running"));
57
+ expect(screen.getByRole("button")).toBeDisabled();
58
+
59
+ resolveReindex();
60
+ await waitFor(() => expect(screen.getByRole("button")).toHaveTextContent("howto.reindex.action"));
61
+ });
62
+
63
+ it("shows the done state once the reindex succeeds", async () => {
64
+ render(<HowToReindexButton refresh={vi.fn().mockResolvedValue(undefined)} />);
65
+
66
+ await userEvent.click(screen.getByRole("button"));
67
+
68
+ await waitFor(() => expect(showToast).toHaveBeenCalledWith("howto.reindex.done"));
69
+ expect(showError).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("shows the failed state when the reindex rejects, and re-enables itself", async () => {
73
+ vi.mocked(HowToService.reindex).mockRejectedValue(new Error("boom"));
74
+ const refresh = vi.fn().mockResolvedValue(undefined);
75
+ render(<HowToReindexButton refresh={refresh} />);
76
+
77
+ await userEvent.click(screen.getByRole("button"));
78
+
79
+ await waitFor(() => expect(showError).toHaveBeenCalledWith("howto.reindex.failed"));
80
+ expect(showToast).not.toHaveBeenCalled();
81
+ expect(refresh).not.toHaveBeenCalled();
82
+ await waitFor(() => expect(screen.getByRole("button")).not.toBeDisabled());
83
+ });
84
+ });
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useState } from "react";
5
+
6
+ import { Button } from "../../../../shadcnui";
7
+ import { showError, showToast } from "../../../../utils/toast";
8
+ import { HowToService } from "../../data/HowToService";
9
+
10
+ type HowToReindexButtonProps = {
11
+ refresh: () => Promise<void>;
12
+ };
13
+
14
+ /**
15
+ * Re-queues every guide for chunking.
16
+ *
17
+ * Guides are chunked on create and update only, so one written before chunking
18
+ * existed — or one whose chunks were dropped — is invisible to the help chat
19
+ * with no way back in. This is the way back in.
20
+ *
21
+ * The control is a plain Button, not a trigger: it opens nothing, so the
22
+ * render-prop composition rule for Base UI triggers does not apply here.
23
+ */
24
+ export default function HowToReindexButton({ refresh }: HowToReindexButtonProps) {
25
+ const t = useTranslations();
26
+ const [isReindexing, setIsReindexing] = useState(false);
27
+
28
+ const onReindex = async () => {
29
+ setIsReindexing(true);
30
+ try {
31
+ await HowToService.reindex();
32
+ await refresh();
33
+ showToast(t(`howto.reindex.done`));
34
+ } catch {
35
+ showError(t(`howto.reindex.failed`));
36
+ } finally {
37
+ setIsReindexing(false);
38
+ }
39
+ };
40
+
41
+ return (
42
+ <Button variant="outline" onClick={onReindex} disabled={isReindexing}>
43
+ {isReindexing ? t(`howto.reindex.running`) : t(`howto.reindex.action`)}
44
+ </Button>
45
+ );
46
+ }