@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,26 @@
|
|
|
1
|
+
import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
|
|
2
|
+
import { HandbookSectionInterface } from "./HandbookSectionInterface";
|
|
3
|
+
|
|
4
|
+
export class HandbookSectionService extends AbstractService {
|
|
5
|
+
/**
|
|
6
|
+
* Every section in one read, always.
|
|
7
|
+
*
|
|
8
|
+
* There are as many sections as an application has documentation directories
|
|
9
|
+
* — ten or so — so this takes no `fetchAll` argument and no cursor: unlike
|
|
10
|
+
* the page list, nothing here is ever a second page, and a caller that could
|
|
11
|
+
* ask for one page of sections would only be able to get it wrong.
|
|
12
|
+
*/
|
|
13
|
+
static async findMany(): Promise<HandbookSectionInterface[]> {
|
|
14
|
+
const endpoint = new EndpointCreator({ endpoint: Modules.HandbookSection });
|
|
15
|
+
|
|
16
|
+
endpoint.addAdditionalParam("fetchAll", "true");
|
|
17
|
+
if (Modules.HandbookSection.inclusions?.lists?.fields)
|
|
18
|
+
endpoint.limitToFields(Modules.HandbookSection.inclusions.lists.fields);
|
|
19
|
+
|
|
20
|
+
return this.callApi({
|
|
21
|
+
type: Modules.HandbookSection,
|
|
22
|
+
method: HttpMethod.GET,
|
|
23
|
+
endpoint: endpoint.generate(),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
|
|
2
|
+
import { HandbookThreadInput, HandbookThreadInterface } from "./HandbookThreadInterface";
|
|
3
|
+
import { HandbookThreadMessageInterface } from "./HandbookThreadMessageInterface";
|
|
4
|
+
|
|
5
|
+
export class HandbookThread extends AbstractApiData implements HandbookThreadInterface {
|
|
6
|
+
private _title?: string;
|
|
7
|
+
private _messageCount?: number;
|
|
8
|
+
private _messages?: HandbookThreadMessageInterface[];
|
|
9
|
+
|
|
10
|
+
get title(): string {
|
|
11
|
+
return this._title ?? "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get messageCount(): number {
|
|
15
|
+
return this._messageCount ?? 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The handbook runs one agent and has no engine choice, so nothing routes on
|
|
20
|
+
* this. It exists because `AssistantInterface` declares it, which is what
|
|
21
|
+
* lets the shared chat chrome take a handbook thread unchanged.
|
|
22
|
+
*/
|
|
23
|
+
get engine(): string | undefined {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A handbook thread is never bound to a product resource. */
|
|
28
|
+
get boundContentType(): string | undefined {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get boundContentId(): string | undefined {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get messages(): HandbookThreadMessageInterface[] {
|
|
37
|
+
return this._messages ?? [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
41
|
+
super.rehydrate(data);
|
|
42
|
+
|
|
43
|
+
const attributes = data.jsonApi.attributes ?? {};
|
|
44
|
+
this._title = attributes.title;
|
|
45
|
+
|
|
46
|
+
const fromMeta = data.jsonApi.meta?.messageCount;
|
|
47
|
+
this._messageCount =
|
|
48
|
+
typeof fromMeta === "number"
|
|
49
|
+
? fromMeta
|
|
50
|
+
: typeof attributes.messageCount === "number"
|
|
51
|
+
? attributes.messageCount
|
|
52
|
+
: 0;
|
|
53
|
+
|
|
54
|
+
const messages = this._readIncluded<HandbookThreadMessageInterface>(
|
|
55
|
+
data,
|
|
56
|
+
"messages",
|
|
57
|
+
Modules.HandbookThreadMessage,
|
|
58
|
+
);
|
|
59
|
+
this._messages = Array.isArray(messages) ? messages : messages ? [messages] : [];
|
|
60
|
+
|
|
61
|
+
// `groupThreadsByBucket` buckets the sidebar on `updatedAt` as a Date, and
|
|
62
|
+
// `AbstractApiData` only reads it from `meta`. A thread that carries the
|
|
63
|
+
// timestamp as an attribute instead would otherwise fall back to "now" and
|
|
64
|
+
// land every thread in "Today".
|
|
65
|
+
if (this._updatedAt === undefined && attributes.updatedAt !== undefined)
|
|
66
|
+
this._updatedAt = new Date(attributes.updatedAt);
|
|
67
|
+
if (this._createdAt === undefined && attributes.createdAt !== undefined)
|
|
68
|
+
this._createdAt = new Date(attributes.createdAt);
|
|
69
|
+
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The envelope `POST /handbookthreads` accepts: the opening question. The
|
|
75
|
+
* server derives the title from it, so no title is sent and no client id —
|
|
76
|
+
* the thread's identity is the server's to mint.
|
|
77
|
+
*
|
|
78
|
+
* `handbookPageId` rides the same way `question` does, through this method
|
|
79
|
+
* and never through `overridesJsonApiCreation`. It is written only when the
|
|
80
|
+
* caller supplied it: the DTO takes it as optional, and sending an explicit
|
|
81
|
+
* `null` would fail validation rather than mean "the whole manual".
|
|
82
|
+
*/
|
|
83
|
+
createJsonApi(data: HandbookThreadInput) {
|
|
84
|
+
return {
|
|
85
|
+
data: {
|
|
86
|
+
type: Modules.HandbookThread.name,
|
|
87
|
+
attributes: {
|
|
88
|
+
question: data.question,
|
|
89
|
+
...(data.handbookPageId !== undefined ? { handbookPageId: data.handbookPageId } : {}),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
included: [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The envelope `PATCH /handbookthreads/:id` accepts. A dedicated method
|
|
98
|
+
* rather than a branch inside `createJsonApi`: the rename sends the title and
|
|
99
|
+
* nothing else, while a create sends the question and nothing else, and the
|
|
100
|
+
* service must never hand-build either.
|
|
101
|
+
*/
|
|
102
|
+
createRenameJsonApi(params: { id: string; title: string }) {
|
|
103
|
+
return {
|
|
104
|
+
data: {
|
|
105
|
+
type: Modules.HandbookThread.name,
|
|
106
|
+
id: params.id,
|
|
107
|
+
attributes: {
|
|
108
|
+
title: params.title,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
included: [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AssistantInterface } from "../../assistant/data/AssistantInterface";
|
|
2
|
+
import { HandbookThreadMessageInterface } from "./HandbookThreadMessageInterface";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A persisted handbook conversation.
|
|
6
|
+
*
|
|
7
|
+
* It extends `AssistantInterface` so the shared chat chrome —
|
|
8
|
+
* `AssistantSidebar`, `AssistantThreadHeader`, `groupThreadsByBucket` — accepts
|
|
9
|
+
* it verbatim. It is NOT an Assistant: an Assistant is company scoped and a
|
|
10
|
+
* platform administrator has no company, so the handbook owns its own threads
|
|
11
|
+
* and answers `engine` / `boundContentType` / `boundContentId` with `undefined`.
|
|
12
|
+
*/
|
|
13
|
+
export interface HandbookThreadInterface extends AssistantInterface {
|
|
14
|
+
/** Side-loaded by `GET /handbookthreads/:id`; empty on the list endpoint. */
|
|
15
|
+
get messages(): HandbookThreadMessageInterface[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The payload that opens a thread: the first question. The server derives the
|
|
20
|
+
* title from it, so nothing else is sent.
|
|
21
|
+
*/
|
|
22
|
+
export type HandbookThreadInput = {
|
|
23
|
+
id: string;
|
|
24
|
+
question: string;
|
|
25
|
+
/**
|
|
26
|
+
* Scopes retrieval to one handbook page. Set only by the reader's ask sheet,
|
|
27
|
+
* when the reader turns the "this page only" chip on; absent everywhere else,
|
|
28
|
+
* and the agent then searches the whole manual.
|
|
29
|
+
*/
|
|
30
|
+
handbookPageId?: string;
|
|
31
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import { AbstractApiData, ApiDataInterface, JsonApiHydratedDataInterface, Modules } from "../../../core";
|
|
3
|
+
import type {
|
|
4
|
+
AssistantMessageRole,
|
|
5
|
+
AssistantMessageType,
|
|
6
|
+
} from "../../assistant-message/data/AssistantMessageInterface";
|
|
7
|
+
import { ChunkInterface, ChunkRelationshipMeta } from "../../chunk/data/ChunkInterface";
|
|
8
|
+
import { HandbookThreadMessageInput, HandbookThreadMessageInterface } from "./HandbookThreadMessageInterface";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One persisted handbook turn.
|
|
12
|
+
*
|
|
13
|
+
* The getters below are the structural contract `AssistantMessageInterface`
|
|
14
|
+
* asks for, which is what lets `AssistantThread` render handbook messages with
|
|
15
|
+
* no adapter. The handbook has no token accounting, no entity references, no
|
|
16
|
+
* chunk citations and no approval actions, so those answer `undefined` / `[]`
|
|
17
|
+
* rather than being faked.
|
|
18
|
+
*/
|
|
19
|
+
export class HandbookThreadMessage extends AbstractApiData implements HandbookThreadMessageInterface {
|
|
20
|
+
private _role?: AssistantMessageRole;
|
|
21
|
+
private _content?: string;
|
|
22
|
+
private _position?: number;
|
|
23
|
+
private _sources?: string[];
|
|
24
|
+
private _isOptimistic = false;
|
|
25
|
+
|
|
26
|
+
get role(): AssistantMessageRole {
|
|
27
|
+
return this._role ?? "assistant";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get content(): string {
|
|
31
|
+
return this._content ?? "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get position(): number {
|
|
35
|
+
return this._position ?? 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get sources(): string[] {
|
|
39
|
+
return this._sources ?? [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The handbook agent returns no follow-ups. */
|
|
43
|
+
get suggestedQuestions(): string[] {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Token accounting is not billed against a handbook thread. */
|
|
48
|
+
get inputTokens(): number | undefined {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get outputTokens(): number | undefined {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The handbook answers from pages, never from product entities. */
|
|
57
|
+
get references(): ApiDataInterface[] {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Cited pages travel in `sources`, not as Chunk resources. */
|
|
62
|
+
get citations(): (ChunkInterface & ChunkRelationshipMeta)[] {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Set only by `buildLocal`; a rehydrated message is always real. */
|
|
67
|
+
get isOptimistic(): boolean {
|
|
68
|
+
return this._isOptimistic;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** There is no operator engine behind the handbook, so never an approval request. */
|
|
72
|
+
get messageType(): AssistantMessageType {
|
|
73
|
+
return "text";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get actionId(): string | undefined {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
81
|
+
super.rehydrate(data);
|
|
82
|
+
|
|
83
|
+
const attributes = data.jsonApi.attributes ?? {};
|
|
84
|
+
this._role = attributes.role as AssistantMessageRole | undefined;
|
|
85
|
+
this._content = attributes.content;
|
|
86
|
+
this._position = typeof attributes.position === "number" ? attributes.position : Number(attributes.position ?? 0);
|
|
87
|
+
this._sources = Array.isArray(attributes.sources) ? attributes.sources : [];
|
|
88
|
+
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The envelope `POST /handbookthreads/:id/handbookthreadmessages` accepts:
|
|
94
|
+
* only the question. Role and position are derived server-side from the
|
|
95
|
+
* thread, and there is no client-chosen id for the pair of messages the turn
|
|
96
|
+
* creates.
|
|
97
|
+
*
|
|
98
|
+
* `handbookPageId` rides the same way `question` does, through this method
|
|
99
|
+
* and never through `overridesJsonApiCreation`. It is written only when the
|
|
100
|
+
* caller supplied it: the DTO takes it as optional, and sending an explicit
|
|
101
|
+
* `null` would fail validation rather than mean "the whole manual".
|
|
102
|
+
*/
|
|
103
|
+
createJsonApi(data: HandbookThreadMessageInput) {
|
|
104
|
+
return {
|
|
105
|
+
data: {
|
|
106
|
+
type: Modules.HandbookThreadMessage.name,
|
|
107
|
+
attributes: {
|
|
108
|
+
question: data.question,
|
|
109
|
+
...(data.handbookPageId !== undefined ? { handbookPageId: data.handbookPageId } : {}),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
included: [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A message that exists only on the client: the optimistic user turn while
|
|
118
|
+
* the agent runs, and the failure notice when it does not answer. Mirrors
|
|
119
|
+
* `AssistantMessage.buildLocal`, which the stateless handbook surface used
|
|
120
|
+
* before threads existed.
|
|
121
|
+
*/
|
|
122
|
+
static buildLocal(params: {
|
|
123
|
+
role: AssistantMessageRole;
|
|
124
|
+
content: string;
|
|
125
|
+
position: number;
|
|
126
|
+
isOptimistic?: boolean;
|
|
127
|
+
}): HandbookThreadMessage {
|
|
128
|
+
const message = new HandbookThreadMessage();
|
|
129
|
+
message.rehydrate({
|
|
130
|
+
jsonApi: {
|
|
131
|
+
id: uuidv4(),
|
|
132
|
+
type: Modules.HandbookThreadMessage.name,
|
|
133
|
+
attributes: {
|
|
134
|
+
role: params.role,
|
|
135
|
+
content: params.content,
|
|
136
|
+
position: params.position,
|
|
137
|
+
},
|
|
138
|
+
} as any,
|
|
139
|
+
included: [],
|
|
140
|
+
});
|
|
141
|
+
message._isOptimistic = params.isOptimistic ?? false;
|
|
142
|
+
return message;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One turn of a persisted handbook conversation.
|
|
5
|
+
*
|
|
6
|
+
* It extends `AssistantMessageInterface` rather than restating it: the shared
|
|
7
|
+
* chat components (`AssistantThread` → `MessageList` → `MessageItem`) are typed
|
|
8
|
+
* against that interface and take their data as plain props, so a handbook
|
|
9
|
+
* message that satisfies the same structural contract renders through them with
|
|
10
|
+
* no adapter. Everything the handbook has no concept of — token counts,
|
|
11
|
+
* references, citations, approval actions — is answered with the empty value.
|
|
12
|
+
*
|
|
13
|
+
* `sources` is the handbook's own addition: repo-relative page paths, in the
|
|
14
|
+
* order retrieval returned them.
|
|
15
|
+
*/
|
|
16
|
+
export interface HandbookThreadMessageInterface extends AssistantMessageInterface {
|
|
17
|
+
get sources(): string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The payload of one question appended to an existing thread. `id` is the
|
|
22
|
+
* client-generated identity every model input in this framework carries; the
|
|
23
|
+
* append endpoint derives role and position server-side, so the envelope the
|
|
24
|
+
* model builds sends only `question`.
|
|
25
|
+
*/
|
|
26
|
+
export type HandbookThreadMessageInput = {
|
|
27
|
+
id: string;
|
|
28
|
+
question: string;
|
|
29
|
+
/**
|
|
30
|
+
* Scopes retrieval to one handbook page. Set only by the reader's ask sheet,
|
|
31
|
+
* when the reader turns the "this page only" chip on; absent everywhere else,
|
|
32
|
+
* and the agent then searches the whole manual.
|
|
33
|
+
*/
|
|
34
|
+
handbookPageId?: string;
|
|
35
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
|
|
2
|
+
import { HandbookThreadMessageInput } from "./HandbookThreadMessageInterface";
|
|
3
|
+
|
|
4
|
+
export class HandbookThreadMessageService extends AbstractService {
|
|
5
|
+
/**
|
|
6
|
+
* Appends one question to an existing thread and runs the agent turn.
|
|
7
|
+
*
|
|
8
|
+
* It returns nothing on purpose: the caller reads the turn back with
|
|
9
|
+
* `HandbookThreadService.findOne`, which is the one endpoint the contract
|
|
10
|
+
* guarantees side-loads the full message list. That keeps a single code path
|
|
11
|
+
* for "load the thread after a mutation" — the same one the sidebar's select
|
|
12
|
+
* uses — instead of two ways of arriving at the transcript.
|
|
13
|
+
*
|
|
14
|
+
* The API answers 400 when the installation has no usable AI configuration
|
|
15
|
+
* and 403 for a non-administrator; both reach the caller as a thrown error.
|
|
16
|
+
*/
|
|
17
|
+
static async ask(params: HandbookThreadMessageInput & { threadId: string }): Promise<void> {
|
|
18
|
+
await this.callApi({
|
|
19
|
+
type: Modules.HandbookThreadMessage,
|
|
20
|
+
method: HttpMethod.POST,
|
|
21
|
+
endpoint: new EndpointCreator({
|
|
22
|
+
endpoint: Modules.HandbookThread,
|
|
23
|
+
id: params.threadId,
|
|
24
|
+
childEndpoint: Modules.HandbookThreadMessage,
|
|
25
|
+
}).generate(),
|
|
26
|
+
input: { id: params.id, question: params.question, handbookPageId: params.handbookPageId },
|
|
27
|
+
suppressGlobalError: true,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef, PreviousRef } from "../../../core";
|
|
2
|
+
import { HandbookThread } from "./HandbookThread";
|
|
3
|
+
import { HandbookThreadInput, HandbookThreadInterface } from "./HandbookThreadInterface";
|
|
4
|
+
|
|
5
|
+
export class HandbookThreadService extends AbstractService {
|
|
6
|
+
/**
|
|
7
|
+
* `next` / `prev` are not optional extras: `useDataListRetriever` drives
|
|
8
|
+
* pagination by handing the cursor back here, so a findMany that drops them
|
|
9
|
+
* renders exactly one page and no navigation, however many rows exist.
|
|
10
|
+
* Mirrors HowToService.findMany.
|
|
11
|
+
*/
|
|
12
|
+
static async findMany(
|
|
13
|
+
params: {
|
|
14
|
+
search?: string;
|
|
15
|
+
fetchAll?: boolean;
|
|
16
|
+
next?: NextRef;
|
|
17
|
+
prev?: PreviousRef;
|
|
18
|
+
} = {},
|
|
19
|
+
): Promise<HandbookThreadInterface[]> {
|
|
20
|
+
const endpoint = new EndpointCreator({ endpoint: Modules.HandbookThread });
|
|
21
|
+
|
|
22
|
+
if (params.fetchAll) endpoint.addAdditionalParam("fetchAll", "true");
|
|
23
|
+
if (params.search) endpoint.addAdditionalParam("search", params.search);
|
|
24
|
+
if (Modules.HandbookThread.inclusions?.lists?.fields)
|
|
25
|
+
endpoint.limitToFields(Modules.HandbookThread.inclusions.lists.fields);
|
|
26
|
+
if (Modules.HandbookThread.inclusions?.lists?.types)
|
|
27
|
+
endpoint.limitToType(Modules.HandbookThread.inclusions.lists.types);
|
|
28
|
+
|
|
29
|
+
return this.callApi({
|
|
30
|
+
type: Modules.HandbookThread,
|
|
31
|
+
method: HttpMethod.GET,
|
|
32
|
+
endpoint: endpoint.generate(),
|
|
33
|
+
next: params.next,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns the thread with its messages side-loaded — the sidebar's select. */
|
|
38
|
+
static async findOne(params: { id: string }): Promise<HandbookThreadInterface> {
|
|
39
|
+
return this.callApi<HandbookThreadInterface>({
|
|
40
|
+
type: Modules.HandbookThread,
|
|
41
|
+
method: HttpMethod.GET,
|
|
42
|
+
endpoint: new EndpointCreator({ endpoint: Modules.HandbookThread, id: params.id }).generate(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Opens a thread with its first question. The response is the thread (title
|
|
48
|
+
* included); the messages are read back with `findOne`, because only the
|
|
49
|
+
* single-resource endpoint side-loads them.
|
|
50
|
+
*
|
|
51
|
+
* The API answers 400 when the installation has no usable AI configuration
|
|
52
|
+
* and 403 for a non-administrator; both reach the caller as a thrown error.
|
|
53
|
+
*
|
|
54
|
+
* `HandbookThreadInput.handbookPageId` scopes retrieval to one page. It rides
|
|
55
|
+
* through the model's `createJsonApi()`, exactly as `question` does — there is
|
|
56
|
+
* no `overridesJsonApiCreation` on this path.
|
|
57
|
+
*/
|
|
58
|
+
static async create(params: HandbookThreadInput): Promise<HandbookThreadInterface> {
|
|
59
|
+
return this.callApi<HandbookThreadInterface>({
|
|
60
|
+
type: Modules.HandbookThread,
|
|
61
|
+
method: HttpMethod.POST,
|
|
62
|
+
endpoint: new EndpointCreator({ endpoint: Modules.HandbookThread }).generate(),
|
|
63
|
+
input: params,
|
|
64
|
+
suppressGlobalError: true,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static async rename(params: { id: string; title: string }): Promise<void> {
|
|
69
|
+
const thread = new HandbookThread();
|
|
70
|
+
await this.callApi({
|
|
71
|
+
type: Modules.HandbookThread,
|
|
72
|
+
method: HttpMethod.PATCH,
|
|
73
|
+
endpoint: new EndpointCreator({ endpoint: Modules.HandbookThread, id: params.id }).generate(),
|
|
74
|
+
input: thread.createRenameJsonApi({ id: params.id, title: params.title }),
|
|
75
|
+
overridesJsonApiCreation: true,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static async delete(params: { id: string }): Promise<void> {
|
|
80
|
+
await this.callApi({
|
|
81
|
+
type: Modules.HandbookThread,
|
|
82
|
+
method: HttpMethod.DELETE,
|
|
83
|
+
endpoint: new EndpointCreator({ endpoint: Modules.HandbookThread, id: params.id }).generate(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { AbstractService } from "../../../../core/abstracts/AbstractService";
|
|
4
|
+
import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
5
|
+
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
6
|
+
import { HandbookThread } from "../HandbookThread";
|
|
7
|
+
import { HandbookThreadMessage } from "../HandbookThreadMessage";
|
|
8
|
+
import { HandbookThreadMessageService } from "../HandbookThreadMessageService";
|
|
9
|
+
import { HandbookThreadService } from "../HandbookThreadService";
|
|
10
|
+
|
|
11
|
+
// ModuleRegistry is backed by a globalThis symbol shared across test files in a
|
|
12
|
+
// worker, hence the register-if-absent guard (same idiom as
|
|
13
|
+
// Assistant.scope.spec.ts).
|
|
14
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
15
|
+
try {
|
|
16
|
+
ModuleRegistry.get(key as any);
|
|
17
|
+
} catch {
|
|
18
|
+
ModuleRegistry.register(key, module);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
registerIfAbsent("HandbookThread", { name: "handbookthreads", model: HandbookThread } as any);
|
|
24
|
+
registerIfAbsent("HandbookThreadMessage", {
|
|
25
|
+
name: "handbookthreadmessages",
|
|
26
|
+
model: HandbookThreadMessage,
|
|
27
|
+
} as any);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// `callApi` is a protected static on AbstractService; the services inherit it,
|
|
31
|
+
// so spying on the base class intercepts every call without touching the HTTP
|
|
32
|
+
// layer.
|
|
33
|
+
type CallApiParams = {
|
|
34
|
+
method: string;
|
|
35
|
+
endpoint: string;
|
|
36
|
+
input?: unknown;
|
|
37
|
+
next?: unknown;
|
|
38
|
+
overridesJsonApiCreation?: boolean;
|
|
39
|
+
};
|
|
40
|
+
const callApi = () => vi.spyOn(AbstractService as any, "callApi");
|
|
41
|
+
const firstCall = (spy: ReturnType<typeof callApi>): CallApiParams => spy.mock.calls[0][0] as CallApiParams;
|
|
42
|
+
|
|
43
|
+
describe("HandbookThreadService.findMany", () => {
|
|
44
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
45
|
+
|
|
46
|
+
it("forwards the pagination cursor — `useDataListRetriever` pages through it", async () => {
|
|
47
|
+
const spy = callApi().mockResolvedValue([]);
|
|
48
|
+
|
|
49
|
+
await HandbookThreadService.findMany({ next: { next: "handbookthreads?page[cursor]=42" } });
|
|
50
|
+
|
|
51
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ next: { next: "handbookthreads?page[cursor]=42" } }));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("still calls with an undefined cursor on the first page", async () => {
|
|
55
|
+
const spy = callApi().mockResolvedValue([]);
|
|
56
|
+
|
|
57
|
+
await HandbookThreadService.findMany();
|
|
58
|
+
|
|
59
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ next: undefined }));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("passes search and fetchAll through to the endpoint", async () => {
|
|
63
|
+
const spy = callApi().mockResolvedValue([]);
|
|
64
|
+
|
|
65
|
+
await HandbookThreadService.findMany({ search: "cypher", fetchAll: true });
|
|
66
|
+
|
|
67
|
+
const endpoint = firstCall(spy).endpoint;
|
|
68
|
+
expect(endpoint).toContain("handbookthreads");
|
|
69
|
+
expect(endpoint).toContain("cypher");
|
|
70
|
+
expect(endpoint).toContain("fetchAll");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("HandbookThreadService mutations", () => {
|
|
75
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
76
|
+
|
|
77
|
+
it("creates a thread from the question alone and lets the model build the envelope", async () => {
|
|
78
|
+
const spy = callApi().mockResolvedValue({});
|
|
79
|
+
|
|
80
|
+
await HandbookThreadService.create({ id: "client-1", question: " " });
|
|
81
|
+
|
|
82
|
+
const call = firstCall(spy);
|
|
83
|
+
expect(call.method).toBe("POST");
|
|
84
|
+
expect(call.input).toEqual({ id: "client-1", question: " " });
|
|
85
|
+
expect(call.overridesJsonApiCreation).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renames through the model's dedicated envelope, never a hand-built one", async () => {
|
|
89
|
+
const spy = callApi().mockResolvedValue(undefined);
|
|
90
|
+
|
|
91
|
+
await HandbookThreadService.rename({ id: "t1", title: "Filtri di azienda" });
|
|
92
|
+
|
|
93
|
+
const call = firstCall(spy);
|
|
94
|
+
expect(call.method).toBe("PATCH");
|
|
95
|
+
expect(call.input).toEqual(new HandbookThread().createRenameJsonApi({ id: "t1", title: "Filtri di azienda" }));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("HandbookThreadMessageService.ask", () => {
|
|
100
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
101
|
+
|
|
102
|
+
it("posts the question to the thread's message collection", async () => {
|
|
103
|
+
const spy = callApi().mockResolvedValue(undefined);
|
|
104
|
+
|
|
105
|
+
await HandbookThreadMessageService.ask({ id: "m1", threadId: "t1", question: "Come funziona?" });
|
|
106
|
+
|
|
107
|
+
const call = firstCall(spy);
|
|
108
|
+
expect(call.method).toBe("POST");
|
|
109
|
+
expect(call.endpoint).toContain("handbookthreads/t1/handbookthreadmessages");
|
|
110
|
+
expect(call.input).toEqual({ id: "m1", question: "Come funziona?" });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
handbookPageContent,
|
|
4
|
+
handbookPageSummary,
|
|
5
|
+
handbookPageTitle,
|
|
6
|
+
handbookSectionSummary,
|
|
7
|
+
handbookSectionTitle,
|
|
8
|
+
} from "./handbookDisplay";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The one rule the whole feature depends on: indexed in English, shown
|
|
12
|
+
* translated, and nothing but the rendered words changes.
|
|
13
|
+
*/
|
|
14
|
+
describe("handbookDisplay", () => {
|
|
15
|
+
describe("a page with a translation", () => {
|
|
16
|
+
const page = {
|
|
17
|
+
title: "Module anatomy",
|
|
18
|
+
displayTitle: "Anatomia di un modulo",
|
|
19
|
+
summary: "What a feature module contains.",
|
|
20
|
+
displaySummary: "Cosa contiene un modulo.",
|
|
21
|
+
content: "# Module anatomy\n",
|
|
22
|
+
displayContent: "# Anatomia di un modulo\n",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it("renders the translated title", () => {
|
|
26
|
+
expect(handbookPageTitle(page)).toBe("Anatomia di un modulo");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders the translated summary", () => {
|
|
30
|
+
expect(handbookPageSummary(page)).toBe("Cosa contiene un modulo.");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders the translated body", () => {
|
|
34
|
+
expect(handbookPageContent(page)).toBe("# Anatomia di un modulo\n");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("a page without a translation", () => {
|
|
39
|
+
const page = {
|
|
40
|
+
title: "Module anatomy",
|
|
41
|
+
displayTitle: undefined,
|
|
42
|
+
summary: "What a feature module contains.",
|
|
43
|
+
displaySummary: undefined,
|
|
44
|
+
content: "# Module anatomy\n",
|
|
45
|
+
displayContent: undefined,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
it("falls back to the English title", () => {
|
|
49
|
+
expect(handbookPageTitle(page)).toBe("Module anatomy");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("falls back to the English summary", () => {
|
|
53
|
+
expect(handbookPageSummary(page)).toBe("What a feature module contains.");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("falls back to the English body", () => {
|
|
57
|
+
expect(handbookPageContent(page)).toBe("# Module anatomy\n");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("leaves a summary that exists in neither language undefined", () => {
|
|
61
|
+
expect(handbookPageSummary({ summary: undefined, displaySummary: undefined })).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("sections", () => {
|
|
66
|
+
it("renders the translated title and summary when there is one", () => {
|
|
67
|
+
const section = {
|
|
68
|
+
title: "Backend",
|
|
69
|
+
displayTitle: "Backend (API)",
|
|
70
|
+
summary: "The API.",
|
|
71
|
+
displaySummary: "Le API.",
|
|
72
|
+
};
|
|
73
|
+
expect(handbookSectionTitle(section)).toBe("Backend (API)");
|
|
74
|
+
expect(handbookSectionSummary(section)).toBe("Le API.");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("falls back to English when there is none", () => {
|
|
78
|
+
const section = { title: "Backend", displayTitle: undefined, summary: "The API.", displaySummary: undefined };
|
|
79
|
+
expect(handbookSectionTitle(section)).toBe("Backend");
|
|
80
|
+
expect(handbookSectionSummary(section)).toBe("The API.");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An empty string is a translation the ingest wrote, not a missing one, so
|
|
86
|
+
* `??` is the correct operator and `||` would be wrong: the fallback must
|
|
87
|
+
* only fire on `undefined`.
|
|
88
|
+
*/
|
|
89
|
+
it("treats an empty translation as a translation", () => {
|
|
90
|
+
expect(handbookPageTitle({ title: "Module anatomy", displayTitle: "" })).toBe("");
|
|
91
|
+
});
|
|
92
|
+
});
|