@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AbstractService } from "../../../../core/abstracts/AbstractService";
|
|
3
|
+
import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
4
|
+
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
5
|
+
import { AssistantAction } from "../AssistantAction";
|
|
6
|
+
import { AssistantActionService } from "../AssistantActionService";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Setup / teardown — the services resolve their endpoints through the real
|
|
10
|
+
// ModuleRegistry, so the two modules the feature touches must be registered.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const assistantActionModule: ApiRequestDataTypeInterface = {
|
|
13
|
+
name: "assistant-actions",
|
|
14
|
+
model: AssistantAction,
|
|
15
|
+
} as any;
|
|
16
|
+
|
|
17
|
+
const assistantMessageModule: ApiRequestDataTypeInterface = {
|
|
18
|
+
name: "assistant-messages",
|
|
19
|
+
model: class {},
|
|
20
|
+
} as any;
|
|
21
|
+
|
|
22
|
+
// `callApi` is protected static on AbstractService; spying on it there covers
|
|
23
|
+
// every subclass through the static prototype chain.
|
|
24
|
+
let callApiMock: ReturnType<typeof vi.fn>;
|
|
25
|
+
|
|
26
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
27
|
+
try {
|
|
28
|
+
ModuleRegistry.get(key as any);
|
|
29
|
+
} catch {
|
|
30
|
+
ModuleRegistry.register(key, module);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
registerIfAbsent("AssistantAction", assistantActionModule);
|
|
36
|
+
registerIfAbsent("AssistantMessage", assistantMessageModule);
|
|
37
|
+
callApiMock = vi.spyOn(AbstractService as any, "callApi") as unknown as ReturnType<typeof vi.fn>;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
vi.restoreAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function makeHydratedData(overrides: Record<string, any> = {}) {
|
|
45
|
+
return {
|
|
46
|
+
jsonApi: {
|
|
47
|
+
id: "action-1",
|
|
48
|
+
type: "assistant-actions",
|
|
49
|
+
attributes: {
|
|
50
|
+
status: "pending",
|
|
51
|
+
toolName: "createNpc",
|
|
52
|
+
toolArgs: '{"name":"A"}',
|
|
53
|
+
summary: "Create a new record",
|
|
54
|
+
threadId: "thread-1",
|
|
55
|
+
userModuleIds: ["module-1"],
|
|
56
|
+
expiresAt: "2026-06-10T12:00:00.000Z",
|
|
57
|
+
...overrides,
|
|
58
|
+
},
|
|
59
|
+
meta: {},
|
|
60
|
+
relationships: {},
|
|
61
|
+
},
|
|
62
|
+
included: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("AssistantAction model", () => {
|
|
67
|
+
it("rehydrates status, toolName and summary", () => {
|
|
68
|
+
const action = new AssistantAction();
|
|
69
|
+
action.rehydrate(makeHydratedData() as any);
|
|
70
|
+
|
|
71
|
+
expect(action.id).toBe("action-1");
|
|
72
|
+
expect(action.status).toBe("pending");
|
|
73
|
+
expect(action.toolName).toBe("createNpc");
|
|
74
|
+
expect(action.summary).toBe("Create a new record");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("parses expiresAt into a Date", () => {
|
|
78
|
+
const action = new AssistantAction();
|
|
79
|
+
action.rehydrate(makeHydratedData() as any);
|
|
80
|
+
|
|
81
|
+
expect(action.expiresAt).toBeInstanceOf(Date);
|
|
82
|
+
expect(action.expiresAt).toEqual(new Date("2026-06-10T12:00:00.000Z"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("parses resolvedAt into a Date when present", () => {
|
|
86
|
+
const action = new AssistantAction();
|
|
87
|
+
action.rehydrate(makeHydratedData({ status: "approved", resolvedAt: "2026-06-10T11:30:00.000Z" }) as any);
|
|
88
|
+
|
|
89
|
+
expect(action.status).toBe("approved");
|
|
90
|
+
expect(action.resolvedAt).toBeInstanceOf(Date);
|
|
91
|
+
expect(action.resolvedAt).toEqual(new Date("2026-06-10T11:30:00.000Z"));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("leaves resolvedAt undefined when absent", () => {
|
|
95
|
+
const action = new AssistantAction();
|
|
96
|
+
action.rehydrate(makeHydratedData() as any);
|
|
97
|
+
|
|
98
|
+
expect(action.resolvedAt).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns defaults for missing attributes", () => {
|
|
102
|
+
const action = new AssistantAction();
|
|
103
|
+
action.rehydrate({
|
|
104
|
+
jsonApi: {
|
|
105
|
+
id: "action-1",
|
|
106
|
+
type: "assistant-actions",
|
|
107
|
+
attributes: {},
|
|
108
|
+
meta: {},
|
|
109
|
+
relationships: {},
|
|
110
|
+
},
|
|
111
|
+
included: [],
|
|
112
|
+
} as any);
|
|
113
|
+
|
|
114
|
+
// Missing status defaults to the fail-safe, non-actionable "expired" — never the actionable "pending".
|
|
115
|
+
expect(action.status).toBe("expired");
|
|
116
|
+
expect(action.toolName).toBe("");
|
|
117
|
+
expect(action.summary).toBe("");
|
|
118
|
+
expect(action.resolvedAt).toBeUndefined();
|
|
119
|
+
expect(action.expiresAt).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("creates a minimal JSON:API payload (id only)", () => {
|
|
123
|
+
const action = new AssistantAction();
|
|
124
|
+
const payload = action.createJsonApi({ id: "action-1" });
|
|
125
|
+
|
|
126
|
+
expect(payload.data.type).toBe("assistant-actions");
|
|
127
|
+
expect(payload.data.id).toBe("action-1");
|
|
128
|
+
expect(payload.data.attributes).toEqual({});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("AssistantActionService", () => {
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
// mockClear (not mockReset): on a spy, mockReset would restore the real
|
|
135
|
+
// callApi and the assertions would fire a network request.
|
|
136
|
+
callApiMock.mockClear();
|
|
137
|
+
callApiMock.mockResolvedValue({});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("findOne GETs the action typed as Modules.AssistantAction", async () => {
|
|
141
|
+
await AssistantActionService.findOne({ id: "action-1" });
|
|
142
|
+
|
|
143
|
+
expect(callApiMock).toHaveBeenCalledTimes(1);
|
|
144
|
+
const call = callApiMock.mock.calls[0][0];
|
|
145
|
+
expect(call.type.name).toBe("assistant-actions");
|
|
146
|
+
expect(call.method).toBe("GET");
|
|
147
|
+
expect(call.endpoint).toBe("assistant-actions/action-1");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("approve POSTs body-less to /approve typed as Modules.AssistantMessage", async () => {
|
|
151
|
+
await AssistantActionService.approve({ id: "action-1" });
|
|
152
|
+
|
|
153
|
+
expect(callApiMock).toHaveBeenCalledTimes(1);
|
|
154
|
+
const call = callApiMock.mock.calls[0][0];
|
|
155
|
+
expect(call.type.name).toBe("assistant-messages");
|
|
156
|
+
expect(call.method).toBe("POST");
|
|
157
|
+
expect(call.endpoint).toBe("assistant-actions/action-1/approve");
|
|
158
|
+
expect(call.input).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("deny POSTs body-less to /deny typed as Modules.AssistantMessage", async () => {
|
|
162
|
+
await AssistantActionService.deny({ id: "action-1" });
|
|
163
|
+
|
|
164
|
+
expect(callApiMock).toHaveBeenCalledTimes(1);
|
|
165
|
+
const call = callApiMock.mock.calls[0][0];
|
|
166
|
+
expect(call.type.name).toBe("assistant-messages");
|
|
167
|
+
expect(call.method).toBe("POST");
|
|
168
|
+
expect(call.endpoint).toBe("assistant-actions/action-1/deny");
|
|
169
|
+
expect(call.input).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
4
|
import { Sparkles, AlertCircle } from "lucide-react";
|
|
5
|
-
import type
|
|
6
|
-
import ReactMarkdown from "react-markdown";
|
|
5
|
+
import { useMemo, type ReactNode } from "react";
|
|
6
|
+
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
|
7
7
|
import remarkGfm from "remark-gfm";
|
|
8
8
|
import type { AssistantMessageInterface } from "../data/AssistantMessageInterface";
|
|
9
9
|
import { MessageSourcesContainer } from "./parts/MessageSourcesContainer";
|
|
@@ -15,6 +15,24 @@ import { MessageSourcesContainer } from "./parts/MessageSourcesContainer";
|
|
|
15
15
|
*/
|
|
16
16
|
export type ApprovalActionRenderer = (message: AssistantMessageInterface) => ReactNode;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Render slot for `mention://<type>/<id>` links inside assistant markdown. The
|
|
20
|
+
* consuming app owns the entity chip (avatar, hovercard, navigation), so the
|
|
21
|
+
* chat renderer receives it as a function instead of importing it.
|
|
22
|
+
*/
|
|
23
|
+
export type MentionRenderer = (p: { type: string; id: string; alias: string }) => ReactNode;
|
|
24
|
+
|
|
25
|
+
const MENTION_HREF = /^mention:\/\/([^/]+)\/(.+)$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `react-markdown` strips every href whose protocol is not in its safe list,
|
|
29
|
+
* which would empty `mention://…` before the custom `a` renderer ever sees it.
|
|
30
|
+
* Let that one scheme through and defer everything else to the default.
|
|
31
|
+
*/
|
|
32
|
+
function mentionUrlTransform(url: string): string {
|
|
33
|
+
return MENTION_HREF.test(url) ? url : defaultUrlTransform(url);
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
interface Props {
|
|
19
37
|
message: AssistantMessageInterface;
|
|
20
38
|
isLatestAssistant: boolean;
|
|
@@ -22,6 +40,7 @@ interface Props {
|
|
|
22
40
|
failedMessageIds?: Set<string>;
|
|
23
41
|
onRetry?: (tempId: string) => void;
|
|
24
42
|
renderApprovalAction?: ApprovalActionRenderer;
|
|
43
|
+
renderMention?: MentionRenderer;
|
|
25
44
|
}
|
|
26
45
|
|
|
27
46
|
export function MessageItem({
|
|
@@ -31,16 +50,42 @@ export function MessageItem({
|
|
|
31
50
|
failedMessageIds,
|
|
32
51
|
onRetry,
|
|
33
52
|
renderApprovalAction,
|
|
53
|
+
renderMention,
|
|
34
54
|
}: Props) {
|
|
35
55
|
const t = useTranslations();
|
|
36
56
|
const isUser = message.role === "user";
|
|
37
57
|
const isFailed = isUser && !!failedMessageIds?.has(message.id);
|
|
38
58
|
|
|
59
|
+
const markdownComponents = useMemo(
|
|
60
|
+
() => ({
|
|
61
|
+
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
|
|
62
|
+
const match = href ? MENTION_HREF.exec(href) : null;
|
|
63
|
+
if (!match || !renderMention) return <a href={href}>{children}</a>;
|
|
64
|
+
return <>{renderMention({ type: match[1], id: match[2], alias: String(children ?? "") })}</>;
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
[renderMention],
|
|
68
|
+
);
|
|
69
|
+
|
|
39
70
|
if (isUser) {
|
|
40
71
|
return (
|
|
41
72
|
<div className="flex flex-col items-end gap-1">
|
|
42
|
-
|
|
43
|
-
|
|
73
|
+
{/*
|
|
74
|
+
Rendered as markdown, not raw text: a message composed with mentions
|
|
75
|
+
is stored as `[Alias](mention://<type>/<id>)`, which would otherwise
|
|
76
|
+
show the link markup and a bare uuid to the person who just wrote it.
|
|
77
|
+
`markdownComponents` routes those hrefs through `renderMention`.
|
|
78
|
+
*/}
|
|
79
|
+
{/*
|
|
80
|
+
Links inside this bubble sit on `bg-primary`. The mention chip and any
|
|
81
|
+
markdown link default to `text-primary`, which is the same teal and
|
|
82
|
+
renders invisible here, so force the on-primary colour and lean on
|
|
83
|
+
weight for the affordance (links are never underlined in this UI).
|
|
84
|
+
*/}
|
|
85
|
+
<div className="bg-primary text-primary-foreground max-w-[72%] rounded-2xl rounded-br-sm px-3.5 py-2 text-sm [&_a]:text-primary-foreground [&_a]:font-semibold [&_p]:m-0">
|
|
86
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={mentionUrlTransform} components={markdownComponents}>
|
|
87
|
+
{message.content}
|
|
88
|
+
</ReactMarkdown>
|
|
44
89
|
</div>
|
|
45
90
|
{isFailed && (
|
|
46
91
|
<div className="text-destructive flex items-center gap-2 text-xs">
|
|
@@ -68,8 +113,14 @@ export function MessageItem({
|
|
|
68
113
|
{isApprovalRequest ? (
|
|
69
114
|
renderApprovalAction(message)
|
|
70
115
|
) : (
|
|
71
|
-
|
|
72
|
-
|
|
116
|
+
// Markdown blocks arrive unstyled under Tailwind preflight (all margins
|
|
117
|
+
// zeroed): restore flow spacing between sibling blocks and give
|
|
118
|
+
// blockquotes a callout treatment — assistant answers use them for
|
|
119
|
+
// proposals and asides that must stand apart from factual prose.
|
|
120
|
+
<div className="bg-muted text-foreground rounded-2xl rounded-bl-sm px-3.5 py-2.5 text-sm leading-relaxed [&>*+*]:mt-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/50 [&_blockquote]:bg-primary/5 [&_blockquote]:rounded-md [&_blockquote]:px-3 [&_blockquote]:py-1.5">
|
|
121
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents} urlTransform={mentionUrlTransform}>
|
|
122
|
+
{message.content}
|
|
123
|
+
</ReactMarkdown>
|
|
73
124
|
</div>
|
|
74
125
|
)}
|
|
75
126
|
<MessageSourcesContainer
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { AssistantMessageInterface } from "../data/AssistantMessageInterface";
|
|
4
|
-
import { MessageItem, type ApprovalActionRenderer } from "./MessageItem";
|
|
4
|
+
import { MessageItem, type ApprovalActionRenderer, type MentionRenderer } from "./MessageItem";
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
7
7
|
messages: AssistantMessageInterface[];
|
|
@@ -9,9 +9,17 @@ interface Props {
|
|
|
9
9
|
failedMessageIds?: Set<string>;
|
|
10
10
|
onRetry?: (tempId: string) => void;
|
|
11
11
|
renderApprovalAction?: ApprovalActionRenderer;
|
|
12
|
+
renderMention?: MentionRenderer;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export function MessageList({
|
|
15
|
+
export function MessageList({
|
|
16
|
+
messages,
|
|
17
|
+
onSelectFollowUp,
|
|
18
|
+
failedMessageIds,
|
|
19
|
+
onRetry,
|
|
20
|
+
renderApprovalAction,
|
|
21
|
+
renderMention,
|
|
22
|
+
}: Props) {
|
|
15
23
|
const ordered = [...messages].sort((a, b) => a.position - b.position);
|
|
16
24
|
|
|
17
25
|
let lastAssistantIndex = -1;
|
|
@@ -33,6 +41,7 @@ export function MessageList({ messages, onSelectFollowUp, failedMessageIds, onRe
|
|
|
33
41
|
failedMessageIds={failedMessageIds}
|
|
34
42
|
onRetry={onRetry}
|
|
35
43
|
renderApprovalAction={renderApprovalAction}
|
|
44
|
+
renderMention={renderMention}
|
|
36
45
|
/>
|
|
37
46
|
))}
|
|
38
47
|
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { renderWithProviders, screen } from "../../../../testing";
|
|
3
|
+
import type { AssistantMessageInterface } from "../../data/AssistantMessageInterface";
|
|
4
|
+
import { MessageItem } from "../MessageItem";
|
|
5
|
+
|
|
6
|
+
const message = {
|
|
7
|
+
id: "m1",
|
|
8
|
+
type: "assistant-messages",
|
|
9
|
+
role: "assistant",
|
|
10
|
+
position: 1,
|
|
11
|
+
content: "Ask [The Quiet One](mention://npcs/npc-1) about it.",
|
|
12
|
+
references: [],
|
|
13
|
+
citations: [],
|
|
14
|
+
suggestedQuestions: [],
|
|
15
|
+
} as unknown as AssistantMessageInterface;
|
|
16
|
+
|
|
17
|
+
describe("MessageItem mention rendering", () => {
|
|
18
|
+
it("hands mention links to renderMention", () => {
|
|
19
|
+
renderWithProviders(
|
|
20
|
+
<MessageItem
|
|
21
|
+
message={message}
|
|
22
|
+
isLatestAssistant={false}
|
|
23
|
+
onSelectFollowUp={vi.fn()}
|
|
24
|
+
renderMention={({ type, id, alias }) => <span data-testid="mention">{`${type}:${id}:${alias}`}</span>}
|
|
25
|
+
/>,
|
|
26
|
+
);
|
|
27
|
+
expect(screen.getByTestId("mention")).toHaveTextContent("npcs:npc-1:The Quiet One");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders a plain link when no renderer is supplied", () => {
|
|
31
|
+
renderWithProviders(<MessageItem message={message} isLatestAssistant={false} onSelectFollowUp={vi.fn()} />);
|
|
32
|
+
expect(screen.getByText("The Quiet One")).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -18,19 +18,36 @@ class SourcesFetcher extends ClientAbstractService {
|
|
|
18
18
|
idsParam: string;
|
|
19
19
|
ids: string[];
|
|
20
20
|
}): Promise<ApiDataInterface[]> {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// The
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
21
|
+
// Resolved one id at a time, deliberately.
|
|
22
|
+
//
|
|
23
|
+
// The obvious alternative — a single `GET /<type>?<type>Ids=…&fetchAll=true`
|
|
24
|
+
// — assumes every entity exposes a flat collection route. narr8 does not:
|
|
25
|
+
// it serves `/npcs/:id` and `/campaigns/:campaignId/npcs`, with no
|
|
26
|
+
// `GET /npcs`, so the bulk call 404s, every citation degrades to
|
|
27
|
+
// "<Type> <uuid>", and the browser logs a failed request for each source
|
|
28
|
+
// type on every message. Reading by id needs only the by-id route, which
|
|
29
|
+
// every entity has. Citation counts are small (a handful per message) and
|
|
30
|
+
// the reads run in parallel, so the extra round-trips are cheap.
|
|
31
|
+
return this.findEachById(params);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private static async findEachById(params: {
|
|
35
|
+
module: ApiRequestDataTypeInterface;
|
|
36
|
+
ids: string[];
|
|
37
|
+
}): Promise<ApiDataInterface[]> {
|
|
38
|
+
const settled = await Promise.allSettled(
|
|
39
|
+
params.ids.map((id) =>
|
|
40
|
+
this.callApi<ApiDataInterface>({
|
|
41
|
+
type: params.module,
|
|
42
|
+
method: ClientHttpMethod.GET,
|
|
43
|
+
endpoint: new EndpointCreator({ endpoint: params.module, id }).generate(),
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
// A deleted or unreadable source must not blank out the whole panel.
|
|
48
|
+
return settled
|
|
49
|
+
.filter((r): r is PromiseFulfilledResult<ApiDataInterface> => r.status === "fulfilled" && !!r.value)
|
|
50
|
+
.map((r) => r.value);
|
|
34
51
|
}
|
|
35
52
|
}
|
|
36
53
|
|
|
@@ -11,6 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
|
|
|
11
11
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../../shadcnui/ui/tooltip";
|
|
12
12
|
import { cn } from "@/lib/utils";
|
|
13
13
|
import { RelevanceMeter } from "../RelevanceMeter";
|
|
14
|
+
import { useEntityLabel } from "./useEntityLabel";
|
|
14
15
|
|
|
15
16
|
interface Props {
|
|
16
17
|
citations: (ChunkInterface & ChunkRelationshipMeta)[];
|
|
@@ -24,6 +25,7 @@ interface Props {
|
|
|
24
25
|
|
|
25
26
|
export function CitationsTab({ citations, sources }: Props) {
|
|
26
27
|
const t = useTranslations();
|
|
28
|
+
const entityLabel = useEntityLabel();
|
|
27
29
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
28
30
|
if (citations.length === 0) return null;
|
|
29
31
|
|
|
@@ -50,9 +52,12 @@ export function CitationsTab({ citations, sources }: Props) {
|
|
|
50
52
|
{sorted.map((chunk) => {
|
|
51
53
|
const isOpen = expanded.has(chunk.id);
|
|
52
54
|
const resolved = chunk.nodeId ? sources?.get(chunk.nodeId) : undefined;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// `nodeType` is a JSON:API wire type (`npcs`), not UI copy — translate
|
|
56
|
+
// it the same way the References tab does before showing it.
|
|
57
|
+
const typeLabel = chunk.nodeType
|
|
58
|
+
? entityLabel(chunk.nodeType)
|
|
59
|
+
: t("features.assistant.message.sources.source");
|
|
60
|
+
const fallbackName = chunk.nodeId ? `${typeLabel} ${chunk.nodeId.slice(0, 8)}` : typeLabel;
|
|
56
61
|
const sourceName = (resolved as any)?.name ?? fallbackName;
|
|
57
62
|
return (
|
|
58
63
|
<Fragment key={chunk.id}>
|
|
@@ -89,8 +94,21 @@ export function CitationsTab({ citations, sources }: Props) {
|
|
|
89
94
|
</TableRow>
|
|
90
95
|
{isOpen && (
|
|
91
96
|
<TableRow>
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
{/*
|
|
98
|
+
`max-w-0` is the table-layout trick: without it the cell
|
|
99
|
+
sizes to its widest line, so the panel either scrolls
|
|
100
|
+
sideways or clips. Pinning the cell's intrinsic max width to
|
|
101
|
+
zero makes the browser take its width from the table instead,
|
|
102
|
+
which lets the content below actually wrap.
|
|
103
|
+
*/}
|
|
104
|
+
<TableCell colSpan={2} className="w-full max-w-0 border-t-0 p-4">
|
|
105
|
+
{/*
|
|
106
|
+
Prose wraps; only genuinely wide children scroll. The
|
|
107
|
+
wrapper used to be `overflow-x-auto`, which turned every
|
|
108
|
+
long sentence into a horizontal scrollbar instead of
|
|
109
|
+
breaking onto the next line.
|
|
110
|
+
*/}
|
|
111
|
+
<div className="bg-card w-full min-w-0 rounded border p-4 text-sm break-words whitespace-pre-wrap [overflow-wrap:anywhere] [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto">
|
|
94
112
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{chunk.content}</ReactMarkdown>
|
|
95
113
|
</div>
|
|
96
114
|
</TableCell>
|
|
@@ -7,6 +7,7 @@ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
|
7
7
|
import { usePageUrlGenerator } from "../../../../../hooks";
|
|
8
8
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui/ui/table";
|
|
9
9
|
import type { ChunkInterface, ChunkRelationshipMeta } from "../../../../chunk/data/ChunkInterface";
|
|
10
|
+
import { useEntityLabel } from "./useEntityLabel";
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
12
13
|
citations: (ChunkInterface & ChunkRelationshipMeta)[];
|
|
@@ -22,6 +23,7 @@ interface ContentRow {
|
|
|
22
23
|
|
|
23
24
|
export function ContentsTab({ citations, sources }: Props) {
|
|
24
25
|
const t = useTranslations();
|
|
26
|
+
const entityLabel = useEntityLabel();
|
|
25
27
|
const generate = usePageUrlGenerator();
|
|
26
28
|
|
|
27
29
|
// Group citations by nodeId, then materialise rows from the resolved sources
|
|
@@ -82,7 +84,7 @@ export function ContentsTab({ citations, sources }: Props) {
|
|
|
82
84
|
</span>
|
|
83
85
|
</Link>
|
|
84
86
|
</TableCell>
|
|
85
|
-
<TableCell className="text-muted-foreground text-xs">{module.name}</TableCell>
|
|
87
|
+
<TableCell className="text-muted-foreground text-xs">{entityLabel(module.name)}</TableCell>
|
|
86
88
|
</TableRow>
|
|
87
89
|
);
|
|
88
90
|
})}
|
|
@@ -6,6 +6,7 @@ import type { ApiDataInterface } from "../../../../../core";
|
|
|
6
6
|
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
7
7
|
import { usePageUrlGenerator } from "../../../../../hooks";
|
|
8
8
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui/ui/table";
|
|
9
|
+
import { useEntityLabel } from "./useEntityLabel";
|
|
9
10
|
|
|
10
11
|
interface Props {
|
|
11
12
|
references: ApiDataInterface[];
|
|
@@ -15,6 +16,8 @@ export function ReferencesTab({ references }: Props) {
|
|
|
15
16
|
const t = useTranslations();
|
|
16
17
|
const generate = usePageUrlGenerator();
|
|
17
18
|
|
|
19
|
+
const entityLabel = useEntityLabel();
|
|
20
|
+
|
|
18
21
|
if (references.length === 0) return null;
|
|
19
22
|
|
|
20
23
|
return (
|
|
@@ -43,7 +46,7 @@ export function ReferencesTab({ references }: Props) {
|
|
|
43
46
|
{ref.identifier}
|
|
44
47
|
</Link>
|
|
45
48
|
</TableCell>
|
|
46
|
-
<TableCell className="text-muted-foreground text-xs">{module.name}</TableCell>
|
|
49
|
+
<TableCell className="text-muted-foreground text-xs">{entityLabel(module.name)}</TableCell>
|
|
47
50
|
</TableRow>
|
|
48
51
|
);
|
|
49
52
|
})}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useCallback } from "react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Turns a JSON:API type (`npcs`, `locations`) into UI copy.
|
|
8
|
+
*
|
|
9
|
+
* The source panels receive wire identifiers, not labels. Host apps translate
|
|
10
|
+
* entity names under `entities.<type>` with a plural count, so prefer that and
|
|
11
|
+
* fall back to the raw type when the app has no such key — the panel must
|
|
12
|
+
* still render for a type the app never localised.
|
|
13
|
+
*/
|
|
14
|
+
export function useEntityLabel(): (type: string) => string {
|
|
15
|
+
const t = useTranslations();
|
|
16
|
+
|
|
17
|
+
return useCallback(
|
|
18
|
+
(type: string): string => {
|
|
19
|
+
const key = `entities.${type}`;
|
|
20
|
+
// `t.has` is the only non-throwing probe: a plain `t()` on a missing key
|
|
21
|
+
// either throws or returns the key path, depending on the host app's
|
|
22
|
+
// next-intl error handler, and the key path reads worse than the type.
|
|
23
|
+
if (typeof t.has === "function" && !t.has(key as never)) return type;
|
|
24
|
+
try {
|
|
25
|
+
// Cast through the untyped call signature: the key is computed, so the
|
|
26
|
+
// generated message-key union cannot describe it, and the `count`
|
|
27
|
+
// parameter is only accepted on keys the union knows about.
|
|
28
|
+
return (t as unknown as (k: string, values?: Record<string, unknown>) => string)(key, { count: 1 });
|
|
29
|
+
} catch {
|
|
30
|
+
return type;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
[t],
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -125,12 +125,21 @@ export class AssistantMessage extends AbstractApiData implements AssistantMessag
|
|
|
125
125
|
* the append-to-thread endpoint derives those server-side, so we only send `content` and
|
|
126
126
|
* the optional retrieval-mode flags.
|
|
127
127
|
*/
|
|
128
|
-
createAppendMessageJsonApi(params: {
|
|
128
|
+
createAppendMessageJsonApi(params: {
|
|
129
|
+
content: string;
|
|
130
|
+
howToMode?: boolean;
|
|
131
|
+
limitToHowToId?: string;
|
|
132
|
+
contentBlocks?: unknown[];
|
|
133
|
+
}) {
|
|
129
134
|
return {
|
|
130
135
|
data: {
|
|
131
136
|
type: Modules.AssistantMessage.name,
|
|
132
137
|
attributes: {
|
|
133
|
-
|
|
138
|
+
// narr8 convention for BlockNote-backed fields: the document travels
|
|
139
|
+
// as a JSON string in the field's own string attribute (see
|
|
140
|
+
// Npc.description). The server detects the shape and derives the
|
|
141
|
+
// stored markdown from it.
|
|
142
|
+
content: params.contentBlocks !== undefined ? JSON.stringify(params.contentBlocks) : params.content,
|
|
134
143
|
...(params.howToMode !== undefined ? { howToMode: params.howToMode } : {}),
|
|
135
144
|
...(params.limitToHowToId !== undefined ? { limitToHowToId: params.limitToHowToId } : {}),
|
|
136
145
|
},
|
|
@@ -121,20 +121,25 @@ export function TokenStatusIndicator({ className, size = "md", showExtraPages =
|
|
|
121
121
|
|
|
122
122
|
return (
|
|
123
123
|
<Tooltip>
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
124
|
+
{/* The layout classes live on the trigger itself, which renders the
|
|
125
|
+
<button>. Wrapping them in a nested inline-flex div instead makes that
|
|
126
|
+
child sit on the button's text baseline, so the button reserves
|
|
127
|
+
descender space below it and the icons render ~2px above the centre of
|
|
128
|
+
any flex row the indicator is placed in (e.g. the app header). */}
|
|
129
|
+
<TooltipTrigger
|
|
130
|
+
className={cn("inline-flex items-center gap-1.5 cursor-default", className)}
|
|
131
|
+
aria-label={t("billing.tokens.status", { defaultValue: "Page Status" })}
|
|
132
|
+
>
|
|
133
|
+
{getBatteryIcon()}
|
|
134
|
+
<span className={cn(textSize, "text-muted-foreground font-medium leading-none")}>
|
|
135
|
+
{availableMonthlyCredits}
|
|
136
|
+
</span>
|
|
137
|
+
{showExtraPages && availableExtraCredits > 0 && (
|
|
138
|
+
<span className="inline-flex items-center gap-0.5">
|
|
139
|
+
<PlusCircle className={cn(smallIconSize, "text-blue-500")} />
|
|
140
|
+
<span className={cn(textSize, "text-blue-500 font-medium leading-none")}>{availableExtraCredits}</span>
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
138
143
|
</TooltipTrigger>
|
|
139
144
|
<TooltipContent side="top" className="max-w-xs">
|
|
140
145
|
{tooltipContent}
|
package/src/features/index.ts
CHANGED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { A as ApiDataInterface } from './ApiDataInterface-BcZeXy5X.mjs';
|
|
2
|
-
|
|
3
|
-
type AssistantInput = {
|
|
4
|
-
firstMessage: string;
|
|
5
|
-
title?: string;
|
|
6
|
-
howToMode?: boolean;
|
|
7
|
-
limitToHowToId?: string;
|
|
8
|
-
};
|
|
9
|
-
interface AssistantInterface extends ApiDataInterface {
|
|
10
|
-
get title(): string;
|
|
11
|
-
get messageCount(): number;
|
|
12
|
-
/** Agent engine the thread runs on — `"operator"` or undefined (responder). */
|
|
13
|
-
get engine(): string | undefined;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type { AssistantInput as A, AssistantInterface as a };
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { A as ApiDataInterface } from './ApiDataInterface-BcZeXy5X.js';
|
|
2
|
-
|
|
3
|
-
type AssistantInput = {
|
|
4
|
-
firstMessage: string;
|
|
5
|
-
title?: string;
|
|
6
|
-
howToMode?: boolean;
|
|
7
|
-
limitToHowToId?: string;
|
|
8
|
-
};
|
|
9
|
-
interface AssistantInterface extends ApiDataInterface {
|
|
10
|
-
get title(): string;
|
|
11
|
-
get messageCount(): number;
|
|
12
|
-
/** Agent engine the thread runs on — `"operator"` or undefined (responder). */
|
|
13
|
-
get engine(): string | undefined;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type { AssistantInput as A, AssistantInterface as a };
|