@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.
- 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-6G27S6P3.js → BlockNoteEditor-CFVSHS46.js} +19 -19
- package/dist/{BlockNoteEditor-6G27S6P3.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
- package/dist/{BlockNoteEditor-CR5KH4AI.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
- 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-L7DZSTFU.js → chunk-IB7DTXWG.js} +846 -129
- package/dist/chunk-IB7DTXWG.js.map +1 -0
- package/dist/{chunk-OKGGYCO5.js → chunk-LVE3BJ6K.js} +2088 -1134
- package/dist/chunk-LVE3BJ6K.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-JEGL7KHK.mjs → chunk-UQXE3SME.mjs} +2081 -1127
- package/dist/chunk-UQXE3SME.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 +258 -7
- package/dist/components/index.d.ts +258 -7
- package/dist/components/index.js +30 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +29 -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 +1 -1
- package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
- 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 +27 -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-SERY337S.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,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { MessagesSquareIcon, Sparkles } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { useCallback, useState } from "react";
|
|
7
|
+
|
|
8
|
+
import { SectionHeader } from "../../../../components/typography";
|
|
9
|
+
import { Modules } from "../../../../core";
|
|
10
|
+
import { usePageUrlGenerator } from "../../../../hooks";
|
|
11
|
+
import {
|
|
12
|
+
Button,
|
|
13
|
+
DropdownMenu,
|
|
14
|
+
DropdownMenuContent,
|
|
15
|
+
DropdownMenuItem,
|
|
16
|
+
DropdownMenuTrigger,
|
|
17
|
+
Sheet,
|
|
18
|
+
SheetContent,
|
|
19
|
+
SheetHeader,
|
|
20
|
+
SheetTitle,
|
|
21
|
+
SheetTrigger,
|
|
22
|
+
Switch,
|
|
23
|
+
} from "../../../../shadcnui";
|
|
24
|
+
import { AssistantComposer } from "../../../assistant/components/parts/AssistantComposer";
|
|
25
|
+
import { AssistantThread } from "../../../assistant/components/parts/AssistantThread";
|
|
26
|
+
import { useHandbookAsk } from "../../hooks/useHandbookAsk";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The ask, reachable from every handbook surface.
|
|
30
|
+
*
|
|
31
|
+
* It is the same conversation as `/administration/handbook/chat`, not a second
|
|
32
|
+
* one: both mount `useHandbookAsk`, so the threads listed here are the threads
|
|
33
|
+
* listed there and a question asked in the sheet is readable on the route. Only
|
|
34
|
+
* two things differ.
|
|
35
|
+
*
|
|
36
|
+
* 1. No url sync. A sheet floats over the page a reader is on; rewriting the
|
|
37
|
+
* address under it would send them somewhere else the moment they close it.
|
|
38
|
+
* `syncUrl: false` is exactly that difference.
|
|
39
|
+
* 2. No thread sidebar. The full list belongs to the route — the sheet carries
|
|
40
|
+
* the five most recent threads in a menu and a link to the rest, which is
|
|
41
|
+
* what a side panel has room for.
|
|
42
|
+
*
|
|
43
|
+
* On the reader the sheet also knows which page is open, and offers to confine
|
|
44
|
+
* the answer to it. The chip is off by default: a reader asking from inside a
|
|
45
|
+
* page usually still wants the whole manual to answer, and the narrow search is
|
|
46
|
+
* the deliberate choice.
|
|
47
|
+
*/
|
|
48
|
+
export function HandbookAskSheet({ handbookPageId }: HandbookAskSheetProps = {}) {
|
|
49
|
+
const t = useTranslations();
|
|
50
|
+
const generateUrl = usePageUrlGenerator();
|
|
51
|
+
|
|
52
|
+
const { threads, thread, messages, sending, ask, selectThread } = useHandbookAsk({ syncUrl: false });
|
|
53
|
+
const [scoped, setScoped] = useState(false);
|
|
54
|
+
|
|
55
|
+
const chatUrl = `${generateUrl({ page: Modules.HandbookPage })}/chat`;
|
|
56
|
+
const showThread = !!thread || sending || messages.length > 0;
|
|
57
|
+
|
|
58
|
+
const send = useCallback(
|
|
59
|
+
async (question: string) => {
|
|
60
|
+
await ask(question, { handbookPageId: scoped ? handbookPageId : undefined });
|
|
61
|
+
},
|
|
62
|
+
[ask, handbookPageId, scoped],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Sheet>
|
|
67
|
+
{/* `render`, never a nested <Button>: the trigger draws its own <button>,
|
|
68
|
+
and wrapping one inside it is invalid HTML. */}
|
|
69
|
+
<SheetTrigger render={<Button variant="outline" />}>
|
|
70
|
+
<MessagesSquareIcon className="me-1 h-4 w-4" />
|
|
71
|
+
{t("handbook.ask.open")}
|
|
72
|
+
</SheetTrigger>
|
|
73
|
+
<SheetContent side="end" className="flex w-full flex-col sm:max-w-lg">
|
|
74
|
+
{/* pe-10 clears SheetContent's own close button, which sits at the
|
|
75
|
+
inline end of the same row. */}
|
|
76
|
+
<SheetHeader className="flex-row items-center gap-2 pe-10">
|
|
77
|
+
<SheetTitle className="flex-1">{t("handbook.chat.title")}</SheetTitle>
|
|
78
|
+
|
|
79
|
+
<DropdownMenu>
|
|
80
|
+
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>
|
|
81
|
+
{t("handbook.ask.recent")}
|
|
82
|
+
</DropdownMenuTrigger>
|
|
83
|
+
<DropdownMenuContent align="end">
|
|
84
|
+
{threads.slice(0, 5).map((item) => (
|
|
85
|
+
<DropdownMenuItem key={item.id} onClick={() => void selectThread(item.id)}>
|
|
86
|
+
{item.title}
|
|
87
|
+
</DropdownMenuItem>
|
|
88
|
+
))}
|
|
89
|
+
<DropdownMenuItem>
|
|
90
|
+
<Link href={chatUrl} className="text-primary w-full font-medium">
|
|
91
|
+
{t("handbook.ask.all")}
|
|
92
|
+
</Link>
|
|
93
|
+
</DropdownMenuItem>
|
|
94
|
+
</DropdownMenuContent>
|
|
95
|
+
</DropdownMenu>
|
|
96
|
+
|
|
97
|
+
{thread && (
|
|
98
|
+
<Link href={`${chatUrl}/${thread.id}`} className="text-primary font-medium">
|
|
99
|
+
{t("handbook.ask.expand")}
|
|
100
|
+
</Link>
|
|
101
|
+
)}
|
|
102
|
+
</SheetHeader>
|
|
103
|
+
|
|
104
|
+
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden">
|
|
105
|
+
{showThread ? (
|
|
106
|
+
<AssistantThread
|
|
107
|
+
messages={messages}
|
|
108
|
+
sending={sending}
|
|
109
|
+
status={t("handbook.chat.pending")}
|
|
110
|
+
onSelectFollowUp={send}
|
|
111
|
+
/>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="flex flex-1 items-center justify-center p-6">
|
|
114
|
+
<div className="flex max-w-sm flex-col items-center text-center">
|
|
115
|
+
<span className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-blue-400 to-violet-500 text-white">
|
|
116
|
+
<Sparkles className="h-5 w-5" />
|
|
117
|
+
</span>
|
|
118
|
+
<SectionHeader>{t("handbook.chat.empty_state.title")}</SectionHeader>
|
|
119
|
+
<p className="text-muted-foreground mt-1 text-sm">{t("handbook.chat.empty_state.subtitle")}</p>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{handbookPageId && (
|
|
125
|
+
<div className="flex items-center gap-2 border-t px-4 pt-3">
|
|
126
|
+
<Switch checked={scoped} onCheckedChange={setScoped} aria-label={t("handbook.ask.thisPageOnly")} />
|
|
127
|
+
<span className="text-muted-foreground text-xs">{t("handbook.ask.thisPageOnly")}</span>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<AssistantComposer
|
|
132
|
+
onSend={send}
|
|
133
|
+
disabled={sending}
|
|
134
|
+
placeholder={t("handbook.chat.placeholder")}
|
|
135
|
+
sendLabel={t("handbook.chat.ask")}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</SheetContent>
|
|
139
|
+
</Sheet>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type HandbookAskSheetProps = {
|
|
144
|
+
/**
|
|
145
|
+
* The page the reader has open. Present only on the reader; when set, the
|
|
146
|
+
* sheet offers to confine the next question to that page.
|
|
147
|
+
*/
|
|
148
|
+
handbookPageId?: string;
|
|
149
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { RoundPageContainer } from "../../../../components";
|
|
6
|
+
import { Modules } from "../../../../core";
|
|
7
|
+
import { HandbookProvider } from "../../contexts/HandbookContext";
|
|
8
|
+
import { HandbookPageInterface } from "../../data/HandbookPageInterface";
|
|
9
|
+
import { HandbookPageService } from "../../data/HandbookPageService";
|
|
10
|
+
import { HandbookSectionInterface } from "../../data/HandbookSectionInterface";
|
|
11
|
+
import { HandbookSectionService } from "../../data/HandbookSectionService";
|
|
12
|
+
import { HandbookPageDetails } from "../details/HandbookPageDetails";
|
|
13
|
+
import { HandbookPageNeighbours } from "../details/HandbookPageNeighbours";
|
|
14
|
+
import { HandbookPageToc } from "../details/HandbookPageToc";
|
|
15
|
+
import { HandbookPageNavigator } from "../parts/HandbookPageNavigator";
|
|
16
|
+
import { HandbookAskSheet } from "./HandbookAskSheet";
|
|
17
|
+
|
|
18
|
+
type HandbookPageContainerProps = {
|
|
19
|
+
handbookPageId: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The element the heading index reads its headings out of. */
|
|
23
|
+
const CONTENT_CONTAINER_ID = "handbook-page-content";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fetches one page client-side from its id.
|
|
27
|
+
*
|
|
28
|
+
* The sibling how-to detail uses a server page that dehydrates into a provider.
|
|
29
|
+
* This one does not, for the same reason the ask surface does not: a handbook
|
|
30
|
+
* page is read-only and never edited, so there is no shared mutable state for a
|
|
31
|
+
* provider to hold, and an id string crosses the server/client boundary without
|
|
32
|
+
* any dehydrate/rehydrate ceremony.
|
|
33
|
+
*
|
|
34
|
+
* Three loads, deliberately separate: the page itself, the whole ordered page
|
|
35
|
+
* list (the navigator and the prev/next pair need it) and the sections. The
|
|
36
|
+
* section load has its own `try` — a failing sections endpoint degrades the
|
|
37
|
+
* navigator to raw keys, and must never take the page being read down with it.
|
|
38
|
+
*/
|
|
39
|
+
export default function HandbookPageContainer({ handbookPageId }: HandbookPageContainerProps) {
|
|
40
|
+
const t = useTranslations();
|
|
41
|
+
const [handbookPage, setHandbookPage] = useState<HandbookPageInterface | undefined>(undefined);
|
|
42
|
+
const [pages, setPages] = useState<HandbookPageInterface[]>([]);
|
|
43
|
+
const [sections, setSections] = useState<HandbookSectionInterface[]>([]);
|
|
44
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
|
|
49
|
+
const load = async () => {
|
|
50
|
+
setIsLoading(true);
|
|
51
|
+
try {
|
|
52
|
+
const page = await HandbookPageService.findOne({ id: handbookPageId });
|
|
53
|
+
if (!cancelled) setHandbookPage(page);
|
|
54
|
+
} finally {
|
|
55
|
+
if (!cancelled) setIsLoading(false);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
void load();
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
};
|
|
64
|
+
}, [handbookPageId]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
let cancelled = false;
|
|
68
|
+
|
|
69
|
+
const loadPages = async () => {
|
|
70
|
+
const allPages = await HandbookPageService.findMany({ fetchAll: true });
|
|
71
|
+
if (!cancelled) setPages(allPages);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const loadSections = async () => {
|
|
75
|
+
try {
|
|
76
|
+
const allSections = await HandbookSectionService.findMany();
|
|
77
|
+
if (!cancelled) setSections(allSections);
|
|
78
|
+
} catch {
|
|
79
|
+
if (!cancelled) setSections([]);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
void loadPages();
|
|
84
|
+
void loadSections();
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
};
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const section = useMemo(
|
|
92
|
+
() => sections.find((candidate) => candidate.key === handbookPage?.section),
|
|
93
|
+
[sections, handbookPage],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<HandbookProvider
|
|
98
|
+
page={handbookPage}
|
|
99
|
+
section={section}
|
|
100
|
+
functions={handbookPage ? <HandbookAskSheet handbookPageId={handbookPage.id} /> : undefined}
|
|
101
|
+
>
|
|
102
|
+
{/*
|
|
103
|
+
`fullWidth` on the READER, unlike the contents page.
|
|
104
|
+
|
|
105
|
+
The container's `max-w-6xl` exists to hold a reading measure, and the
|
|
106
|
+
two rails are not reading — cramming navigator, prose and heading index
|
|
107
|
+
into that one column squeezes the prose to a third of it. So the reader
|
|
108
|
+
takes the full width and caps only its centre column, which is where the
|
|
109
|
+
measure belongs.
|
|
110
|
+
*/}
|
|
111
|
+
<RoundPageContainer module={Modules.HandbookPage} fullWidth forceHeader>
|
|
112
|
+
{isLoading ? null : handbookPage ? (
|
|
113
|
+
/*
|
|
114
|
+
`items-start` is what makes `sticky` work on the two side columns:
|
|
115
|
+
a grid item stretches to the row's height by default, so a sticky
|
|
116
|
+
child inside it has nowhere to travel and the whole page scrolls
|
|
117
|
+
instead. With `items-start` each column is only as tall as its
|
|
118
|
+
content, and the rails hold their place while the page moves.
|
|
119
|
+
|
|
120
|
+
Each rail wrapper owns the scrolling — the navigator carries no
|
|
121
|
+
`max-h` of its own, so there is exactly one scroll container per
|
|
122
|
+
rail rather than two nested ones.
|
|
123
|
+
*/
|
|
124
|
+
<div className="grid items-start gap-8 p-4 lg:grid-cols-[240px_minmax(0,1fr)_240px]">
|
|
125
|
+
<div className="sticky top-4 hidden max-h-[calc(100svh-8rem)] overflow-y-auto lg:block">
|
|
126
|
+
<HandbookPageNavigator pages={pages} sections={sections} currentId={handbookPage.id} />
|
|
127
|
+
</div>
|
|
128
|
+
{/* The measure lives HERE, on the prose, not on the whole page. */}
|
|
129
|
+
<div id={CONTENT_CONTAINER_ID} className="mx-auto w-full max-w-3xl min-w-0">
|
|
130
|
+
<HandbookPageDetails handbookPage={handbookPage} />
|
|
131
|
+
<HandbookPageNeighbours pages={pages} currentId={handbookPage.id} />
|
|
132
|
+
</div>
|
|
133
|
+
<div className="sticky top-4 hidden max-h-[calc(100svh-8rem)] overflow-y-auto lg:block">
|
|
134
|
+
<HandbookPageToc containerId={CONTENT_CONTAINER_ID} />
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
) : (
|
|
138
|
+
<span className="text-muted-foreground text-sm">{t("handbook.notFound")}</span>
|
|
139
|
+
)}
|
|
140
|
+
</RoundPageContainer>
|
|
141
|
+
</HandbookProvider>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { DataClassRegistry } from "../../../../../core/registry/DataClassRegistry";
|
|
6
|
+
import { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
7
|
+
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
8
|
+
import { HandbookThread } from "../../../data/HandbookThread";
|
|
9
|
+
import { HandbookThreadMessage } from "../../../data/HandbookThreadMessage";
|
|
10
|
+
import { HandbookThreadMessageService } from "../../../data/HandbookThreadMessageService";
|
|
11
|
+
import { HandbookThreadService } from "../../../data/HandbookThreadService";
|
|
12
|
+
import { HandbookAskContainer } from "../HandbookAskContainer";
|
|
13
|
+
|
|
14
|
+
vi.mock("../../../data/HandbookThreadService", () => ({
|
|
15
|
+
HandbookThreadService: {
|
|
16
|
+
findMany: vi.fn(),
|
|
17
|
+
findOne: vi.fn(),
|
|
18
|
+
create: vi.fn(),
|
|
19
|
+
rename: vi.fn(),
|
|
20
|
+
delete: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("../../../data/HandbookThreadMessageService", () => ({
|
|
25
|
+
HandbookThreadMessageService: { ask: vi.fn() },
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// RoundPageContainer is page chrome — header, details panel, url rewriting.
|
|
29
|
+
// None of it is under test here, and all of it needs providers this spec has no
|
|
30
|
+
// reason to mount. The stub still renders `details`, because that is where the
|
|
31
|
+
// thread list lives in this layout (copied from AssistantPageContainer).
|
|
32
|
+
vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
|
|
33
|
+
RoundPageContainer: ({ children, details }: { children?: React.ReactNode; details?: React.ReactNode }) => (
|
|
34
|
+
<div>
|
|
35
|
+
{details}
|
|
36
|
+
{children}
|
|
37
|
+
</div>
|
|
38
|
+
),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// RoundPageContainer reads Modules.HandbookPage; the handbook models resolve
|
|
42
|
+
// Modules.HandbookThread / Modules.HandbookThreadMessage lazily through
|
|
43
|
+
// ModuleRegistry. Registering is the package's spec convention (see
|
|
44
|
+
// HandbookPage.spec.ts) and is preferred to mocking the core barrel, which the
|
|
45
|
+
// reused assistant components also import from.
|
|
46
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
47
|
+
try {
|
|
48
|
+
ModuleRegistry.get(key as any);
|
|
49
|
+
} catch {
|
|
50
|
+
ModuleRegistry.register(key, module);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
beforeAll(() => {
|
|
55
|
+
registerIfAbsent("HandbookPage", { name: "handbookpages" } as any);
|
|
56
|
+
const threadModule = { name: "handbookthreads", model: HandbookThread } as any;
|
|
57
|
+
const messageModule = { name: "handbookthreadmessages", model: HandbookThreadMessage } as any;
|
|
58
|
+
registerIfAbsent("HandbookThread", threadModule);
|
|
59
|
+
registerIfAbsent("HandbookThreadMessage", messageModule);
|
|
60
|
+
// `_readIncluded` and `rehydrate()` resolve the CONSTRUCTOR through
|
|
61
|
+
// DataClassRegistry, which the app's Bootstrapper fills from the same module
|
|
62
|
+
// definitions — registering only in ModuleRegistry leaves side-loaded
|
|
63
|
+
// messages unrehydratable.
|
|
64
|
+
DataClassRegistry.registerObjectClass(threadModule, HandbookThread as any);
|
|
65
|
+
DataClassRegistry.registerObjectClass(messageModule, HandbookThreadMessage as any);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function buildThread(params: {
|
|
69
|
+
id: string;
|
|
70
|
+
title: string;
|
|
71
|
+
updatedAt?: string;
|
|
72
|
+
messages?: { id: string; role: "user" | "assistant"; content: string; position: number; sources?: string[] }[];
|
|
73
|
+
}): HandbookThread {
|
|
74
|
+
const messages = params.messages ?? [];
|
|
75
|
+
return new HandbookThread().rehydrate({
|
|
76
|
+
jsonApi: {
|
|
77
|
+
id: params.id,
|
|
78
|
+
type: "handbookthreads",
|
|
79
|
+
attributes: { title: params.title },
|
|
80
|
+
meta: { updatedAt: params.updatedAt ?? new Date().toISOString() },
|
|
81
|
+
relationships: {
|
|
82
|
+
messages: { data: messages.map((m) => ({ id: m.id, type: "handbookthreadmessages" })) },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
included: messages.map((m) => ({
|
|
86
|
+
id: m.id,
|
|
87
|
+
type: "handbookthreadmessages",
|
|
88
|
+
attributes: { role: m.role, content: m.content, position: m.position, sources: m.sources ?? [] },
|
|
89
|
+
})),
|
|
90
|
+
} as any);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The send control belongs to the shared AssistantComposer, not to this feature. */
|
|
94
|
+
const sendButton = () => screen.getByRole("button", { name: /handbook\.chat\.ask/i });
|
|
95
|
+
|
|
96
|
+
describe("HandbookAskContainer", () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
vi.mocked(HandbookThreadService.findMany).mockReset().mockResolvedValue([]);
|
|
99
|
+
vi.mocked(HandbookThreadService.findOne).mockReset();
|
|
100
|
+
vi.mocked(HandbookThreadService.create).mockReset();
|
|
101
|
+
vi.mocked(HandbookThreadService.delete).mockReset().mockResolvedValue(undefined);
|
|
102
|
+
vi.mocked(HandbookThreadService.rename).mockReset().mockResolvedValue(undefined);
|
|
103
|
+
vi.mocked(HandbookThreadMessageService.ask).mockReset().mockResolvedValue(undefined);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("reuses the shared composer and sidebar but never the assistant's copy", async () => {
|
|
107
|
+
render(<HandbookAskContainer />);
|
|
108
|
+
|
|
109
|
+
expect(screen.getByPlaceholderText("handbook.chat.placeholder")).toBeInTheDocument();
|
|
110
|
+
expect(screen.queryByPlaceholderText("features.assistant.composer_placeholder")).not.toBeInTheDocument();
|
|
111
|
+
expect(sendButton()).toBeDisabled();
|
|
112
|
+
expect(screen.getByRole("button", { name: /handbook\.chat\.new/ })).toBeInTheDocument();
|
|
113
|
+
await waitFor(() => expect(screen.getByText("handbook.chat.empty_sidebar")).toBeInTheDocument());
|
|
114
|
+
expect(screen.queryByText("features.assistant.empty_sidebar")).not.toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("shows the handbook empty state until a thread exists", async () => {
|
|
118
|
+
render(<HandbookAskContainer />);
|
|
119
|
+
|
|
120
|
+
expect(screen.getByText("handbook.chat.empty_state.title")).toBeInTheDocument();
|
|
121
|
+
expect(screen.getByText("handbook.chat.empty_state.subtitle")).toBeInTheDocument();
|
|
122
|
+
await waitFor(() => expect(HandbookThreadService.findMany).toHaveBeenCalled());
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("lists the previous threads in the sidebar", async () => {
|
|
126
|
+
vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
|
|
127
|
+
buildThread({ id: "t1", title: "Filtri di azienda" }),
|
|
128
|
+
buildThread({ id: "t2", title: "Porte del worker" }),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
render(<HandbookAskContainer />);
|
|
132
|
+
|
|
133
|
+
await waitFor(() => expect(screen.getByRole("button", { name: "Filtri di azienda" })).toBeInTheDocument());
|
|
134
|
+
expect(screen.getByRole("button", { name: "Porte del worker" })).toBeInTheDocument();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("loads the messages of the thread picked from the sidebar", async () => {
|
|
138
|
+
vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
|
|
139
|
+
buildThread({ id: "t1", title: "Filtri di azienda" }),
|
|
140
|
+
]);
|
|
141
|
+
vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
|
|
142
|
+
buildThread({
|
|
143
|
+
id: "t1",
|
|
144
|
+
title: "Filtri di azienda",
|
|
145
|
+
messages: [
|
|
146
|
+
{ id: "m1", role: "user", content: "Come funziona buildDefaultMatch?", position: 0 },
|
|
147
|
+
{ id: "m2", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 },
|
|
148
|
+
],
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
render(<HandbookAskContainer />);
|
|
153
|
+
await userEvent.click(await screen.findByRole("button", { name: "Filtri di azienda" }));
|
|
154
|
+
|
|
155
|
+
await waitFor(() => expect(HandbookThreadService.findOne).toHaveBeenCalledWith({ id: "t1" }));
|
|
156
|
+
expect(await screen.findByText("Come funziona buildDefaultMatch?")).toBeInTheDocument();
|
|
157
|
+
expect(screen.getByText(/Inietta il filtro di azienda/)).toBeInTheDocument();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("asks the trimmed question, creates a thread and shows its title", async () => {
|
|
161
|
+
vi.mocked(HandbookThreadService.create).mockResolvedValue(buildThread({ id: "t9", title: "Filtri di azienda" }));
|
|
162
|
+
vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
|
|
163
|
+
buildThread({
|
|
164
|
+
id: "t9",
|
|
165
|
+
title: "Filtri di azienda",
|
|
166
|
+
messages: [
|
|
167
|
+
{ id: "m1", role: "user", content: "Come funziona buildDefaultMatch?", position: 0 },
|
|
168
|
+
{ id: "m2", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 },
|
|
169
|
+
],
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
render(<HandbookAskContainer />);
|
|
174
|
+
await userEvent.type(screen.getByRole("textbox"), " Come funziona buildDefaultMatch? ");
|
|
175
|
+
await userEvent.click(sendButton());
|
|
176
|
+
|
|
177
|
+
await waitFor(() =>
|
|
178
|
+
expect(HandbookThreadService.create).toHaveBeenCalledWith(
|
|
179
|
+
expect.objectContaining({ question: "Come funziona buildDefaultMatch?" }),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// The title only exists server-side until the thread is read back, so its
|
|
184
|
+
// appearance in the sidebar row is the proof the transcript was reloaded.
|
|
185
|
+
await waitFor(() => expect(screen.getAllByText("Filtri di azienda").length).toBeGreaterThan(0));
|
|
186
|
+
expect(screen.getByText(/Inietta il filtro di azienda/)).toBeInTheDocument();
|
|
187
|
+
|
|
188
|
+
// There is exactly ONE header on this page — RoundPageContainer's. The
|
|
189
|
+
// assistant's per-thread header is deliberately not rendered, so neither
|
|
190
|
+
// its rename nor its delete control may appear.
|
|
191
|
+
expect(screen.queryByRole("button", { name: "features.assistant.rename" })).toBeNull();
|
|
192
|
+
expect(screen.queryByRole("button", { name: "features.assistant.delete" })).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("appends to the open thread instead of creating a second one", async () => {
|
|
196
|
+
vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
|
|
197
|
+
buildThread({ id: "t1", title: "Filtri di azienda" }),
|
|
198
|
+
]);
|
|
199
|
+
vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
|
|
200
|
+
buildThread({
|
|
201
|
+
id: "t1",
|
|
202
|
+
title: "Filtri di azienda",
|
|
203
|
+
messages: [{ id: "m1", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 }],
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
render(<HandbookAskContainer />);
|
|
208
|
+
await userEvent.click(await screen.findByRole("button", { name: "Filtri di azienda" }));
|
|
209
|
+
await waitFor(() => expect(screen.getByText(/Inietta il filtro di azienda/)).toBeInTheDocument());
|
|
210
|
+
|
|
211
|
+
await userEvent.type(screen.getByRole("textbox"), "E i cursori?");
|
|
212
|
+
await userEvent.click(sendButton());
|
|
213
|
+
|
|
214
|
+
await waitFor(() =>
|
|
215
|
+
expect(HandbookThreadMessageService.ask).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({ threadId: "t1", question: "E i cursori?" }),
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
expect(HandbookThreadService.create).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("renders the cited sources inside the answer", async () => {
|
|
223
|
+
vi.mocked(HandbookThreadService.create).mockResolvedValue(buildThread({ id: "t9", title: "Fonti" }));
|
|
224
|
+
vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
|
|
225
|
+
buildThread({
|
|
226
|
+
id: "t9",
|
|
227
|
+
title: "Fonti",
|
|
228
|
+
messages: [
|
|
229
|
+
{
|
|
230
|
+
id: "m2",
|
|
231
|
+
role: "assistant",
|
|
232
|
+
content: "Inietta il filtro di azienda.",
|
|
233
|
+
position: 1,
|
|
234
|
+
sources: ["03-backend/repositories.md", "02-framework/security.md"],
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
render(<HandbookAskContainer />);
|
|
241
|
+
await userEvent.type(screen.getByRole("textbox"), "Perché?");
|
|
242
|
+
await userEvent.click(sendButton());
|
|
243
|
+
|
|
244
|
+
await waitFor(() => expect(screen.getByText("03-backend/repositories.md")).toBeInTheDocument());
|
|
245
|
+
expect(screen.getByText("02-framework/security.md")).toBeInTheDocument();
|
|
246
|
+
expect(screen.getByText("handbook.chat.sources")).toBeInTheDocument();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("surfaces a failure as an assistant message rather than an error pane", async () => {
|
|
250
|
+
vi.mocked(HandbookThreadService.create).mockRejectedValue(new Error("AI is not configured"));
|
|
251
|
+
|
|
252
|
+
render(<HandbookAskContainer />);
|
|
253
|
+
await userEvent.type(screen.getByRole("textbox"), "Perché?");
|
|
254
|
+
await userEvent.click(sendButton());
|
|
255
|
+
|
|
256
|
+
await waitFor(() => expect(screen.getByText("handbook.chat.failed")).toBeInTheDocument());
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("keeps the asked question in the transcript when the answer fails", async () => {
|
|
260
|
+
vi.mocked(HandbookThreadService.create).mockRejectedValue(new Error("boom"));
|
|
261
|
+
|
|
262
|
+
render(<HandbookAskContainer />);
|
|
263
|
+
await userEvent.type(screen.getByRole("textbox"), "Che porte usa il worker?");
|
|
264
|
+
await userEvent.click(sendButton());
|
|
265
|
+
|
|
266
|
+
await waitFor(() => expect(screen.getByText("Che porte usa il worker?")).toBeInTheDocument());
|
|
267
|
+
});
|
|
268
|
+
});
|