@carlonicora/nextjs-jsonapi 2.0.2 → 2.1.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-DMh-zApr.d.mts +34 -0
- package/dist/AssistantInterface-DwdBzS9f.d.ts +34 -0
- package/dist/{BlockNoteEditor-4224DOTR.js → BlockNoteEditor-C5CYCS3I.js} +22 -20
- package/dist/BlockNoteEditor-C5CYCS3I.js.map +1 -0
- package/dist/{BlockNoteEditor-7NVXRTVR.mjs → BlockNoteEditor-NXDUGMP7.mjs} +7 -5
- package/dist/BlockNoteEditor-NXDUGMP7.mjs.map +1 -0
- package/dist/{BlockNoteViewer-UW5VZ5UF.js → BlockNoteViewer-6N2GQWTI.js} +4 -2
- package/dist/BlockNoteViewer-6N2GQWTI.js.map +1 -0
- package/dist/{BlockNoteViewer-OPSCTHKJ.mjs → BlockNoteViewer-IBQRJ3WM.mjs} +4 -2
- package/dist/BlockNoteViewer-IBQRJ3WM.mjs.map +1 -0
- package/dist/billing/index.js +357 -357
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-DS4IBFWA.js → chunk-6YIZX26T.js} +185 -33
- package/dist/chunk-6YIZX26T.js.map +1 -0
- package/dist/{chunk-TESVH5PV.mjs → chunk-A7AGXKZX.mjs} +1100 -738
- package/dist/chunk-A7AGXKZX.mjs.map +1 -0
- package/dist/{chunk-QS5Y6ZBF.js → chunk-I3GHIU4Y.js} +1258 -896
- package/dist/chunk-I3GHIU4Y.js.map +1 -0
- package/dist/{chunk-UXJAS6C5.js → chunk-JG7MCYCW.js} +7 -7
- package/dist/{chunk-UXJAS6C5.js.map → chunk-JG7MCYCW.js.map} +1 -1
- package/dist/{chunk-HN5ED7OF.mjs → chunk-MAWTFEY3.mjs} +2 -2
- package/dist/{chunk-BIL2WQEV.mjs → chunk-P5TMD7GK.mjs} +158 -6
- package/dist/chunk-P5TMD7GK.mjs.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +102 -11
- package/dist/components/index.d.ts +102 -11
- package/dist/components/index.js +12 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +11 -3
- package/dist/contexts/index.d.mts +29 -3
- package/dist/contexts/index.d.ts +29 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +99 -2
- package/dist/core/index.d.ts +99 -2
- package/dist/core/index.js +8 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +7 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +71 -71
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/editors/BlockNoteEditor.tsx +5 -1
- package/src/components/editors/BlockNoteEditorSuggestionMenuController.tsx +14 -2
- package/src/components/index.ts +1 -0
- package/src/components/viewers/BlockNoteViewer.tsx +5 -1
- package/src/core/index.ts +2 -0
- package/src/core/registry/ModuleRegistry.ts +1 -0
- package/src/features/assistant/components/containers/AssistantPageContainer.tsx +123 -0
- package/src/features/assistant/components/index.ts +2 -0
- package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +135 -0
- package/src/features/assistant/components/parts/AssistantThread.tsx +4 -1
- package/src/features/assistant/contexts/AssistantContext.tsx +60 -13
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +81 -0
- package/src/features/assistant/data/Assistant.ts +29 -1
- package/src/features/assistant/data/AssistantInterface.ts +15 -0
- package/src/features/assistant/data/AssistantService.ts +19 -2
- package/src/features/assistant/data/__tests__/Assistant.scope.spec.ts +64 -0
- package/src/features/assistant-action/AssistantActionModule.ts +13 -0
- package/src/features/assistant-action/components/ApprovalActionCard.tsx +127 -0
- package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +34 -0
- package/src/features/assistant-action/components/index.ts +2 -0
- package/src/features/assistant-action/data/AssistantAction.ts +58 -0
- package/src/features/assistant-action/data/AssistantActionInterface.ts +24 -0
- package/src/features/assistant-action/data/AssistantActionService.ts +51 -0
- package/src/features/assistant-action/data/__tests__/AssistantAction.spec.ts +171 -0
- package/src/features/assistant-action/data/index.ts +3 -0
- package/src/features/assistant-action/index.ts +2 -0
- package/src/features/assistant-message/components/MessageItem.tsx +57 -6
- package/src/features/assistant-message/components/MessageList.tsx +11 -2
- package/src/features/assistant-message/components/__tests__/MessageItem.mentions.spec.tsx +34 -0
- package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +30 -13
- package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
- package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +3 -1
- package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +4 -1
- package/src/features/assistant-message/components/parts/tabs/useEntityLabel.ts +35 -0
- package/src/features/assistant-message/data/AssistantMessage.ts +11 -2
- package/src/features/company/components/details/TokenStatusIndicator.tsx +19 -14
- package/src/features/index.ts +1 -0
- package/dist/AssistantInterface-B56qznuH.d.mts +0 -16
- package/dist/AssistantInterface-CHiJIYDY.d.ts +0 -16
- package/dist/BlockNoteEditor-4224DOTR.js.map +0 -1
- package/dist/BlockNoteEditor-7NVXRTVR.mjs.map +0 -1
- package/dist/BlockNoteViewer-OPSCTHKJ.mjs.map +0 -1
- package/dist/BlockNoteViewer-UW5VZ5UF.js.map +0 -1
- package/dist/chunk-BIL2WQEV.mjs.map +0 -1
- package/dist/chunk-DS4IBFWA.js.map +0 -1
- package/dist/chunk-QS5Y6ZBF.js.map +0 -1
- package/dist/chunk-TESVH5PV.mjs.map +0 -1
- /package/dist/{chunk-HN5ED7OF.mjs.map → chunk-MAWTFEY3.mjs.map} +0 -0
|
@@ -523,4 +523,85 @@ describe("AssistantContext", () => {
|
|
|
523
523
|
expect(result.current.messages.map((m) => m.content)).toEqual(["retry-me", "ok"]);
|
|
524
524
|
expect(appendMock).toHaveBeenCalledTimes(2);
|
|
525
525
|
});
|
|
526
|
+
|
|
527
|
+
it("loads only threads bound to the scope", async () => {
|
|
528
|
+
const findMany = vi.fn().mockResolvedValue([]);
|
|
529
|
+
AssistantService.findMany = findMany;
|
|
530
|
+
|
|
531
|
+
renderHook(() => useAssistantContext(), {
|
|
532
|
+
wrapper: ({ children }) => (
|
|
533
|
+
<AssistantProvider scope={{ type: "campaigns", id: "camp-1" }}>{children}</AssistantProvider>
|
|
534
|
+
),
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
await waitFor(() => expect(findMany).toHaveBeenCalledWith({ boundType: "campaigns", boundId: "camp-1" }));
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("creates the first thread bound to the scope", async () => {
|
|
541
|
+
const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
|
|
542
|
+
const create = vi.fn().mockResolvedValue(buildAssistantStub({ id: "a1" }));
|
|
543
|
+
AssistantService.create = create;
|
|
544
|
+
AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
|
|
545
|
+
|
|
546
|
+
const { result } = renderHook(() => useAssistantContext(), {
|
|
547
|
+
wrapper: ({ children }) => (
|
|
548
|
+
<AssistantProvider scope={{ type: "campaigns", id: "camp-1" }}>{children}</AssistantProvider>
|
|
549
|
+
),
|
|
550
|
+
});
|
|
551
|
+
await act(async () => {
|
|
552
|
+
await result.current.sendMessage("hello");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
expect(create).toHaveBeenCalledWith(expect.objectContaining({ boundContent: { type: "campaigns", id: "camp-1" } }));
|
|
556
|
+
replaceState.mockRestore();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("uses threadUrl for history replacement", () => {
|
|
560
|
+
const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
|
|
561
|
+
|
|
562
|
+
const { result } = renderHook(() => useAssistantContext(), {
|
|
563
|
+
wrapper: ({ children }) => (
|
|
564
|
+
<AssistantProvider
|
|
565
|
+
scope={{ type: "campaigns", id: "camp-1" }}
|
|
566
|
+
threadUrl={(id?: string) => (id ? `/campaigns/camp-1/assistant/${id}` : "/campaigns/camp-1/assistant")}
|
|
567
|
+
>
|
|
568
|
+
{children}
|
|
569
|
+
</AssistantProvider>
|
|
570
|
+
),
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
act(() => {
|
|
574
|
+
result.current.startNew();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
expect(replaceState).toHaveBeenCalledWith(null, "", "/campaigns/camp-1/assistant");
|
|
578
|
+
replaceState.mockRestore();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("forwards contentBlocks from the composer to create and appendMessage", async () => {
|
|
582
|
+
const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
|
|
583
|
+
const blocks = [{ type: "paragraph", content: [] }];
|
|
584
|
+
|
|
585
|
+
AssistantService.create = vi.fn().mockResolvedValue(buildAssistantStub({ id: "a-blocks" }));
|
|
586
|
+
AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
|
|
587
|
+
const { result } = renderHook(() => useAssistantContext(), {
|
|
588
|
+
wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
|
|
589
|
+
});
|
|
590
|
+
await act(async () => {
|
|
591
|
+
await result.current.sendMessage("hello", { contentBlocks: blocks });
|
|
592
|
+
});
|
|
593
|
+
expect(AssistantService.create).toHaveBeenCalledWith(expect.objectContaining({ contentBlocks: blocks }));
|
|
594
|
+
|
|
595
|
+
const existing = buildAssistantDehydrated({ id: "a-blocks-2" });
|
|
596
|
+
AssistantService.appendMessage = vi.fn().mockResolvedValue([]);
|
|
597
|
+
const { result: followUp } = renderHook(() => useAssistantContext(), {
|
|
598
|
+
wrapper: ({ children }) => <AssistantProvider dehydratedAssistant={existing}>{children}</AssistantProvider>,
|
|
599
|
+
});
|
|
600
|
+
await act(async () => {
|
|
601
|
+
await followUp.current.sendMessage("more", { contentBlocks: blocks });
|
|
602
|
+
});
|
|
603
|
+
expect(AssistantService.appendMessage).toHaveBeenCalledWith(expect.objectContaining({ contentBlocks: blocks }));
|
|
604
|
+
|
|
605
|
+
replaceState.mockRestore();
|
|
606
|
+
});
|
|
526
607
|
});
|
|
@@ -5,6 +5,8 @@ export class Assistant extends AbstractApiData implements AssistantInterface {
|
|
|
5
5
|
private _title?: string;
|
|
6
6
|
private _messageCount?: number;
|
|
7
7
|
private _engine?: string;
|
|
8
|
+
private _boundContentType?: string;
|
|
9
|
+
private _boundContentId?: string;
|
|
8
10
|
|
|
9
11
|
get title(): string {
|
|
10
12
|
return this._title ?? "";
|
|
@@ -18,6 +20,14 @@ export class Assistant extends AbstractApiData implements AssistantInterface {
|
|
|
18
20
|
return this._engine;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
get boundContentType(): string | undefined {
|
|
24
|
+
return this._boundContentType;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get boundContentId(): string | undefined {
|
|
28
|
+
return this._boundContentId;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
22
32
|
super.rehydrate(data);
|
|
23
33
|
this._title = data.jsonApi.attributes?.title;
|
|
@@ -25,6 +35,13 @@ export class Assistant extends AbstractApiData implements AssistantInterface {
|
|
|
25
35
|
const fromMeta = data.jsonApi.meta?.messageCount;
|
|
26
36
|
const fromAttrs = data.jsonApi.attributes?.messageCount;
|
|
27
37
|
this._messageCount = typeof fromMeta === "number" ? fromMeta : typeof fromAttrs === "number" ? fromAttrs : 0;
|
|
38
|
+
// Read the linkage pointer directly instead of `_readIncluded`: the bound
|
|
39
|
+
// resource is not side-loaded on this endpoint and only its {type, id} is
|
|
40
|
+
// needed to know which scope the thread belongs to.
|
|
41
|
+
const contentLinkage = data.jsonApi.relationships?.content?.data;
|
|
42
|
+
const boundContent = Array.isArray(contentLinkage) ? contentLinkage[0] : contentLinkage;
|
|
43
|
+
this._boundContentType = boundContent?.type;
|
|
44
|
+
this._boundContentId = boundContent?.id;
|
|
28
45
|
return this;
|
|
29
46
|
}
|
|
30
47
|
|
|
@@ -33,11 +50,22 @@ export class Assistant extends AbstractApiData implements AssistantInterface {
|
|
|
33
50
|
data: {
|
|
34
51
|
type: Modules.Assistant.name,
|
|
35
52
|
attributes: {
|
|
36
|
-
|
|
53
|
+
// narr8 convention for BlockNote-backed fields: the document travels
|
|
54
|
+
// as a JSON string in the entity's own string attribute, exactly as
|
|
55
|
+
// Npc.description does (`JSON.stringify` out, `JSON.parse` back).
|
|
56
|
+
// The server detects the shape and derives the stored markdown.
|
|
57
|
+
content: data.contentBlocks !== undefined ? JSON.stringify(data.contentBlocks) : data.firstMessage,
|
|
37
58
|
...(data.title !== undefined ? { title: data.title } : {}),
|
|
38
59
|
...(data.howToMode !== undefined ? { howToMode: data.howToMode } : {}),
|
|
39
60
|
...(data.limitToHowToId !== undefined ? { limitToHowToId: data.limitToHowToId } : {}),
|
|
40
61
|
},
|
|
62
|
+
...(data.boundContent !== undefined
|
|
63
|
+
? {
|
|
64
|
+
relationships: {
|
|
65
|
+
content: { data: { type: data.boundContent.type, id: data.boundContent.id } },
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
: {}),
|
|
41
69
|
},
|
|
42
70
|
included: [],
|
|
43
71
|
};
|
|
@@ -5,6 +5,17 @@ export type AssistantInput = {
|
|
|
5
5
|
title?: string;
|
|
6
6
|
howToMode?: boolean;
|
|
7
7
|
limitToHowToId?: string;
|
|
8
|
+
/**
|
|
9
|
+
* BlockNote document behind `firstMessage`. Sent alongside the flattened
|
|
10
|
+
* text so the server can re-derive the content and read `@`-mentions from
|
|
11
|
+
* the inline nodes; the flattened text is never trusted on its own.
|
|
12
|
+
*/
|
|
13
|
+
contentBlocks?: unknown[];
|
|
14
|
+
/**
|
|
15
|
+
* Resource the thread is bound to (e.g. a campaign). Confines every agent
|
|
16
|
+
* data-access point in the run to that resource's scope.
|
|
17
|
+
*/
|
|
18
|
+
boundContent?: { type: string; id: string };
|
|
8
19
|
};
|
|
9
20
|
|
|
10
21
|
export interface AssistantInterface extends ApiDataInterface {
|
|
@@ -12,4 +23,8 @@ export interface AssistantInterface extends ApiDataInterface {
|
|
|
12
23
|
get messageCount(): number;
|
|
13
24
|
/** Agent engine the thread runs on — `"operator"` or undefined (responder). */
|
|
14
25
|
get engine(): string | undefined;
|
|
26
|
+
/** JSON:API type of the resource this thread is bound to, when bound. */
|
|
27
|
+
get boundContentType(): string | undefined;
|
|
28
|
+
/** Id of the resource this thread is bound to, when bound. */
|
|
29
|
+
get boundContentId(): string | undefined;
|
|
15
30
|
}
|
|
@@ -12,9 +12,18 @@ export class AssistantService extends AbstractService {
|
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Lists threads. `boundType`/`boundId` narrow the list to the threads bound
|
|
17
|
+
* to one resource (e.g. a campaign), so a scoped surface never shows the
|
|
18
|
+
* user's unrelated threads.
|
|
19
|
+
*/
|
|
20
|
+
static async findMany(
|
|
21
|
+
params: { fetchAll?: boolean; boundType?: string; boundId?: string } = {},
|
|
22
|
+
): Promise<AssistantInterface[]> {
|
|
16
23
|
const endpoint = new EndpointCreator({ endpoint: Modules.Assistant });
|
|
17
24
|
if (params.fetchAll) endpoint.addAdditionalParam("fetchAll", "true");
|
|
25
|
+
if (params.boundType) endpoint.addAdditionalParam("boundType", params.boundType);
|
|
26
|
+
if (params.boundId) endpoint.addAdditionalParam("boundId", params.boundId);
|
|
18
27
|
return this.callApi({
|
|
19
28
|
type: Modules.Assistant,
|
|
20
29
|
method: HttpMethod.GET,
|
|
@@ -58,6 +67,8 @@ export class AssistantService extends AbstractService {
|
|
|
58
67
|
content: string;
|
|
59
68
|
howToMode?: boolean;
|
|
60
69
|
limitToHowToId?: string;
|
|
70
|
+
/** BlockNote document. Serialised by the model into `content`; never its own attribute. */
|
|
71
|
+
contentBlocks?: unknown[];
|
|
61
72
|
}): Promise<AssistantMessageInterface[]> {
|
|
62
73
|
const message = new AssistantMessage();
|
|
63
74
|
return this.callApi<AssistantMessageInterface[]>({
|
|
@@ -72,6 +83,7 @@ export class AssistantService extends AbstractService {
|
|
|
72
83
|
content: params.content,
|
|
73
84
|
howToMode: params.howToMode,
|
|
74
85
|
limitToHowToId: params.limitToHowToId,
|
|
86
|
+
contentBlocks: params.contentBlocks,
|
|
75
87
|
}),
|
|
76
88
|
overridesJsonApiCreation: true,
|
|
77
89
|
});
|
|
@@ -86,6 +98,8 @@ export class AssistantService extends AbstractService {
|
|
|
86
98
|
static async appendMessageOperator(params: {
|
|
87
99
|
assistantId: string;
|
|
88
100
|
content: string;
|
|
101
|
+
/** BlockNote document. Serialised by the model into `content`; never its own attribute. */
|
|
102
|
+
contentBlocks?: unknown[];
|
|
89
103
|
}): Promise<AssistantMessageInterface[]> {
|
|
90
104
|
const message = new AssistantMessage();
|
|
91
105
|
return this.callApi<AssistantMessageInterface[]>({
|
|
@@ -96,7 +110,10 @@ export class AssistantService extends AbstractService {
|
|
|
96
110
|
id: params.assistantId,
|
|
97
111
|
childEndpoint: Modules.AssistantMessage,
|
|
98
112
|
}).generate(),
|
|
99
|
-
input: message.createAppendMessageJsonApi({
|
|
113
|
+
input: message.createAppendMessageJsonApi({
|
|
114
|
+
content: params.content,
|
|
115
|
+
contentBlocks: params.contentBlocks,
|
|
116
|
+
}),
|
|
100
117
|
overridesJsonApiCreation: true,
|
|
101
118
|
});
|
|
102
119
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
3
|
+
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { Assistant } from "../Assistant";
|
|
5
|
+
|
|
6
|
+
// `createJsonApi()` reads `Modules.Assistant.name` for the JSON:API `type`, so
|
|
7
|
+
// the module must exist in the registry. ModuleRegistry is backed by a
|
|
8
|
+
// globalThis symbol shared across test files in a worker, hence the
|
|
9
|
+
// register-if-absent guard (same idiom as AssistantAction.spec.ts).
|
|
10
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
11
|
+
try {
|
|
12
|
+
ModuleRegistry.get(key as any);
|
|
13
|
+
} catch {
|
|
14
|
+
ModuleRegistry.register(key, module);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
registerIfAbsent("Assistant", { name: "assistants", model: Assistant } as any);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("Assistant.createJsonApi", () => {
|
|
23
|
+
it("emits the bound content relationship", () => {
|
|
24
|
+
const payload: any = new Assistant().createJsonApi({
|
|
25
|
+
firstMessage: "hello",
|
|
26
|
+
boundContent: { type: "campaigns", id: "camp-1" },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(payload.data.relationships.content).toEqual({ data: { type: "campaigns", id: "camp-1" } });
|
|
30
|
+
expect(payload.data.attributes.content).toBe("hello");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("serialises the rich document into `content` when the composer supplied one", () => {
|
|
34
|
+
// BlockNote-backed fields travel as a JSON string inside the entity's own
|
|
35
|
+
// string attribute (same convention as Npc.description) — there is no
|
|
36
|
+
// separate `contentBlocks` attribute on the wire.
|
|
37
|
+
const blocks = [{ type: "paragraph", content: [] }];
|
|
38
|
+
const payload: any = new Assistant().createJsonApi({ firstMessage: "hello", contentBlocks: blocks });
|
|
39
|
+
expect(payload.data.attributes.content).toBe(JSON.stringify(blocks));
|
|
40
|
+
expect(payload.data.attributes.contentBlocks).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("omits the relationships key entirely when no content is bound", () => {
|
|
44
|
+
const payload: any = new Assistant().createJsonApi({ firstMessage: "hello" });
|
|
45
|
+
expect(payload.data.relationships?.content).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("Assistant.rehydrate", () => {
|
|
50
|
+
it("exposes the bound content type and id", () => {
|
|
51
|
+
const assistant = new Assistant().rehydrate({
|
|
52
|
+
jsonApi: {
|
|
53
|
+
type: "assistants",
|
|
54
|
+
id: "a1",
|
|
55
|
+
attributes: { title: "T" },
|
|
56
|
+
relationships: { content: { data: { type: "campaigns", id: "camp-1" } } },
|
|
57
|
+
},
|
|
58
|
+
included: [],
|
|
59
|
+
} as any);
|
|
60
|
+
|
|
61
|
+
expect(assistant.boundContentType).toBe("campaigns");
|
|
62
|
+
expect(assistant.boundContentId).toBe("camp-1");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ShieldCheckIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { AssistantAction } from "./data/AssistantAction";
|
|
4
|
+
|
|
5
|
+
export const AssistantActionModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
pageUrl: "/assistant-actions",
|
|
8
|
+
name: "assistant-actions",
|
|
9
|
+
model: AssistantAction,
|
|
10
|
+
identifier: ["summary"],
|
|
11
|
+
moduleId: "e3c27517-bac0-43cb-adff-cc20c4210c12",
|
|
12
|
+
icon: ShieldCheckIcon,
|
|
13
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ShieldAlert } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { MicroLabel } from "../../../components/typography";
|
|
7
|
+
import { Badge, Button } from "../../../shadcnui";
|
|
8
|
+
import type { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
|
|
9
|
+
import type { AssistantActionStatus } from "../data/AssistantActionInterface";
|
|
10
|
+
import { AssistantActionService } from "../data/AssistantActionService";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Status pill variants. Soft `Badge` variants only — a hand-rolled
|
|
14
|
+
* `bg-*-100 text-*-800` span is never a status pill (typography rule 8).
|
|
15
|
+
*/
|
|
16
|
+
const STATUS_VARIANT: Record<AssistantActionStatus, "softBlue" | "softGreen" | "softRed" | "softGray"> = {
|
|
17
|
+
pending: "softBlue",
|
|
18
|
+
approved: "softBlue",
|
|
19
|
+
executed: "softGreen",
|
|
20
|
+
denied: "softRed",
|
|
21
|
+
failed: "softRed",
|
|
22
|
+
expired: "softGray",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Literal i18n keys (not a template literal) so the key set stays greppable
|
|
27
|
+
* and every one of them can be verified against the app's messages file.
|
|
28
|
+
*/
|
|
29
|
+
const STATUS_LABEL_KEY: Record<AssistantActionStatus, string> = {
|
|
30
|
+
pending: "features.assistant.approval.status.pending",
|
|
31
|
+
approved: "features.assistant.approval.status.approved",
|
|
32
|
+
executed: "features.assistant.approval.status.executed",
|
|
33
|
+
denied: "features.assistant.approval.status.denied",
|
|
34
|
+
failed: "features.assistant.approval.status.failed",
|
|
35
|
+
expired: "features.assistant.approval.status.expired",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
/** Id of the pending AssistantAction linked to the approval-request message. */
|
|
40
|
+
actionId: string;
|
|
41
|
+
/** Human-readable summary fallback (the approval-request message content). */
|
|
42
|
+
summary?: string;
|
|
43
|
+
/** Invoked with the resumed assistant message returned by approve/deny. */
|
|
44
|
+
onResolved?: (message: AssistantMessageInterface) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Renders an `approval-request` assistant message as an action card:
|
|
49
|
+
* the destructive-tool summary plus Approve / Deny buttons. Buttons are
|
|
50
|
+
* only actionable while the underlying AssistantAction is `pending`.
|
|
51
|
+
*
|
|
52
|
+
* NOTE: plain `<Button>` elements on purpose — never wrapped inside any
|
|
53
|
+
* Base UI trigger component (no nested buttons, no `asChild`).
|
|
54
|
+
*/
|
|
55
|
+
export function ApprovalActionCard({ actionId, summary, onResolved }: Props) {
|
|
56
|
+
const t = useTranslations();
|
|
57
|
+
const [status, setStatus] = useState<AssistantActionStatus | undefined>(undefined);
|
|
58
|
+
const [actionSummary, setActionSummary] = useState<string | undefined>(undefined);
|
|
59
|
+
const [busy, setBusy] = useState<boolean>(false);
|
|
60
|
+
const [failed, setFailed] = useState<boolean>(false);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
let cancelled = false;
|
|
64
|
+
(async () => {
|
|
65
|
+
try {
|
|
66
|
+
const action = await AssistantActionService.findOne({ id: actionId });
|
|
67
|
+
if (cancelled || !action) return;
|
|
68
|
+
setStatus(action.status);
|
|
69
|
+
setActionSummary(action.summary);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error(`ApprovalActionCard: failed to fetch assistant action ${actionId}`, error);
|
|
72
|
+
if (!cancelled) setFailed(true);
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
return () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
78
|
+
}, [actionId]);
|
|
79
|
+
|
|
80
|
+
const actionable = status === "pending" && !busy && !failed;
|
|
81
|
+
|
|
82
|
+
const resolve = async (kind: "approve" | "deny") => {
|
|
83
|
+
if (!actionable) return;
|
|
84
|
+
setBusy(true);
|
|
85
|
+
try {
|
|
86
|
+
const message =
|
|
87
|
+
kind === "approve"
|
|
88
|
+
? await AssistantActionService.approve({ id: actionId })
|
|
89
|
+
: await AssistantActionService.deny({ id: actionId });
|
|
90
|
+
setStatus(kind === "approve" ? "executed" : "denied");
|
|
91
|
+
if (message) onResolved?.(message);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(`ApprovalActionCard: failed to ${kind} assistant action ${actionId}`, error);
|
|
94
|
+
setFailed(true);
|
|
95
|
+
} finally {
|
|
96
|
+
setBusy(false);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="border-border bg-muted/30 flex flex-col gap-2 rounded-xl border p-3.5">
|
|
102
|
+
<MicroLabel className="flex items-center gap-2">
|
|
103
|
+
<ShieldAlert className="text-warning h-3.5 w-3.5" />
|
|
104
|
+
<span>{t("features.assistant.approval.title")}</span>
|
|
105
|
+
{status && status !== "pending" && (
|
|
106
|
+
<Badge variant={STATUS_VARIANT[status]} className="ml-auto tracking-normal normal-case">
|
|
107
|
+
{t(STATUS_LABEL_KEY[status])}
|
|
108
|
+
</Badge>
|
|
109
|
+
)}
|
|
110
|
+
</MicroLabel>
|
|
111
|
+
<div className="text-foreground text-sm leading-relaxed">{actionSummary ?? summary}</div>
|
|
112
|
+
{failed && (
|
|
113
|
+
<div role="alert" className="text-destructive text-xs/relaxed">
|
|
114
|
+
{t("features.assistant.approval.error")}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
<div className="flex items-center gap-2 pt-1">
|
|
118
|
+
<Button size="sm" disabled={!actionable} onClick={() => void resolve("approve")}>
|
|
119
|
+
{t("features.assistant.approval.approve")}
|
|
120
|
+
</Button>
|
|
121
|
+
<Button size="sm" variant="outline" disabled={!actionable} onClick={() => void resolve("deny")}>
|
|
122
|
+
{t("features.assistant.approval.deny")}
|
|
123
|
+
</Button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
|
|
4
|
+
import { AssistantContainer } from "../../assistant/components/containers/AssistantContainer";
|
|
5
|
+
import { useAssistantContext } from "../../assistant/contexts/AssistantContext";
|
|
6
|
+
import { ApprovalActionCard } from "./ApprovalActionCard";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AssistantContainer with the operator approval card wired in. The container
|
|
10
|
+
* renders `approval-request` messages through the `renderApprovalAction` slot,
|
|
11
|
+
* which stays a slot so the plain chat container has no dependency on the
|
|
12
|
+
* AssistantAction module. Must be rendered inside an `AssistantProvider`.
|
|
13
|
+
*/
|
|
14
|
+
export function AssistantContainerWithApprovals() {
|
|
15
|
+
const ctx = useAssistantContext();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<AssistantContainer
|
|
19
|
+
renderApprovalAction={(message: AssistantMessageInterface) =>
|
|
20
|
+
message.actionId ? (
|
|
21
|
+
<ApprovalActionCard
|
|
22
|
+
actionId={message.actionId}
|
|
23
|
+
summary={message.content}
|
|
24
|
+
onResolved={ctx.appendResolvedMessage}
|
|
25
|
+
/>
|
|
26
|
+
) : (
|
|
27
|
+
<div className="bg-muted text-foreground rounded-2xl rounded-bl-sm px-3.5 py-2.5 text-sm leading-relaxed">
|
|
28
|
+
{message.content}
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
|
|
2
|
+
import { AssistantActionInput, AssistantActionInterface, AssistantActionStatus } from "./AssistantActionInterface";
|
|
3
|
+
|
|
4
|
+
export class AssistantAction extends AbstractApiData implements AssistantActionInterface {
|
|
5
|
+
private _status?: AssistantActionStatus;
|
|
6
|
+
private _toolName?: string;
|
|
7
|
+
private _summary?: string;
|
|
8
|
+
private _resolvedAt?: Date;
|
|
9
|
+
private _expiresAt?: Date;
|
|
10
|
+
|
|
11
|
+
get status(): AssistantActionStatus {
|
|
12
|
+
// Fail-safe: a missing status must never render actionable Approve/Deny buttons, so default to the non-actionable "expired".
|
|
13
|
+
return this._status ?? "expired";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get toolName(): string {
|
|
17
|
+
return this._toolName ?? "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get summary(): string {
|
|
21
|
+
return this._summary ?? "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get resolvedAt(): Date | undefined {
|
|
25
|
+
return this._resolvedAt;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get expiresAt(): Date | undefined {
|
|
29
|
+
return this._expiresAt;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
33
|
+
super.rehydrate(data);
|
|
34
|
+
|
|
35
|
+
this._status = data.jsonApi.attributes.status;
|
|
36
|
+
this._toolName = data.jsonApi.attributes.toolName;
|
|
37
|
+
this._summary = data.jsonApi.attributes.summary;
|
|
38
|
+
this._resolvedAt = data.jsonApi.attributes.resolvedAt ? new Date(data.jsonApi.attributes.resolvedAt) : undefined;
|
|
39
|
+
this._expiresAt = data.jsonApi.attributes.expiresAt ? new Date(data.jsonApi.attributes.expiresAt) : undefined;
|
|
40
|
+
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Minimal payload (id only): the frontend never POSTs assistant actions —
|
|
46
|
+
* approve/deny are body-less POSTs handled by AssistantActionService.
|
|
47
|
+
*/
|
|
48
|
+
createJsonApi(data: AssistantActionInput) {
|
|
49
|
+
return {
|
|
50
|
+
data: {
|
|
51
|
+
type: Modules.AssistantAction.name,
|
|
52
|
+
id: data.id,
|
|
53
|
+
attributes: {},
|
|
54
|
+
},
|
|
55
|
+
included: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ApiDataInterface } from "../../../core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle of a destructive tool call the operator engine paused on.
|
|
5
|
+
* Only `pending` is actionable from the UI.
|
|
6
|
+
*/
|
|
7
|
+
export type AssistantActionStatus = "pending" | "approved" | "denied" | "expired" | "executed" | "failed";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The frontend never creates assistant actions — the operator engine does.
|
|
11
|
+
* The input therefore carries the id alone, so `createJsonApi()` can emit a
|
|
12
|
+
* well-formed (if minimal) JSON:API resource object.
|
|
13
|
+
*/
|
|
14
|
+
export type AssistantActionInput = {
|
|
15
|
+
id: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export interface AssistantActionInterface extends ApiDataInterface {
|
|
19
|
+
get status(): AssistantActionStatus;
|
|
20
|
+
get toolName(): string;
|
|
21
|
+
get summary(): string;
|
|
22
|
+
get resolvedAt(): Date | undefined;
|
|
23
|
+
get expiresAt(): Date | undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
|
|
2
|
+
import { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
|
|
3
|
+
import { AssistantActionInterface } from "./AssistantActionInterface";
|
|
4
|
+
|
|
5
|
+
export class AssistantActionService extends AbstractService {
|
|
6
|
+
/**
|
|
7
|
+
* GET single assistant action by ID
|
|
8
|
+
*/
|
|
9
|
+
static async findOne(params: { id: string }): Promise<AssistantActionInterface> {
|
|
10
|
+
return this.callApi<AssistantActionInterface>({
|
|
11
|
+
type: Modules.AssistantAction,
|
|
12
|
+
method: HttpMethod.GET,
|
|
13
|
+
endpoint: new EndpointCreator({
|
|
14
|
+
endpoint: Modules.AssistantAction,
|
|
15
|
+
id: params.id,
|
|
16
|
+
}).generate(),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* POST (body-less) approve action — resumes the paused run and
|
|
22
|
+
* returns the resumed assistant message.
|
|
23
|
+
*/
|
|
24
|
+
static async approve(params: { id: string }): Promise<AssistantMessageInterface> {
|
|
25
|
+
return this.callApi<AssistantMessageInterface>({
|
|
26
|
+
type: Modules.AssistantMessage,
|
|
27
|
+
method: HttpMethod.POST,
|
|
28
|
+
endpoint: new EndpointCreator({
|
|
29
|
+
endpoint: Modules.AssistantAction,
|
|
30
|
+
id: params.id,
|
|
31
|
+
childEndpoint: "approve",
|
|
32
|
+
}).generate(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* POST (body-less) deny action — resumes the paused run with a denial and
|
|
38
|
+
* returns the resumed assistant message.
|
|
39
|
+
*/
|
|
40
|
+
static async deny(params: { id: string }): Promise<AssistantMessageInterface> {
|
|
41
|
+
return this.callApi<AssistantMessageInterface>({
|
|
42
|
+
type: Modules.AssistantMessage,
|
|
43
|
+
method: HttpMethod.POST,
|
|
44
|
+
endpoint: new EndpointCreator({
|
|
45
|
+
endpoint: Modules.AssistantAction,
|
|
46
|
+
id: params.id,
|
|
47
|
+
childEndpoint: "deny",
|
|
48
|
+
}).generate(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|