@carlonicora/nextjs-jsonapi 3.23.0 → 3.24.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/{AssistantMessageInterface-CuaMDs2i.d.ts → AssistantMessageInterface-1Kbz8RVs.d.ts} +34 -1
- package/dist/{AssistantMessageInterface-Du3ShThp.d.mts → AssistantMessageInterface-CoxJyuFM.d.mts} +34 -1
- package/dist/{BlockNoteEditor-BUSOUPQ7.js → BlockNoteEditor-PTD2V4CH.js} +19 -19
- package/dist/{BlockNoteEditor-BUSOUPQ7.js.map → BlockNoteEditor-PTD2V4CH.js.map} +1 -1
- package/dist/{BlockNoteEditor-W42IQTGL.mjs → BlockNoteEditor-Q2WEDMKJ.mjs} +4 -4
- package/dist/{BlockNoteViewer-FQ23TZ3J.mjs → BlockNoteViewer-WO6GTDSI.mjs} +2 -2
- package/dist/{BlockNoteViewer-MCX53AR4.js → BlockNoteViewer-WOXEJ6RM.js} +3 -3
- package/dist/{BlockNoteViewer-MCX53AR4.js.map → BlockNoteViewer-WOXEJ6RM.js.map} +1 -1
- package/dist/billing/index.js +401 -401
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-O5MKEU7G.mjs → chunk-2O3SFHQ7.mjs} +36 -38
- package/dist/{chunk-O5MKEU7G.mjs.map → chunk-2O3SFHQ7.mjs.map} +1 -1
- package/dist/{chunk-FXWO5J2R.js → chunk-3D2MCVRZ.js} +7 -7
- package/dist/{chunk-FXWO5J2R.js.map → chunk-3D2MCVRZ.js.map} +1 -1
- package/dist/{chunk-VR65NRE4.js → chunk-3YJF7QY4.js} +1338 -1115
- package/dist/chunk-3YJF7QY4.js.map +1 -0
- package/dist/{chunk-GFLEFIAW.mjs → chunk-GJHKOG5L.mjs} +1399 -1176
- package/dist/chunk-GJHKOG5L.mjs.map +1 -0
- package/dist/{chunk-JZUBFAAY.mjs → chunk-HAI7G6MD.mjs} +2 -2
- package/dist/{chunk-YCHPWXHD.js → chunk-IV4DTYXN.js} +36 -38
- package/dist/chunk-IV4DTYXN.js.map +1 -0
- package/dist/client/index.d.mts +13 -2
- package/dist/client/index.d.ts +13 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +87 -28
- package/dist/components/index.d.ts +87 -28
- package/dist/components/index.js +8 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +7 -3
- package/dist/contexts/index.d.mts +11 -2
- package/dist/contexts/index.d.ts +11 -2
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +27 -5
- package/dist/core/index.d.ts +27 -5
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -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 +2 -3
- package/dist/index.d.ts +2 -3
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/{oauth.interface-CD90ycYz.d.ts → oauth.interface-BZKZAPRw.d.ts} +8 -1
- package/dist/{oauth.interface-DRE3NAKc.d.mts → oauth.interface-nVwnzxJw.d.mts} +8 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/forms/__tests__/EditorSheet.test.tsx +5 -3
- package/src/core/utils/__tests__/translateResponse.test.ts +3 -1
- package/src/features/assistant/components/containers/AssistantContainer.tsx +105 -36
- package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +163 -1
- package/src/features/assistant/components/parts/AssistantComposer.tsx +38 -15
- package/src/features/assistant/components/parts/AssistantEmptyState.tsx +5 -3
- package/src/features/assistant/components/parts/AssistantSidebar.tsx +26 -7
- package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +8 -0
- package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +7 -0
- package/src/features/assistant/contexts/AssistantContext.tsx +91 -7
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +196 -0
- package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +23 -1
- package/src/features/assistant-message/components/EntityMentionLink.tsx +28 -0
- package/src/features/assistant-message/components/__tests__/EntityMentionLink.spec.tsx +58 -0
- package/src/features/assistant-message/components/index.ts +1 -0
- package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +1 -1
- package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +2 -2
- package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +3 -2
- package/src/features/oauth/components/OAuthClientForm.tsx +21 -4
- package/src/features/oauth/components/__tests__/OAuthClientForm.spec.tsx +63 -0
- package/src/features/oauth/components/consent/OAuthCompanySelector.tsx +54 -0
- package/src/features/oauth/components/consent/OAuthConsentActions.tsx +5 -2
- package/src/features/oauth/components/consent/OAuthConsentHeader.tsx +10 -6
- package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +33 -13
- package/src/features/oauth/components/consent/OAuthScopeList.tsx +4 -1
- package/src/features/oauth/components/consent/__tests__/OAuthConsentHeader.spec.tsx +25 -0
- package/src/features/oauth/components/consent/__tests__/OAuthConsentScreen.spec.tsx +126 -0
- package/src/features/oauth/components/consent/index.ts +1 -0
- package/src/features/oauth/data/__tests__/oauth.service.spec.ts +103 -0
- package/src/features/oauth/data/oauth.service.ts +3 -13
- package/src/features/oauth/data/oauth.ts +30 -1
- package/src/features/oauth/hooks/useOAuthConsent.ts +34 -3
- package/src/features/oauth/interfaces/oauth.interface.ts +11 -25
- package/src/hooks/__tests__/useDataListRetriever.test.ts +1 -1
- package/dist/AssistantInterface-RvZzlcA_.d.ts +0 -36
- package/dist/AssistantInterface-jL56dyNc.d.mts +0 -36
- package/dist/chunk-GFLEFIAW.mjs.map +0 -1
- package/dist/chunk-VR65NRE4.js.map +0 -1
- package/dist/chunk-YCHPWXHD.js.map +0 -1
- /package/dist/{BlockNoteEditor-W42IQTGL.mjs.map → BlockNoteEditor-Q2WEDMKJ.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-FQ23TZ3J.mjs.map → BlockNoteViewer-WO6GTDSI.mjs.map} +0 -0
- /package/dist/{chunk-JZUBFAAY.mjs.map → chunk-HAI7G6MD.mjs.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, type KeyboardEvent } from "react";
|
|
3
|
+
import { useState, type KeyboardEvent, type ReactNode } from "react";
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
5
|
import { ArrowUp } from "lucide-react";
|
|
6
6
|
import { Button, Textarea } from "../../../../shadcnui";
|
|
@@ -22,6 +22,11 @@ interface Props {
|
|
|
22
22
|
* move.
|
|
23
23
|
*/
|
|
24
24
|
sendLabel?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Rendered at the bottom-left of the input box, opposite the send button
|
|
27
|
+
* (e.g. a scope picker). When omitted the box keeps its single-row layout.
|
|
28
|
+
*/
|
|
29
|
+
leading?: ReactNode;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function AssistantComposer({
|
|
@@ -31,6 +36,7 @@ export function AssistantComposer({
|
|
|
31
36
|
onValueChange,
|
|
32
37
|
placeholder,
|
|
33
38
|
sendLabel,
|
|
39
|
+
leading,
|
|
34
40
|
}: Props) {
|
|
35
41
|
const t = useTranslations();
|
|
36
42
|
const [internal, setInternal] = useState("");
|
|
@@ -53,22 +59,39 @@ export function AssistantComposer({
|
|
|
53
59
|
}
|
|
54
60
|
};
|
|
55
61
|
|
|
62
|
+
const textarea = (
|
|
63
|
+
<Textarea
|
|
64
|
+
value={value}
|
|
65
|
+
onChange={(e) => setValue(e.target.value)}
|
|
66
|
+
onKeyDown={onKeyDown}
|
|
67
|
+
placeholder={placeholder ?? t("features.assistant.composer_placeholder")}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
rows={2}
|
|
70
|
+
className="min-h-[48px] resize-none border-0 bg-transparent focus-visible:ring-0"
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
const sendButton = (
|
|
74
|
+
<Button onClick={submit} disabled={!canSend} size="sm" className="h-8">
|
|
75
|
+
<ArrowUp className="me-1 h-4 w-4" /> {sendLabel ?? t("features.assistant.send")}
|
|
76
|
+
</Button>
|
|
77
|
+
);
|
|
78
|
+
|
|
56
79
|
return (
|
|
57
80
|
<div className="flex flex-col gap-1 border-t p-4">
|
|
58
|
-
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
</
|
|
71
|
-
|
|
81
|
+
{leading ? (
|
|
82
|
+
<div className="bg-muted/30 flex flex-col gap-2 rounded-lg border p-2">
|
|
83
|
+
{textarea}
|
|
84
|
+
<div className="flex items-center justify-between gap-2">
|
|
85
|
+
<div className="min-w-0">{leading}</div>
|
|
86
|
+
{sendButton}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
) : (
|
|
90
|
+
<div className="bg-muted/30 flex items-end gap-2 rounded-lg border p-2">
|
|
91
|
+
{textarea}
|
|
92
|
+
{sendButton}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
72
95
|
<div className="text-muted-foreground text-end text-xs">{t("features.assistant.keyboard_hint")}</div>
|
|
73
96
|
</div>
|
|
74
97
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
5
|
import { Sparkles } from "lucide-react";
|
|
6
6
|
import { Label, Switch } from "../../../../shadcnui";
|
|
@@ -16,11 +16,13 @@ interface Props {
|
|
|
16
16
|
*/
|
|
17
17
|
operatorMode?: boolean;
|
|
18
18
|
onOperatorModeChange?: (value: boolean) => void;
|
|
19
|
+
/** Rendered inside the composer, bottom-left (e.g. a scope picker). */
|
|
20
|
+
composerLeading?: ReactNode;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const STARTER_KEYS = ["a", "b", "c", "d"] as const;
|
|
22
24
|
|
|
23
|
-
export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorModeChange }: Props) {
|
|
25
|
+
export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorModeChange, composerLeading }: Props) {
|
|
24
26
|
const t = useTranslations();
|
|
25
27
|
const [draft, setDraft] = useState("");
|
|
26
28
|
|
|
@@ -34,7 +36,7 @@ export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorMo
|
|
|
34
36
|
<SectionHeader>{t("features.assistant.empty_state.title")}</SectionHeader>
|
|
35
37
|
<p className="text-muted-foreground mt-1 text-sm">{t("features.assistant.empty_state.subtitle")}</p>
|
|
36
38
|
</div>
|
|
37
|
-
<AssistantComposer value={draft} onValueChange={setDraft} onSend={onSend} />
|
|
39
|
+
<AssistantComposer value={draft} onValueChange={setDraft} onSend={onSend} leading={composerLeading} />
|
|
38
40
|
{onOperatorModeChange && (
|
|
39
41
|
<div className="flex items-center justify-end gap-2">
|
|
40
42
|
<Switch
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
4
|
import { Plus } from "lucide-react";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
5
6
|
import { Button } from "../../../../shadcnui";
|
|
6
7
|
import { MicroLabel } from "../../../../components/typography";
|
|
7
8
|
import type { AssistantInterface } from "../../data/AssistantInterface";
|
|
@@ -11,7 +12,12 @@ interface Props {
|
|
|
11
12
|
threads: AssistantInterface[];
|
|
12
13
|
activeId?: string;
|
|
13
14
|
onSelect: (id: string) => void;
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Starts a new thread. Omit it when the host renders the "new" action
|
|
17
|
+
* elsewhere (the `/assistants` page puts it in the page header), and the
|
|
18
|
+
* sidebar then shows only the thread list.
|
|
19
|
+
*/
|
|
20
|
+
onNew?: () => void;
|
|
15
21
|
/**
|
|
16
22
|
* Label of the "start a new thread" button. Defaults to the assistant's own
|
|
17
23
|
* copy; a surface that is not the assistant (the handbook chat) passes its
|
|
@@ -20,9 +26,19 @@ interface Props {
|
|
|
20
26
|
newLabel?: string;
|
|
21
27
|
/** Shown when there are no threads. Same rationale as `newLabel`. */
|
|
22
28
|
emptyLabel?: string;
|
|
29
|
+
/** Optional badge rendered inside each thread button, after the title (e.g. the thread's scope). */
|
|
30
|
+
renderThreadBadge?: (thread: AssistantInterface) => ReactNode;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
export function AssistantSidebar({
|
|
33
|
+
export function AssistantSidebar({
|
|
34
|
+
threads,
|
|
35
|
+
activeId,
|
|
36
|
+
onSelect,
|
|
37
|
+
onNew,
|
|
38
|
+
newLabel,
|
|
39
|
+
emptyLabel,
|
|
40
|
+
renderThreadBadge,
|
|
41
|
+
}: Props) {
|
|
26
42
|
const t = useTranslations();
|
|
27
43
|
const groups = groupThreadsByBucket(threads);
|
|
28
44
|
|
|
@@ -42,6 +58,7 @@ export function AssistantSidebar({ threads, activeId, onSelect, onNew, newLabel,
|
|
|
42
58
|
}
|
|
43
59
|
>
|
|
44
60
|
{thread.title}
|
|
61
|
+
{renderThreadBadge?.(thread)}
|
|
45
62
|
</button>
|
|
46
63
|
))}
|
|
47
64
|
</div>
|
|
@@ -50,11 +67,13 @@ export function AssistantSidebar({ threads, activeId, onSelect, onNew, newLabel,
|
|
|
50
67
|
|
|
51
68
|
return (
|
|
52
69
|
<aside className="bg-muted/30 flex w-64 flex-col border-e">
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
{onNew && (
|
|
71
|
+
<div className="border-b p-3">
|
|
72
|
+
<Button onClick={onNew} className="w-full" size="sm">
|
|
73
|
+
<Plus className="me-1 h-4 w-4" /> {newLabel ?? t("features.assistant.new")}
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
58
77
|
<div className="flex-1 overflow-y-auto p-2">
|
|
59
78
|
{threads.length === 0 ? (
|
|
60
79
|
<div className="text-muted-foreground mt-6 text-center text-xs">
|
|
@@ -29,4 +29,12 @@ describe("AssistantComposer", () => {
|
|
|
29
29
|
const send = screen.getByRole("button", { name: /features\.assistant\.send/i });
|
|
30
30
|
expect(send).toBeDisabled();
|
|
31
31
|
});
|
|
32
|
+
|
|
33
|
+
it("renders the leading slot inside the input box, before the send button", () => {
|
|
34
|
+
render(<AssistantComposer onSend={vi.fn()} leading={<span data-testid="leading-slot" />} />);
|
|
35
|
+
const slot = screen.getByTestId("leading-slot");
|
|
36
|
+
const send = screen.getByRole("button", { name: /features\.assistant\.send/i });
|
|
37
|
+
expect(slot.compareDocumentPosition(send) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
38
|
+
expect(slot.closest(".rounded-lg")).toBe(send.closest(".rounded-lg"));
|
|
39
|
+
});
|
|
32
40
|
});
|
|
@@ -55,4 +55,11 @@ describe("AssistantSidebar", () => {
|
|
|
55
55
|
await userEvent.click(screen.getByRole("button", { name: /^T$/ }));
|
|
56
56
|
expect(onSelect).toHaveBeenCalledWith("a1");
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it("without onNew renders no new button", () => {
|
|
60
|
+
const threads = [buildAssistantStub({ id: "a1", title: "T", updatedAt: new Date("2026-04-22T09:00:00Z") })];
|
|
61
|
+
render(<AssistantSidebar threads={threads} onSelect={vi.fn()} />);
|
|
62
|
+
expect(screen.queryByRole("button", { name: /features\.assistant\.new/ })).not.toBeInTheDocument();
|
|
63
|
+
expect(screen.getByRole("button", { name: /^T$/ })).toBeInTheDocument();
|
|
64
|
+
});
|
|
58
65
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { PlusCircleIcon } from "lucide-react";
|
|
3
4
|
import { useTranslations } from "next-intl";
|
|
4
5
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
5
6
|
import { SharedProvider } from "../../../contexts";
|
|
7
|
+
import { Button } from "../../../shadcnui";
|
|
6
8
|
import { useSocketContext } from "../../../contexts/SocketContext";
|
|
7
9
|
import { BreadcrumbItemData, JsonApiHydratedDataInterface, Modules, rehydrate, rehydrateList } from "../../../core";
|
|
8
10
|
import { usePageUrlGenerator } from "../../../hooks";
|
|
@@ -40,6 +42,10 @@ interface AssistantContextValue {
|
|
|
40
42
|
setOperatorMode(value: boolean): void;
|
|
41
43
|
/** Appends a message returned outside the send flow (e.g. an approve/deny resume). */
|
|
42
44
|
appendResolvedMessage(message: AssistantMessageInterface): void;
|
|
45
|
+
/** The scope a NEW thread will bind to. Starts from the provider's `scope` prop. */
|
|
46
|
+
scope?: { type: string; id: string };
|
|
47
|
+
/** Changes the scope a new thread binds to. Ignored once a thread is active. */
|
|
48
|
+
setScope(scope?: { type: string; id: string }): void;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
const AssistantContext = createContext<AssistantContextValue | undefined>(undefined);
|
|
@@ -141,6 +147,9 @@ export function AssistantProvider({
|
|
|
141
147
|
const [operatorMode, setOperatorMode] = useState<boolean>(
|
|
142
148
|
() => dehydratedAssistant?.jsonApi?.attributes?.engine === "operator",
|
|
143
149
|
);
|
|
150
|
+
// The scope the next created thread binds to. Starts from the `scope` prop
|
|
151
|
+
// and can be changed by the host (e.g. a scope picker) until a thread exists.
|
|
152
|
+
const [pendingScope, setPendingScope] = useState<{ type: string; id: string } | undefined>(scope);
|
|
144
153
|
const { socket } = useSocketContext();
|
|
145
154
|
|
|
146
155
|
const sendMessage = useCallback(
|
|
@@ -172,6 +181,35 @@ export function AssistantProvider({
|
|
|
172
181
|
};
|
|
173
182
|
socket?.on("assistant:status", handler);
|
|
174
183
|
|
|
184
|
+
// A new thread is named and persisted before its answer runs; the server
|
|
185
|
+
// announces it on `assistant:created` so the thread list and the URL
|
|
186
|
+
// update immediately instead of when the long create request returns.
|
|
187
|
+
let createdHandled = false;
|
|
188
|
+
const createdHandler = (payload: {
|
|
189
|
+
assistant?: { data?: JsonApiHydratedDataInterface["jsonApi"]; included?: any[] };
|
|
190
|
+
}) => {
|
|
191
|
+
if (createdHandled) return;
|
|
192
|
+
const data = payload?.assistant?.data;
|
|
193
|
+
if (!data || typeof data !== "object" || typeof data.id !== "string" || !data.id) return;
|
|
194
|
+
let thread: AssistantInterface;
|
|
195
|
+
try {
|
|
196
|
+
thread = rehydrate<AssistantInterface>(Modules.Assistant, {
|
|
197
|
+
jsonApi: data,
|
|
198
|
+
included: payload.assistant?.included ?? [],
|
|
199
|
+
});
|
|
200
|
+
} catch {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!thread?.id) return;
|
|
204
|
+
createdHandled = true;
|
|
205
|
+
setAssistant(thread);
|
|
206
|
+
setThreads((prev) => [thread, ...prev.filter((row) => row.id !== thread.id)]);
|
|
207
|
+
if (manageUrl && typeof window !== "undefined") {
|
|
208
|
+
window.history.replaceState(null, "", resolveThreadUrl(thread.id));
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
if (!assistant) socket?.on("assistant:created", createdHandler);
|
|
212
|
+
|
|
175
213
|
try {
|
|
176
214
|
if (!assistant) {
|
|
177
215
|
const input = {
|
|
@@ -181,7 +219,7 @@ export function AssistantProvider({
|
|
|
181
219
|
handbookMode: opts?.handbookMode ?? retrievalMode?.handbookMode,
|
|
182
220
|
limitToHandbookPageId: opts?.limitToHandbookPageId ?? retrievalMode?.limitToHandbookPageId,
|
|
183
221
|
contentBlocks: opts?.contentBlocks,
|
|
184
|
-
boundContent:
|
|
222
|
+
boundContent: pendingScope,
|
|
185
223
|
};
|
|
186
224
|
const created = operatorMode
|
|
187
225
|
? await AssistantService.createOperator(input)
|
|
@@ -189,7 +227,11 @@ export function AssistantProvider({
|
|
|
189
227
|
const msgs = await AssistantMessageService.findByAssistant({ assistantId: created.id });
|
|
190
228
|
setAssistant(created);
|
|
191
229
|
setMessages(msgs);
|
|
192
|
-
setThreads((prev) =>
|
|
230
|
+
setThreads((prev) =>
|
|
231
|
+
prev.some((row) => row.id === created.id)
|
|
232
|
+
? prev.map((row) => (row.id === created.id ? created : row))
|
|
233
|
+
: [created, ...prev],
|
|
234
|
+
);
|
|
193
235
|
if (manageUrl && typeof window !== "undefined") {
|
|
194
236
|
window.history.replaceState(null, "", resolveThreadUrl(created.id));
|
|
195
237
|
}
|
|
@@ -219,11 +261,12 @@ export function AssistantProvider({
|
|
|
219
261
|
});
|
|
220
262
|
} finally {
|
|
221
263
|
socket?.off("assistant:status", handler);
|
|
264
|
+
if (!assistant) socket?.off("assistant:created", createdHandler);
|
|
222
265
|
setSending(false);
|
|
223
266
|
setStatus(undefined);
|
|
224
267
|
}
|
|
225
268
|
},
|
|
226
|
-
[assistant, messages, socket, operatorMode,
|
|
269
|
+
[assistant, messages, socket, operatorMode, pendingScope, retrievalMode, manageUrl, resolveThreadUrl],
|
|
227
270
|
);
|
|
228
271
|
|
|
229
272
|
const appendResolvedMessage = useCallback((message: AssistantMessageInterface) => {
|
|
@@ -278,10 +321,20 @@ export function AssistantProvider({
|
|
|
278
321
|
setMessages([]);
|
|
279
322
|
setFailedMessageIds(new Set());
|
|
280
323
|
setOperatorMode(false);
|
|
324
|
+
setPendingScope(scope);
|
|
281
325
|
if (manageUrl && typeof window !== "undefined") {
|
|
282
326
|
window.history.replaceState(null, "", resolveThreadUrl());
|
|
283
327
|
}
|
|
284
|
-
}, [manageUrl, resolveThreadUrl]);
|
|
328
|
+
}, [manageUrl, resolveThreadUrl, scope]);
|
|
329
|
+
|
|
330
|
+
const setScope = useCallback(
|
|
331
|
+
(next?: { type: string; id: string }) => {
|
|
332
|
+
// An existing thread is already bound; its scope cannot change.
|
|
333
|
+
if (assistant) return;
|
|
334
|
+
setPendingScope(next);
|
|
335
|
+
},
|
|
336
|
+
[assistant],
|
|
337
|
+
);
|
|
285
338
|
|
|
286
339
|
const deleteThread = useCallback(async (id: string) => {
|
|
287
340
|
await AssistantService.delete({ id });
|
|
@@ -329,6 +382,8 @@ export function AssistantProvider({
|
|
|
329
382
|
operatorMode,
|
|
330
383
|
setOperatorMode,
|
|
331
384
|
appendResolvedMessage,
|
|
385
|
+
scope: pendingScope,
|
|
386
|
+
setScope,
|
|
332
387
|
}),
|
|
333
388
|
[
|
|
334
389
|
assistant,
|
|
@@ -346,6 +401,8 @@ export function AssistantProvider({
|
|
|
346
401
|
deleteThread,
|
|
347
402
|
operatorMode,
|
|
348
403
|
appendResolvedMessage,
|
|
404
|
+
pendingScope,
|
|
405
|
+
setScope,
|
|
349
406
|
],
|
|
350
407
|
);
|
|
351
408
|
|
|
@@ -360,9 +417,36 @@ export function AssistantProvider({
|
|
|
360
417
|
|
|
361
418
|
// `entities.tasks` was a copy-paste from another feature and rendered
|
|
362
419
|
// "Tasks" as the page heading of every assistant page.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
420
|
+
// Shaped like the conversations page heading: the open thread's title as the
|
|
421
|
+
// element, and the new-thread action in the shared header so it stays
|
|
422
|
+
// reachable when the thread-list panel is collapsed.
|
|
423
|
+
const title = useMemo(() => {
|
|
424
|
+
const response: { type: string; element?: string; functions?: React.ReactNode } = {
|
|
425
|
+
type: titleOverride ?? t("entities.assistants", { count: assistant ? 1 : 2 }),
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
if (assistant?.title) {
|
|
429
|
+
response.element = assistant.title.length > 25 ? `${assistant.title.substring(0, 25)}...` : assistant.title;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
response.functions = [
|
|
433
|
+
<Button
|
|
434
|
+
key="new-assistant"
|
|
435
|
+
data-testid="assistant-new-button"
|
|
436
|
+
size="sm"
|
|
437
|
+
variant={`outline`}
|
|
438
|
+
onClick={(e) => {
|
|
439
|
+
e.preventDefault();
|
|
440
|
+
startNew();
|
|
441
|
+
}}
|
|
442
|
+
>
|
|
443
|
+
<PlusCircleIcon className="me-2 h-3.5 w-3.5" />
|
|
444
|
+
{t("features.assistant.new")}
|
|
445
|
+
</Button>,
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
return response;
|
|
449
|
+
}, [assistant, startNew, t, titleOverride]);
|
|
366
450
|
|
|
367
451
|
return (
|
|
368
452
|
<AssistantContext.Provider value={value}>
|
|
@@ -4,6 +4,8 @@ import { AssistantProvider, useAssistantContext } from "../AssistantContext";
|
|
|
4
4
|
import { AssistantService } from "../../data/AssistantService";
|
|
5
5
|
import { AssistantMessageService } from "../../../assistant-message/data/AssistantMessageService";
|
|
6
6
|
import { useSocketContext } from "../../../../contexts/SocketContext";
|
|
7
|
+
import { useSharedContext } from "../../../../contexts";
|
|
8
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
7
9
|
import type { JsonApiHydratedDataInterface } from "../../../../core";
|
|
8
10
|
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
9
11
|
import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
|
|
@@ -636,4 +638,198 @@ describe("AssistantContext", () => {
|
|
|
636
638
|
|
|
637
639
|
replaceState.mockRestore();
|
|
638
640
|
});
|
|
641
|
+
|
|
642
|
+
it("setScope binds the first message's thread", async () => {
|
|
643
|
+
const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
|
|
644
|
+
const create = vi.fn().mockResolvedValue(buildAssistantStub({ id: "a-scoped" }));
|
|
645
|
+
AssistantService.create = create;
|
|
646
|
+
AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
|
|
647
|
+
|
|
648
|
+
const { result } = renderHook(() => useAssistantContext(), {
|
|
649
|
+
wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
|
|
650
|
+
});
|
|
651
|
+
expect(result.current.scope).toBeUndefined();
|
|
652
|
+
|
|
653
|
+
act(() => {
|
|
654
|
+
result.current.setScope({ type: "proceedings", id: "p1" });
|
|
655
|
+
});
|
|
656
|
+
expect(result.current.scope).toEqual({ type: "proceedings", id: "p1" });
|
|
657
|
+
|
|
658
|
+
await act(async () => {
|
|
659
|
+
await result.current.sendMessage("hello");
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
expect(create).toHaveBeenCalledWith(expect.objectContaining({ boundContent: { type: "proceedings", id: "p1" } }));
|
|
663
|
+
|
|
664
|
+
// Once a thread exists its scope is fixed.
|
|
665
|
+
act(() => {
|
|
666
|
+
result.current.setScope({ type: "proceedings", id: "p2" });
|
|
667
|
+
});
|
|
668
|
+
expect(result.current.scope).toEqual({ type: "proceedings", id: "p1" });
|
|
669
|
+
replaceState.mockRestore();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("the provider scope prop still filters the thread list", async () => {
|
|
673
|
+
const findMany = vi.fn().mockResolvedValue([]);
|
|
674
|
+
AssistantService.findMany = findMany;
|
|
675
|
+
|
|
676
|
+
const scope = { type: "campaigns", id: "camp-1" };
|
|
677
|
+
const { result } = renderHook(() => useAssistantContext(), {
|
|
678
|
+
wrapper: ({ children }) => <AssistantProvider scope={scope}>{children}</AssistantProvider>,
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
await waitFor(() => expect(findMany).toHaveBeenCalledWith({ boundType: "campaigns", boundId: "camp-1" }));
|
|
682
|
+
expect(result.current.scope).toEqual(scope);
|
|
683
|
+
|
|
684
|
+
act(() => {
|
|
685
|
+
result.current.setScope({ type: "proceedings", id: "p1" });
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
expect(findMany).not.toHaveBeenCalledWith({ boundType: "proceedings", boundId: "p1" });
|
|
689
|
+
expect(findMany).toHaveBeenLastCalledWith({ boundType: "campaigns", boundId: "camp-1" });
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("page heading: thread title as element and a new-thread button that resets the thread", async () => {
|
|
693
|
+
const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
|
|
694
|
+
const existing = buildAssistantDehydrated({ id: "a-h", title: "A thread title longer than 25 chars" });
|
|
695
|
+
let ctx: ReturnType<typeof useAssistantContext> | undefined;
|
|
696
|
+
function Probe() {
|
|
697
|
+
ctx = useAssistantContext();
|
|
698
|
+
const { title } = useSharedContext();
|
|
699
|
+
return (
|
|
700
|
+
<div>
|
|
701
|
+
<span data-testid="element">{title.element}</span>
|
|
702
|
+
{title.functions}
|
|
703
|
+
</div>
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
render(
|
|
707
|
+
<AssistantProvider dehydratedAssistant={existing}>
|
|
708
|
+
<Probe />
|
|
709
|
+
</AssistantProvider>,
|
|
710
|
+
);
|
|
711
|
+
expect(screen.getByTestId("element")).toHaveTextContent("A thread title longer tha...");
|
|
712
|
+
fireEvent.click(screen.getByTestId("assistant-new-button"));
|
|
713
|
+
await waitFor(() => expect(ctx?.assistant).toBeUndefined());
|
|
714
|
+
expect(screen.getByTestId("element")).toBeEmptyDOMElement();
|
|
715
|
+
expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
describe("assistant:created", () => {
|
|
719
|
+
function fakeSocket() {
|
|
720
|
+
const handlers: Record<string, (payload: any) => void> = {};
|
|
721
|
+
const socket = {
|
|
722
|
+
on: vi.fn((evt: string, h: any) => {
|
|
723
|
+
handlers[evt] = h;
|
|
724
|
+
}),
|
|
725
|
+
off: vi.fn((evt: string) => {
|
|
726
|
+
delete handlers[evt];
|
|
727
|
+
}),
|
|
728
|
+
};
|
|
729
|
+
vi.mocked(useSocketContext).mockReturnValue({ socket, isConnected: true } as any);
|
|
730
|
+
return { socket, handlers };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function createdEvent(id: string, title: string) {
|
|
734
|
+
return {
|
|
735
|
+
assistant: {
|
|
736
|
+
data: { type: "assistants", id, attributes: { title, messageCount: 0 } },
|
|
737
|
+
included: [],
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
it("names the thread, lists it and swaps the URL before the create request returns", async () => {
|
|
743
|
+
const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
|
|
744
|
+
const { socket, handlers } = fakeSocket();
|
|
745
|
+
let resolveCreate!: (value: unknown) => void;
|
|
746
|
+
AssistantService.create = vi.fn().mockImplementation(() => new Promise((resolve) => (resolveCreate = resolve)));
|
|
747
|
+
AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([buildMessageStub({ role: "user" })]);
|
|
748
|
+
|
|
749
|
+
const { result } = renderHook(() => useAssistantContext(), {
|
|
750
|
+
wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
|
|
751
|
+
});
|
|
752
|
+
await waitFor(() => expect(result.current.threadsLoading).toBe(false));
|
|
753
|
+
|
|
754
|
+
let sendPromise: Promise<void>;
|
|
755
|
+
await act(async () => {
|
|
756
|
+
sendPromise = result.current.sendMessage("first question");
|
|
757
|
+
await Promise.resolve();
|
|
758
|
+
});
|
|
759
|
+
expect(socket.on).toHaveBeenCalledWith("assistant:created", expect.any(Function));
|
|
760
|
+
|
|
761
|
+
await act(async () => {
|
|
762
|
+
handlers["assistant:created"]({ assistant: { data: null } });
|
|
763
|
+
handlers["assistant:created"](createdEvent("a-new", "Named thread"));
|
|
764
|
+
handlers["assistant:created"](createdEvent("a-other", "Second event"));
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
expect(result.current.assistant?.id).toBe("a-new");
|
|
768
|
+
expect(result.current.assistant?.title).toBe("Named thread");
|
|
769
|
+
expect(result.current.threads.map((t) => t.id)).toEqual(["a-new"]);
|
|
770
|
+
expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants/a-new");
|
|
771
|
+
expect(result.current.sending).toBe(true);
|
|
772
|
+
expect(result.current.messages.map((m) => m.content)).toEqual(["first question"]);
|
|
773
|
+
|
|
774
|
+
await act(async () => {
|
|
775
|
+
resolveCreate(buildAssistantStub({ id: "a-new", title: "Named thread" }));
|
|
776
|
+
await sendPromise!;
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
expect(result.current.threads.filter((t) => t.id === "a-new")).toHaveLength(1);
|
|
780
|
+
expect(result.current.threads).toHaveLength(1);
|
|
781
|
+
expect(result.current.assistant?.id).toBe("a-new");
|
|
782
|
+
expect(socket.off).toHaveBeenCalledWith("assistant:created", expect.any(Function));
|
|
783
|
+
replaceState.mockRestore();
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("does not touch the URL from the event when manageUrl is false", async () => {
|
|
787
|
+
const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
|
|
788
|
+
const { handlers } = fakeSocket();
|
|
789
|
+
let resolveCreate!: (value: unknown) => void;
|
|
790
|
+
AssistantService.create = vi.fn().mockImplementation(() => new Promise((resolve) => (resolveCreate = resolve)));
|
|
791
|
+
AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
|
|
792
|
+
|
|
793
|
+
const { result } = renderHook(() => useAssistantContext(), {
|
|
794
|
+
wrapper: ({ children }) => <AssistantProvider manageUrl={false}>{children}</AssistantProvider>,
|
|
795
|
+
});
|
|
796
|
+
await waitFor(() => expect(result.current.threadsLoading).toBe(false));
|
|
797
|
+
|
|
798
|
+
let sendPromise: Promise<void>;
|
|
799
|
+
await act(async () => {
|
|
800
|
+
sendPromise = result.current.sendMessage("hello");
|
|
801
|
+
await Promise.resolve();
|
|
802
|
+
});
|
|
803
|
+
await act(async () => {
|
|
804
|
+
handlers["assistant:created"](createdEvent("a-sheet", "Sheet thread"));
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
expect(result.current.assistant?.id).toBe("a-sheet");
|
|
808
|
+
expect(result.current.threads.map((t) => t.id)).toEqual(["a-sheet"]);
|
|
809
|
+
expect(replaceState).not.toHaveBeenCalled();
|
|
810
|
+
|
|
811
|
+
await act(async () => {
|
|
812
|
+
resolveCreate(buildAssistantStub({ id: "a-sheet", title: "Sheet thread" }));
|
|
813
|
+
await sendPromise!;
|
|
814
|
+
});
|
|
815
|
+
expect(replaceState).not.toHaveBeenCalled();
|
|
816
|
+
replaceState.mockRestore();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("does not subscribe to assistant:created when appending to an existing thread", async () => {
|
|
820
|
+
const { socket } = fakeSocket();
|
|
821
|
+
AssistantService.appendMessage = vi.fn().mockResolvedValue([]);
|
|
822
|
+
const { result } = renderHook(() => useAssistantContext(), {
|
|
823
|
+
wrapper: ({ children }) => (
|
|
824
|
+
<AssistantProvider dehydratedAssistant={buildAssistantDehydrated({ id: "a-ex" })}>
|
|
825
|
+
{children}
|
|
826
|
+
</AssistantProvider>
|
|
827
|
+
),
|
|
828
|
+
});
|
|
829
|
+
await act(async () => {
|
|
830
|
+
await result.current.sendMessage("follow-up");
|
|
831
|
+
});
|
|
832
|
+
expect(socket.on).not.toHaveBeenCalledWith("assistant:created", expect.any(Function));
|
|
833
|
+
});
|
|
834
|
+
});
|
|
639
835
|
});
|
|
@@ -1,21 +1,43 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type { MentionRenderer } from "../../assistant-message/components/MessageItem";
|
|
3
5
|
import type { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
|
|
4
6
|
import { AssistantContainer } from "../../assistant/components/containers/AssistantContainer";
|
|
7
|
+
import type { AssistantInterface } from "../../assistant/data/AssistantInterface";
|
|
5
8
|
import { useAssistantContext } from "../../assistant/contexts/AssistantContext";
|
|
6
9
|
import { ApprovalActionCard } from "./ApprovalActionCard";
|
|
7
10
|
|
|
11
|
+
interface Props {
|
|
12
|
+
renderMention?: MentionRenderer;
|
|
13
|
+
embedded?: boolean;
|
|
14
|
+
renderScopePicker?: () => ReactNode;
|
|
15
|
+
renderThreadBadge?: (thread: AssistantInterface) => ReactNode;
|
|
16
|
+
showOperatorToggle?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
/**
|
|
9
20
|
* AssistantContainer with the operator approval card wired in. The container
|
|
10
21
|
* renders `approval-request` messages through the `renderApprovalAction` slot,
|
|
11
22
|
* which stays a slot so the plain chat container has no dependency on the
|
|
12
23
|
* AssistantAction module. Must be rendered inside an `AssistantProvider`.
|
|
13
24
|
*/
|
|
14
|
-
export function AssistantContainerWithApprovals(
|
|
25
|
+
export function AssistantContainerWithApprovals({
|
|
26
|
+
renderMention,
|
|
27
|
+
embedded,
|
|
28
|
+
renderScopePicker,
|
|
29
|
+
renderThreadBadge,
|
|
30
|
+
showOperatorToggle,
|
|
31
|
+
}: Props = {}) {
|
|
15
32
|
const ctx = useAssistantContext();
|
|
16
33
|
|
|
17
34
|
return (
|
|
18
35
|
<AssistantContainer
|
|
36
|
+
renderMention={renderMention}
|
|
37
|
+
embedded={embedded}
|
|
38
|
+
renderScopePicker={renderScopePicker}
|
|
39
|
+
renderThreadBadge={renderThreadBadge}
|
|
40
|
+
showOperatorToggle={showOperatorToggle}
|
|
19
41
|
renderApprovalAction={(message: AssistantMessageInterface) =>
|
|
20
42
|
message.actionId ? (
|
|
21
43
|
<ApprovalActionCard
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { usePageUrlGenerator } from "../../../hooks";
|
|
5
|
+
import { Link } from "../../../shadcnui";
|
|
6
|
+
import type { MentionRenderer } from "./MessageItem";
|
|
7
|
+
|
|
8
|
+
function EntityMentionLinkView({ type, id, alias }: { type: string; id: string; alias: string }) {
|
|
9
|
+
const generate = usePageUrlGenerator();
|
|
10
|
+
|
|
11
|
+
let module;
|
|
12
|
+
try {
|
|
13
|
+
module = ModuleRegistry.findByName(type);
|
|
14
|
+
} catch {
|
|
15
|
+
return <>{alias}</>;
|
|
16
|
+
}
|
|
17
|
+
if (!module?.pageUrl) return <>{alias}</>;
|
|
18
|
+
|
|
19
|
+
return <Link href={generate({ page: module, id })}>{alias}</Link>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Default mention renderer for assistant answers: a `mention://<type>/<id>`
|
|
24
|
+
* link becomes a link to the entity's page when `<type>` is a registered
|
|
25
|
+
* module with a `pageUrl`, and plain text otherwise. Rendered as a component
|
|
26
|
+
* so the URL hook runs inside React's component tree.
|
|
27
|
+
*/
|
|
28
|
+
export const EntityMentionLink: MentionRenderer = (p) => <EntityMentionLinkView {...p} />;
|