@truedat/ai 8.5.4 → 8.5.7

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.
Files changed (27) hide show
  1. package/package.json +3 -3
  2. package/src/api.js +2 -0
  3. package/src/components/AiRoutes.js +3 -0
  4. package/src/components/ConceptSuggestions.js +128 -0
  5. package/src/components/SuggestLinkButton.js +60 -0
  6. package/src/components/__tests__/ConceptSuggestions.spec.js +268 -0
  7. package/src/components/__tests__/SuggestLinkButton.spec.js +40 -0
  8. package/src/components/assistant/Assistant.js +186 -78
  9. package/src/components/assistant/AssistantChat.js +91 -30
  10. package/src/components/assistant/AssistantConversations.js +78 -0
  11. package/src/components/assistant/AssistantPage.js +139 -0
  12. package/src/components/assistant/AssistantPageChat.js +447 -0
  13. package/src/components/assistant/__tests__/Assistant.spec.js +15 -0
  14. package/src/components/assistant/__tests__/AssistantChat.spec.js +18 -0
  15. package/src/components/assistant/__tests__/AssistantConversations.spec.js +111 -0
  16. package/src/components/assistant/__tests__/__snapshots__/Assistant.spec.js.snap +5 -5
  17. package/src/components/assistant/__tests__/__snapshots__/AssistantChat.spec.js.snap +18 -8
  18. package/src/components/assistant/__tests__/__snapshots__/AssistantConversations.spec.js.snap +73 -0
  19. package/src/components/assistant/hooks/useAgentConversation.js +9 -3
  20. package/src/components/assistant/hooks/useAssistantSocket.js +36 -3
  21. package/src/components/index.js +2 -1
  22. package/src/hooks/__tests__/useAgentConversations.spec.js +83 -0
  23. package/src/hooks/useAgentConversations.js +15 -0
  24. package/src/styles/assistant.less +281 -121
  25. package/src/components/assistant/AssistantBubble.js +0 -56
  26. package/src/components/assistant/__tests__/AssistantBubble.spec.js +0 -14
  27. package/src/components/assistant/__tests__/__snapshots__/AssistantBubble.spec.js.snap +0 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/ai",
3
- "version": "8.5.4",
3
+ "version": "8.5.7",
4
4
  "description": "Truedat Web Artificial Intelligence package",
5
5
  "sideEffects": false,
6
6
  "module": "src/index.js",
@@ -50,7 +50,7 @@
50
50
  "@testing-library/jest-dom": "^6.6.3",
51
51
  "@testing-library/react": "^16.3.0",
52
52
  "@testing-library/user-event": "^14.6.1",
53
- "@truedat/test": "8.5.4",
53
+ "@truedat/test": "8.5.7",
54
54
  "identity-obj-proxy": "^3.0.0",
55
55
  "jest": "^29.7.0",
56
56
  "redux-saga-test-plan": "^4.0.6"
@@ -80,5 +80,5 @@
80
80
  "semantic-ui-react": "^3.0.0-beta.2",
81
81
  "swr": "^2.3.3"
82
82
  },
83
- "gitHead": "75272567eb3ec948a5cdeb8346ef9cacac58267f"
83
+ "gitHead": "871357888d97c203cf009a04878e0277cff6af9b"
84
84
  }
package/src/api.js CHANGED
@@ -16,12 +16,14 @@ const API_SUGGESTIONS_REQUEST = "/api/suggestions/request";
16
16
  const API_TRANSLATIONS_AVAILABILITY_CHECK =
17
17
  "/api/translations/availability_check";
18
18
  const API_TRANSLATIONS_REQUEST = "/api/translations/request";
19
+ const API_AGENT_LAYER_CONVERSATIONS = "/api/agent_layer/conversations";
19
20
  const API_AGENT_LAYER_CONVERSATION = "/api/agent_layer/conversation";
20
21
  const API_AGENT_LAYER_RUN = "/api/agent_layer/run";
21
22
  const API_SOCKET_ENDPOINT = "/api/socket";
22
23
 
23
24
  export {
24
25
  API_ACTION,
26
+ API_AGENT_LAYER_CONVERSATIONS,
25
27
  API_ACTIONS,
26
28
  API_ACTIONS_SEARCH,
27
29
  API_ACTION_SET_ACTIVE,
@@ -10,6 +10,7 @@ import Actions from "./actions/Actions";
10
10
  import Action from "./actions/Action";
11
11
  import ActionEdit from "./actions/ActionEdit";
12
12
  import ActionNew from "./actions/ActionNew";
13
+ import AssistantPage from "./assistant/AssistantPage";
13
14
 
14
15
  export default function AiRoutes() {
15
16
  return (
@@ -52,6 +53,8 @@ export default function AiRoutes() {
52
53
  <Route path={":id"} element={<Action />} />
53
54
  </Route>
54
55
 
56
+ <Route path={"ai-assistant"} element={<AssistantPage />} />
57
+
55
58
  <Route path="*" element={null} />
56
59
  </Routes>
57
60
  );
@@ -0,0 +1,128 @@
1
+ import { useEffect, useState, useCallback } from "react";
2
+ import { useParams, useLocation } from "react-router";
3
+ import PropTypes from "prop-types";
4
+ import { FormattedMessage } from "react-intl";
5
+ import { Icon, Loader, Message } from "semantic-ui-react";
6
+ import { ConceptSelectorTable } from "@truedat/bg/concepts/relations/components/ConceptSelector";
7
+ import useAssistantSocket from "./assistant/hooks/useAssistantSocket";
8
+ import ThinkingOutLoud from "./ThinkingOutLoud";
9
+ import { useAgentLayerRun } from "../hooks/useAgentLayerRun";
10
+ import {
11
+ reasonColumnDefinition,
12
+ similarityColumnDefinition,
13
+ } from "./structureSuggestionColumns";
14
+
15
+ const extraColumns = [similarityColumnDefinition, reasonColumnDefinition];
16
+
17
+ export const ConceptSuggestions = ({ selectedConcept, handleConceptSelected }) => {
18
+ const { id } = useParams();
19
+ const { state } = useLocation();
20
+ const prompt = state?.prompt;
21
+
22
+ const { trigger: fetchSuggestions, data: runData } = useAgentLayerRun();
23
+
24
+ const [logItems, setLogItems] = useState([]);
25
+ const [concepts, setConcepts] = useState(null);
26
+ const [hasError, setHasError] = useState(false);
27
+
28
+ const agentStateId = runData?.data?.data?.agent_state_id
29
+ ? String(runData.data.data.agent_state_id)
30
+ : null;
31
+
32
+ useEffect(() => {
33
+ if (id) {
34
+ fetchSuggestions({
35
+ workflow: "suggest_structure_concept_links",
36
+ params: { structure_id: id, prompt },
37
+ });
38
+ }
39
+ }, [id, prompt]);
40
+
41
+ const handleServerMessage = useCallback((event, payload) => {
42
+ const priority = payload?.priority ?? null;
43
+ const source = payload?.source ?? null;
44
+
45
+ if (event === "stream" && Number(priority) === 0) {
46
+ const content = payload?.content;
47
+ let parsed;
48
+ try {
49
+ const raw = typeof content === "string" ? JSON.parse(content) : content;
50
+ parsed = Array.isArray(raw) ? raw : (raw?.result ?? []);
51
+ } catch {
52
+ parsed = [];
53
+ }
54
+ setConcepts(parsed);
55
+ return;
56
+ }
57
+
58
+ if (event === "error" && Number(priority) !== 2) {
59
+ setHasError(true);
60
+ return;
61
+ }
62
+
63
+ if (event === "log" || (event === "error" && Number(priority) === 2)) {
64
+ const content =
65
+ event === "log"
66
+ ? (() => {
67
+ const log = payload?.content;
68
+ return log && typeof log === "object"
69
+ ? log.message ?? log.level ?? JSON.stringify(log)
70
+ : String(log ?? "");
71
+ })()
72
+ : (() => {
73
+ const err = payload?.error;
74
+ return err && typeof err === "object"
75
+ ? err.message ?? err.reason ?? JSON.stringify(err)
76
+ : String(err ?? "");
77
+ })();
78
+
79
+ setLogItems((prev) => [
80
+ ...prev,
81
+ { eventType: event, content: content || " ", priority, source },
82
+ ]);
83
+ }
84
+ }, []);
85
+
86
+ useAssistantSocket(!agentStateId, {
87
+ conversationId: agentStateId,
88
+ onServerMessage: handleServerMessage,
89
+ });
90
+
91
+ if (hasError) {
92
+ return (
93
+ <Message icon negative>
94
+ <Icon name="warning circle" />
95
+ <Message.Content>
96
+ <Message.Header>
97
+ <FormattedMessage id="concepts.suggestions.error.header" />
98
+ </Message.Header>
99
+ <FormattedMessage id="concepts.suggestions.error.body" />
100
+ </Message.Content>
101
+ </Message>
102
+ );
103
+ }
104
+
105
+ if (concepts !== null) {
106
+ return (
107
+ <ConceptSelectorTable
108
+ concepts={concepts}
109
+ selectedConcept={selectedConcept}
110
+ handleConceptSelected={handleConceptSelected}
111
+ extraColumns={extraColumns}
112
+ />
113
+ );
114
+ }
115
+
116
+ if (logItems.length === 0) {
117
+ return <Loader active inline="centered" />;
118
+ }
119
+
120
+ return <ThinkingOutLoud items={logItems} active={true} />;
121
+ };
122
+
123
+ ConceptSuggestions.propTypes = {
124
+ selectedConcept: PropTypes.object,
125
+ handleConceptSelected: PropTypes.func,
126
+ };
127
+
128
+ export default ConceptSuggestions;
@@ -0,0 +1,60 @@
1
+ import { useState } from "react";
2
+ import PropTypes from "prop-types";
3
+ import { Button, Form, Popup } from "semantic-ui-react";
4
+ import { FormattedMessage, useIntl } from "react-intl";
5
+
6
+ export const SuggestLinkButton = ({ onSubmit, floated }) => {
7
+ const { formatMessage } = useIntl();
8
+ const [open, setOpen] = useState(false);
9
+ const [prompt, setPrompt] = useState("");
10
+
11
+ const handleSubmit = () => {
12
+ setOpen(false);
13
+ onSubmit(prompt);
14
+ };
15
+
16
+ return (
17
+ <Popup
18
+ on="click"
19
+ open={open}
20
+ onOpen={() => setOpen(true)}
21
+ onClose={() => setOpen(false)}
22
+ content={
23
+ <Form style={{ minWidth: "300px" }}>
24
+ <Form.Field>
25
+ <label>{formatMessage({ id: "links.suggest.prompt.label" })}</label>
26
+ <Form.Input
27
+ value={prompt}
28
+ onChange={(_, { value }) => setPrompt(value)}
29
+ placeholder={formatMessage({
30
+ id: "links.suggest.prompt.placeholder",
31
+ })}
32
+ />
33
+ </Form.Field>
34
+ <Button
35
+ primary
36
+ content={formatMessage({ id: "links.suggest.submit" })}
37
+ onClick={handleSubmit}
38
+ />
39
+ </Form>
40
+ }
41
+ position="bottom center"
42
+ trigger={
43
+ <Button
44
+ secondary
45
+ active={open}
46
+ floated={floated}
47
+ icon="lightbulb outline"
48
+ content={<FormattedMessage id="links.actions.suggest" />}
49
+ />
50
+ }
51
+ />
52
+ );
53
+ };
54
+
55
+ SuggestLinkButton.propTypes = {
56
+ onSubmit: PropTypes.func.isRequired,
57
+ floated: PropTypes.string,
58
+ };
59
+
60
+ export default SuggestLinkButton;
@@ -0,0 +1,268 @@
1
+ import { act, waitFor } from "@testing-library/react";
2
+ import { useParams, useLocation } from "react-router";
3
+ import { render } from "@truedat/test/render";
4
+ import { ConceptSelectorTable } from "@truedat/bg/concepts/relations/components/ConceptSelector";
5
+ import useAssistantSocket from "../assistant/hooks/useAssistantSocket";
6
+ import ThinkingOutLoud from "../ThinkingOutLoud";
7
+ import { useAgentLayerRun } from "../../hooks/useAgentLayerRun";
8
+ import ConceptSuggestions from "../ConceptSuggestions";
9
+
10
+ jest.mock("react-router", () => ({
11
+ ...jest.requireActual("react-router"),
12
+ useParams: jest.fn(),
13
+ useLocation: jest.fn(),
14
+ }));
15
+
16
+ jest.mock("@truedat/bg/concepts/relations/components/ConceptSelector", () => ({
17
+ ConceptSelectorTable: jest.fn(() => <div>MockConceptSelectorTable</div>),
18
+ }));
19
+
20
+ jest.mock("../ThinkingOutLoud", () => ({
21
+ __esModule: true,
22
+ default: jest.fn(() => <div>MockThinkingOutLoud</div>),
23
+ getSourceLabel: jest.fn((s) => s),
24
+ }));
25
+
26
+ jest.mock("../assistant/hooks/useAssistantSocket", () => jest.fn());
27
+
28
+ jest.mock("../../hooks/useAgentLayerRun", () => ({
29
+ useAgentLayerRun: jest.fn(),
30
+ }));
31
+
32
+ describe("ConceptSuggestions", () => {
33
+ const mockFetchSuggestions = jest.fn();
34
+
35
+ beforeEach(() => {
36
+ jest.clearAllMocks();
37
+
38
+ useParams.mockReturnValue({ id: "42" });
39
+ useLocation.mockReturnValue({ state: { prompt: "find concepts" } });
40
+ useAssistantSocket.mockImplementation(() => {});
41
+
42
+ useAgentLayerRun.mockReturnValue({
43
+ trigger: mockFetchSuggestions,
44
+ data: null,
45
+ });
46
+ });
47
+
48
+ it("calls fetchSuggestions on mount with structure_id and prompt", async () => {
49
+ render(
50
+ <ConceptSuggestions
51
+ selectedConcept={null}
52
+ handleConceptSelected={jest.fn()}
53
+ />
54
+ );
55
+
56
+ await waitFor(() =>
57
+ expect(mockFetchSuggestions).toHaveBeenCalledWith({
58
+ workflow: "suggest_structure_concept_links",
59
+ params: { structure_id: "42", prompt: "find concepts" },
60
+ })
61
+ );
62
+ });
63
+
64
+ it("calls fetchSuggestions without prompt when prompt is missing", async () => {
65
+ useLocation.mockReturnValue({ state: null });
66
+
67
+ render(
68
+ <ConceptSuggestions
69
+ selectedConcept={null}
70
+ handleConceptSelected={jest.fn()}
71
+ />
72
+ );
73
+
74
+ await waitFor(() =>
75
+ expect(mockFetchSuggestions).toHaveBeenCalledWith({
76
+ workflow: "suggest_structure_concept_links",
77
+ params: { structure_id: "42", prompt: undefined },
78
+ })
79
+ );
80
+ });
81
+
82
+ it("shows spinner while waiting for first log item", () => {
83
+ useAgentLayerRun.mockReturnValue({
84
+ trigger: mockFetchSuggestions,
85
+ data: { data: { data: { agent_state_id: "state-1" } } },
86
+ });
87
+
88
+ const rendered = render(
89
+ <ConceptSuggestions
90
+ selectedConcept={null}
91
+ handleConceptSelected={jest.fn()}
92
+ />
93
+ );
94
+
95
+ expect(rendered.queryByText(/mockthinkingoutloud/i)).not.toBeInTheDocument();
96
+ expect(rendered.queryByText(/mockconceptselectortable/i)).not.toBeInTheDocument();
97
+ });
98
+
99
+ it("shows ThinkingOutLoud once log items arrive", async () => {
100
+ let capturedOnServerMessage;
101
+
102
+ useAssistantSocket.mockImplementation((_, { onServerMessage }) => {
103
+ capturedOnServerMessage = onServerMessage;
104
+ });
105
+
106
+ useAgentLayerRun.mockReturnValue({
107
+ trigger: mockFetchSuggestions,
108
+ data: { data: { data: { agent_state_id: "state-1" } } },
109
+ });
110
+
111
+ const rendered = render(
112
+ <ConceptSuggestions
113
+ selectedConcept={null}
114
+ handleConceptSelected={jest.fn()}
115
+ />
116
+ );
117
+
118
+ act(() => {
119
+ capturedOnServerMessage("log", {
120
+ priority: 1,
121
+ source: "agent_x",
122
+ content: "Processing...",
123
+ });
124
+ });
125
+
126
+ await waitFor(() =>
127
+ expect(rendered.queryByText(/mockthinkingoutloud/i)).toBeInTheDocument()
128
+ );
129
+ });
130
+
131
+ it("disables socket when agent_state_id is not yet available", () => {
132
+ render(
133
+ <ConceptSuggestions
134
+ selectedConcept={null}
135
+ handleConceptSelected={jest.fn()}
136
+ />
137
+ );
138
+
139
+ expect(useAssistantSocket).toHaveBeenCalledWith(
140
+ true,
141
+ expect.objectContaining({ conversationId: null })
142
+ );
143
+ });
144
+
145
+ it("connects socket with agent_state_id when available", () => {
146
+ useAgentLayerRun.mockReturnValue({
147
+ trigger: mockFetchSuggestions,
148
+ data: { data: { data: { agent_state_id: "state-1" } } },
149
+ });
150
+
151
+ render(
152
+ <ConceptSuggestions
153
+ selectedConcept={null}
154
+ handleConceptSelected={jest.fn()}
155
+ />
156
+ );
157
+
158
+ expect(useAssistantSocket).toHaveBeenCalledWith(
159
+ false,
160
+ expect.objectContaining({ conversationId: "state-1" })
161
+ );
162
+ });
163
+
164
+ it("shows ConceptSelectorTable when stream priority-0 arrives", async () => {
165
+ const concepts = [{ id: "c1" }, { id: "c2" }];
166
+ let capturedOnServerMessage;
167
+
168
+ useAssistantSocket.mockImplementation((_, { onServerMessage }) => {
169
+ capturedOnServerMessage = onServerMessage;
170
+ });
171
+
172
+ useAgentLayerRun.mockReturnValue({
173
+ trigger: mockFetchSuggestions,
174
+ data: { data: { data: { agent_state_id: "state-1" } } },
175
+ });
176
+
177
+ const rendered = render(
178
+ <ConceptSuggestions
179
+ selectedConcept={null}
180
+ handleConceptSelected={jest.fn()}
181
+ />
182
+ );
183
+
184
+ act(() => {
185
+ capturedOnServerMessage("stream", {
186
+ priority: 0,
187
+ content: JSON.stringify({ result: concepts }),
188
+ });
189
+ });
190
+
191
+ await waitFor(() =>
192
+ expect(rendered.queryByText(/mockconceptselectortable/i)).toBeInTheDocument()
193
+ );
194
+
195
+ expect(rendered.queryByText(/mockthinkingoutloud/i)).not.toBeInTheDocument();
196
+
197
+ const props = ConceptSelectorTable.mock.calls.at(-1)[0];
198
+ expect(props.concepts).toEqual(concepts);
199
+ });
200
+
201
+ it("shows error message when error priority-0 arrives", async () => {
202
+ let capturedOnServerMessage;
203
+
204
+ useAssistantSocket.mockImplementation((_, { onServerMessage }) => {
205
+ capturedOnServerMessage = onServerMessage;
206
+ });
207
+
208
+ useAgentLayerRun.mockReturnValue({
209
+ trigger: mockFetchSuggestions,
210
+ data: { data: { data: { agent_state_id: "state-1" } } },
211
+ });
212
+
213
+ const rendered = render(
214
+ <ConceptSuggestions
215
+ selectedConcept={null}
216
+ handleConceptSelected={jest.fn()}
217
+ />
218
+ );
219
+
220
+ act(() => {
221
+ capturedOnServerMessage("error", {
222
+ priority: 0,
223
+ error: { message: "Something failed" },
224
+ });
225
+ });
226
+
227
+ await waitFor(() =>
228
+ expect(rendered.queryByText(/mockthinkingoutloud/i)).not.toBeInTheDocument()
229
+ );
230
+
231
+ expect(rendered.queryByText(/mockconceptselectortable/i)).not.toBeInTheDocument();
232
+ });
233
+
234
+ it("adds log events to ThinkingOutLoud items", async () => {
235
+ let capturedOnServerMessage;
236
+
237
+ useAssistantSocket.mockImplementation((_, { onServerMessage }) => {
238
+ capturedOnServerMessage = onServerMessage;
239
+ });
240
+
241
+ useAgentLayerRun.mockReturnValue({
242
+ trigger: mockFetchSuggestions,
243
+ data: { data: { data: { agent_state_id: "state-1" } } },
244
+ });
245
+
246
+ render(
247
+ <ConceptSuggestions
248
+ selectedConcept={null}
249
+ handleConceptSelected={jest.fn()}
250
+ />
251
+ );
252
+
253
+ act(() => {
254
+ capturedOnServerMessage("log", {
255
+ priority: 1,
256
+ source: "agent_x",
257
+ content: "Processing...",
258
+ });
259
+ });
260
+
261
+ await waitFor(() => {
262
+ const lastCall = ThinkingOutLoud.mock.calls.at(-1)[0];
263
+ expect(lastCall.items).toHaveLength(1);
264
+ expect(lastCall.items[0].content).toBe("Processing...");
265
+ expect(lastCall.items[0].eventType).toBe("log");
266
+ });
267
+ });
268
+ });
@@ -0,0 +1,40 @@
1
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
2
+ import { render } from "@truedat/test/render";
3
+ import { SuggestLinkButton } from "../SuggestLinkButton";
4
+
5
+ jest.mock("react-intl", () => ({
6
+ ...jest.requireActual("react-intl"),
7
+ useIntl: () => ({ formatMessage: ({ id }) => id }),
8
+ FormattedMessage: ({ id }) => id,
9
+ }));
10
+
11
+ describe("SuggestLinkButton", () => {
12
+ it("renders the trigger button", () => {
13
+ render(<SuggestLinkButton onSubmit={jest.fn()} />);
14
+ expect(screen.getByText("links.actions.suggest")).toBeInTheDocument();
15
+ });
16
+
17
+ it("calls onSubmit with the prompt value when submitted", async () => {
18
+ const onSubmit = jest.fn();
19
+ render(<SuggestLinkButton onSubmit={onSubmit} />);
20
+
21
+ fireEvent.click(screen.getByText("links.actions.suggest"));
22
+
23
+ const input = screen.getByPlaceholderText("links.suggest.prompt.placeholder");
24
+ fireEvent.change(input, { target: { value: "my prompt" } });
25
+
26
+ fireEvent.click(screen.getByText("links.suggest.submit"));
27
+
28
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("my prompt"));
29
+ });
30
+
31
+ it("calls onSubmit with empty string when no prompt entered", async () => {
32
+ const onSubmit = jest.fn();
33
+ render(<SuggestLinkButton onSubmit={onSubmit} />);
34
+
35
+ fireEvent.click(screen.getByText("links.actions.suggest"));
36
+ fireEvent.click(screen.getByText("links.suggest.submit"));
37
+
38
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(""));
39
+ });
40
+ });