@carlonicora/nextjs-jsonapi 3.12.0 → 3.14.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.
- package/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
- package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
- package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
- package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
- package/dist/{BlockNoteEditor-CR5KH4AI.mjs → BlockNoteEditor-QG5OWH5K.mjs} +4 -4
- package/dist/{BlockNoteEditor-6G27S6P3.js → BlockNoteEditor-WP4K423H.js} +19 -19
- package/dist/{BlockNoteEditor-6G27S6P3.js.map → BlockNoteEditor-WP4K423H.js.map} +1 -1
- package/dist/{BlockNoteViewer-5MITJ7AD.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
- package/dist/{BlockNoteViewer-5MITJ7AD.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
- package/dist/{BlockNoteViewer-NJKFUNXP.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
- package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
- package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
- package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
- package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
- package/dist/billing/index.js +401 -401
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-JEGL7KHK.mjs → chunk-46BRB36R.mjs} +2847 -1803
- package/dist/chunk-46BRB36R.mjs.map +1 -0
- package/dist/{chunk-L7DZSTFU.js → chunk-IB7DTXWG.js} +846 -129
- package/dist/chunk-IB7DTXWG.js.map +1 -0
- package/dist/{chunk-OKGGYCO5.js → chunk-OH6GJY4B.js} +2162 -1118
- package/dist/chunk-OH6GJY4B.js.map +1 -0
- package/dist/{chunk-Q2BREXH3.mjs → chunk-OSFYCSTW.mjs} +724 -7
- package/dist/chunk-OSFYCSTW.mjs.map +1 -0
- package/dist/{chunk-HVNEWDRF.mjs → chunk-VZKJIAND.mjs} +2 -2
- package/dist/{chunk-4ETVCRQD.js → chunk-XH4IURUD.js} +7 -7
- package/dist/{chunk-4ETVCRQD.js.map → chunk-XH4IURUD.js.map} +1 -1
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +270 -7
- package/dist/components/index.d.ts +270 -7
- package/dist/components/index.js +32 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +31 -3
- package/dist/contexts/index.d.mts +17 -5
- package/dist/contexts/index.d.ts +17 -5
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +491 -11
- package/dist/core/index.d.ts +491 -11
- package/dist/core/index.js +38 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +37 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +101 -101
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.d.mts +7 -7
- package/dist/index.d.ts +7 -7
- package/dist/index.js +39 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +38 -2
- package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
- package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
- package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
- package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
- package/dist/server/index.d.mts +3 -3
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
- package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
- package/package.json +2 -1
- package/src/components/containers/MermaidDiagram.tsx +117 -0
- package/src/components/containers/ReactMarkdownContainer.tsx +98 -15
- package/src/components/containers/index.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/core/index.ts +5 -0
- package/src/core/registry/ModuleRegistry.ts +4 -0
- package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
- package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
- package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
- package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
- package/src/features/assistant/data/Assistant.ts +2 -0
- package/src/features/assistant/data/AssistantInterface.ts +2 -0
- package/src/features/assistant/data/AssistantService.ts +4 -0
- package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
- package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
- package/src/features/content/data/content.interface.ts +1 -1
- package/src/features/handbook/HandbookPageModule.ts +41 -0
- package/src/features/handbook/HandbookSectionModule.ts +35 -0
- package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
- package/src/features/handbook/HandbookThreadModule.ts +29 -0
- package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
- package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
- package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
- package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
- package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
- package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
- package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
- package/src/features/handbook/components/details/HandbookPageDetails.tsx +52 -0
- package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
- package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
- package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
- package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
- package/src/features/handbook/components/index.ts +12 -0
- package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
- package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
- package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
- package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
- package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
- package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
- package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
- package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
- package/src/features/handbook/data/HandbookPage.ts +99 -0
- package/src/features/handbook/data/HandbookPageFields.ts +31 -0
- package/src/features/handbook/data/HandbookPageInput.ts +8 -0
- package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
- package/src/features/handbook/data/HandbookPageService.ts +60 -0
- package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
- package/src/features/handbook/data/HandbookSection.ts +73 -0
- package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
- package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
- package/src/features/handbook/data/HandbookSectionService.ts +26 -0
- package/src/features/handbook/data/HandbookThread.ts +114 -0
- package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
- package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
- package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
- package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
- package/src/features/handbook/data/HandbookThreadService.ts +86 -0
- package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
- package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
- package/src/features/handbook/data/handbookDisplay.ts +55 -0
- package/src/features/handbook/data/index.ts +16 -0
- package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
- package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
- package/src/features/handbook/i18n-keys.ts +46 -0
- package/src/features/handbook/index.ts +7 -0
- package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
- package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
- package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
- package/src/features/how-to/components/index.ts +1 -0
- package/src/features/how-to/components/lists/HowToList.tsx +5 -1
- package/src/features/how-to/data/HowTo.spec.ts +61 -1
- package/src/features/how-to/data/HowToService.ts +13 -0
- package/dist/chunk-JEGL7KHK.mjs.map +0 -1
- package/dist/chunk-L7DZSTFU.js.map +0 -1
- package/dist/chunk-OKGGYCO5.js.map +0 -1
- package/dist/chunk-Q2BREXH3.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-CR5KH4AI.mjs.map → BlockNoteEditor-QG5OWH5K.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-NJKFUNXP.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
- /package/dist/{chunk-HVNEWDRF.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
|
@@ -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";
|
|
@@ -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
|
+
}
|
|
@@ -7,5 +7,6 @@ export { default as HowToDetails } from "./details/HowToDetails";
|
|
|
7
7
|
export { default as HowToDeleter } from "./forms/HowToDeleter";
|
|
8
8
|
export { default as HowToEditor } from "./forms/HowToEditor";
|
|
9
9
|
export { default as HowToMultiSelector } from "./forms/HowToMultiSelector";
|
|
10
|
+
export { default as HowToReindexButton } from "./forms/HowToReindexButton";
|
|
10
11
|
export { default as HowToSelector } from "./forms/HowToSelector";
|
|
11
12
|
export { default as HowToList } from "./lists/HowToList";
|
|
@@ -10,6 +10,7 @@ import { HowToInterface } from "../../data/HowToInterface";
|
|
|
10
10
|
import { HowToService } from "../../data/HowToService";
|
|
11
11
|
import "../../hooks/useHowToTableStructure";
|
|
12
12
|
import HowToEditor from "../forms/HowToEditor";
|
|
13
|
+
import HowToReindexButton from "../forms/HowToReindexButton";
|
|
13
14
|
|
|
14
15
|
type HowToListProps = {
|
|
15
16
|
fullWidth?: boolean;
|
|
@@ -24,7 +25,10 @@ export default function HowToList({ fullWidth }: HowToListProps) {
|
|
|
24
25
|
retrieverParams: {},
|
|
25
26
|
});
|
|
26
27
|
|
|
27
|
-
const functions: ReactNode[] = [
|
|
28
|
+
const functions: ReactNode[] = [
|
|
29
|
+
<HowToReindexButton key="reindex-how-tos" refresh={data.refresh} />,
|
|
30
|
+
<HowToEditor key="create-how-to" />,
|
|
31
|
+
];
|
|
28
32
|
|
|
29
33
|
return (
|
|
30
34
|
<ContentListTable
|
|
@@ -11,16 +11,34 @@ const howToModule: ApiRequestDataTypeInterface = {
|
|
|
11
11
|
model: HowTo,
|
|
12
12
|
} as any;
|
|
13
13
|
|
|
14
|
+
const userModule: ApiRequestDataTypeInterface = {
|
|
15
|
+
name: "users",
|
|
16
|
+
model: HowTo,
|
|
17
|
+
} as any;
|
|
18
|
+
|
|
14
19
|
beforeAll(() => {
|
|
15
20
|
ModuleRegistry.register("HowTo", howToModule);
|
|
21
|
+
ModuleRegistry.register("User", userModule);
|
|
16
22
|
});
|
|
17
23
|
|
|
24
|
+
/** Collect every `id` value at any depth of a serialised payload. */
|
|
25
|
+
function collectIds(value: any, ids: string[] = []): string[] {
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
for (const item of value) collectIds(item, ids);
|
|
28
|
+
} else if (value && typeof value === "object") {
|
|
29
|
+
for (const [key, child] of Object.entries(value)) {
|
|
30
|
+
if (key === "id") ids.push(child as string);
|
|
31
|
+
else collectIds(child, ids);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return ids;
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
describe("HowTo.createJsonApi", () => {
|
|
19
38
|
it("serialises the new attributes", () => {
|
|
20
39
|
const res = new HowTo().createJsonApi({
|
|
21
40
|
id: "11111111-1111-4111-8111-111111111111",
|
|
22
41
|
name: "Add an NPC",
|
|
23
|
-
authorId: "",
|
|
24
42
|
description: [{ type: "paragraph", content: [] }],
|
|
25
43
|
howToType: "how-to",
|
|
26
44
|
slug: "add-an-npc",
|
|
@@ -40,4 +58,46 @@ describe("HowTo.createJsonApi", () => {
|
|
|
40
58
|
expect(a.draft).toBe(false);
|
|
41
59
|
expect(typeof a.description).toBe("string"); // existing: BlockNote JSON stringified
|
|
42
60
|
});
|
|
61
|
+
|
|
62
|
+
it("emits no author relationship when the how-to has no author", () => {
|
|
63
|
+
// The backend HowTo descriptor declares `relationships: {}` — a how-to has no
|
|
64
|
+
// author on the wire, so the editor must not invent one.
|
|
65
|
+
const res = new HowTo().createJsonApi({
|
|
66
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
67
|
+
name: "Add an NPC",
|
|
68
|
+
description: [{ type: "paragraph", content: [] }],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(res.data.relationships).toEqual({});
|
|
72
|
+
expect(res.data.relationships.author).toBeUndefined();
|
|
73
|
+
expect("author" in res.data.relationships).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("emits no empty-string id anywhere in the payload", () => {
|
|
77
|
+
const res = new HowTo().createJsonApi({
|
|
78
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
79
|
+
name: "Add an NPC",
|
|
80
|
+
description: [{ type: "paragraph", content: [] }],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const ids = collectIds(res);
|
|
84
|
+
expect(ids).toEqual(["11111111-1111-4111-8111-111111111111"]);
|
|
85
|
+
expect(ids).not.toContain("");
|
|
86
|
+
expect(JSON.stringify(res)).not.toContain('"id":""');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("still emits the author relationship when a real author id is supplied", () => {
|
|
90
|
+
// Guards the ContentInput widening: content types that DO carry an author
|
|
91
|
+
// keep serialising it through Content.addContentInput.
|
|
92
|
+
const res = new HowTo().createJsonApi({
|
|
93
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
94
|
+
name: "Add an NPC",
|
|
95
|
+
authorId: "22222222-2222-4222-8222-222222222222",
|
|
96
|
+
description: [{ type: "paragraph", content: [] }],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(res.data.relationships.author).toEqual({
|
|
100
|
+
data: { type: "users", id: "22222222-2222-4222-8222-222222222222" },
|
|
101
|
+
});
|
|
102
|
+
});
|
|
43
103
|
});
|
|
@@ -59,6 +59,19 @@ export class HowToService extends AbstractService {
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* POST /howtos/reindex — re-queues every guide for chunking. Replies 204.
|
|
64
|
+
* Chunking otherwise only happens on create and update, so a guide that was
|
|
65
|
+
* never chunked stays invisible to help-mode retrieval with no way back.
|
|
66
|
+
*/
|
|
67
|
+
static async reindex(): Promise<void> {
|
|
68
|
+
await this.callApi({
|
|
69
|
+
type: Modules.HowTo,
|
|
70
|
+
method: HttpMethod.POST,
|
|
71
|
+
endpoint: new EndpointCreator({ endpoint: Modules.HowTo, id: "reindex" }).generate(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
static async findPublished(params: { howToType?: string } = {}): Promise<HowToInterface[]> {
|
|
63
76
|
const endpoint = new EndpointCreator({ endpoint: `public/${Modules.HowTo.name}` });
|
|
64
77
|
if (params.howToType) endpoint.addAdditionalParam("type", params.howToType);
|