@truedat/ai 8.4.8 → 8.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/ai",
3
- "version": "8.4.8",
3
+ "version": "8.4.9",
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.4.8",
53
+ "@truedat/test": "8.4.9",
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": "7829e377f78c3cfc66e449f5dc31a8b03c0f5f00"
83
+ "gitHead": "9fd4bd2126a33342009194c8ae1829cd3c617a48"
84
84
  }
package/src/api.js CHANGED
@@ -17,6 +17,7 @@ const API_TRANSLATIONS_AVAILABILITY_CHECK =
17
17
  "/api/translations/availability_check";
18
18
  const API_TRANSLATIONS_REQUEST = "/api/translations/request";
19
19
  const API_AGENT_LAYER_CONVERSATION = "/api/agent_layer/conversation";
20
+ const API_AGENT_LAYER_RUN = "/api/agent_layer/run";
20
21
  const API_SOCKET_ENDPOINT = "/api/socket";
21
22
 
22
23
  export {
@@ -37,5 +38,6 @@ export {
37
38
  API_TRANSLATIONS_AVAILABILITY_CHECK,
38
39
  API_TRANSLATIONS_REQUEST,
39
40
  API_AGENT_LAYER_CONVERSATION,
41
+ API_AGENT_LAYER_RUN,
40
42
  API_SOCKET_ENDPOINT,
41
43
  };
@@ -0,0 +1,147 @@
1
+ import { useEffect, useState, useCallback, useMemo } from "react";
2
+ import { useParams, useLocation } from "react-router";
3
+ import { useSelector } from "react-redux";
4
+ import PropTypes from "prop-types";
5
+ import { FormattedMessage } from "react-intl";
6
+ import { Icon, Loader, Message } from "semantic-ui-react";
7
+ import { defaultColumnsForStructureSelector } from "@truedat/dd/selectors";
8
+ import StructuresSearchResults from "@truedat/dd/components/StructuresSearchResults";
9
+ import useAssistantSocket from "./assistant/hooks/useAssistantSocket";
10
+ import ThinkingOutLoud from "./ThinkingOutLoud";
11
+ import { useAgentLayerRun } from "../hooks/useAgentLayerRun";
12
+ import {
13
+ reasonColumnDefinition,
14
+ similarityColumnDefinition,
15
+ } from "./structureSuggestionColumns";
16
+
17
+ export const StructureSuggestions = ({
18
+ selectedStructure,
19
+ handleSelectedStructure,
20
+ selectable,
21
+ }) => {
22
+ const { business_concept_id: id } = useParams();
23
+ const { state } = useLocation();
24
+ const prompt = state?.prompt;
25
+
26
+ const baseColumns = useSelector(defaultColumnsForStructureSelector);
27
+ const columns = useMemo(
28
+ () => [
29
+ ...baseColumns,
30
+ similarityColumnDefinition,
31
+ reasonColumnDefinition,
32
+ ],
33
+ [baseColumns]
34
+ );
35
+
36
+ const { trigger: fetchSuggestions, data: runData } = useAgentLayerRun();
37
+
38
+ const [logItems, setLogItems] = useState([]);
39
+ const [structures, setStructures] = useState(null);
40
+ const [hasError, setHasError] = useState(false);
41
+
42
+ const agentStateId = runData?.data?.data?.agent_state_id
43
+ ? String(runData.data.data.agent_state_id)
44
+ : null;
45
+
46
+ useEffect(() => {
47
+ if (id) {
48
+ fetchSuggestions({
49
+ workflow: "suggest_business_concept_structures_links",
50
+ params: { business_concept_id: id, prompt },
51
+ });
52
+ }
53
+ }, [id, prompt]);
54
+
55
+ const handleServerMessage = useCallback((event, payload) => {
56
+ const priority = payload?.priority ?? null;
57
+ const source = payload?.source ?? null;
58
+
59
+ if (event === "stream" && Number(priority) === 0) {
60
+ const content = payload?.content;
61
+ let parsed;
62
+ try {
63
+ const raw = typeof content === "string" ? JSON.parse(content) : content;
64
+ parsed = Array.isArray(raw) ? raw : (raw?.result ?? []);
65
+ } catch {
66
+ parsed = [];
67
+ }
68
+ setStructures(parsed);
69
+ return;
70
+ }
71
+
72
+ if (event === "error" && Number(priority) !== 2) {
73
+ setHasError(true);
74
+ return;
75
+ }
76
+
77
+ if (event === "log" || (event === "error" && Number(priority) === 2)) {
78
+ const content =
79
+ event === "log"
80
+ ? (() => {
81
+ const log = payload?.content;
82
+ return log && typeof log === "object"
83
+ ? log.message ?? log.level ?? JSON.stringify(log)
84
+ : String(log ?? "");
85
+ })()
86
+ : (() => {
87
+ const err = payload?.error;
88
+ return err && typeof err === "object"
89
+ ? err.message ?? err.reason ?? JSON.stringify(err)
90
+ : String(err ?? "");
91
+ })();
92
+
93
+ setLogItems((prev) => [
94
+ ...prev,
95
+ { eventType: event, content: content || " ", priority, source },
96
+ ]);
97
+ }
98
+ }, []);
99
+
100
+ useAssistantSocket(!agentStateId, {
101
+ conversationId: agentStateId,
102
+ onServerMessage: handleServerMessage,
103
+ });
104
+
105
+ if (hasError) {
106
+ return (
107
+ <Message icon negative>
108
+ <Icon name="warning circle" />
109
+ <Message.Content>
110
+ <Message.Header>
111
+ <FormattedMessage id="structures.suggestions.error.header" />
112
+ </Message.Header>
113
+ <FormattedMessage id="structures.suggestions.error.body" />
114
+ </Message.Content>
115
+ </Message>
116
+ );
117
+ }
118
+
119
+ if (structures !== null) {
120
+ return (
121
+ <StructuresSearchResults
122
+ columns={columns}
123
+ size="small"
124
+ structures={structures}
125
+ selectedStructure={selectedStructure}
126
+ onSelect={handleSelectedStructure}
127
+ labelResults={false}
128
+ pagination={false}
129
+ selectable={selectable}
130
+ />
131
+ );
132
+ }
133
+
134
+ if (logItems.length === 0) {
135
+ return <Loader active inline="centered" />;
136
+ }
137
+
138
+ return <ThinkingOutLoud items={logItems} active={true} />;
139
+ };
140
+
141
+ StructureSuggestions.propTypes = {
142
+ handleSelectedStructure: PropTypes.func,
143
+ selectedStructure: PropTypes.object,
144
+ selectable: PropTypes.bool,
145
+ };
146
+
147
+ export default StructureSuggestions;
@@ -0,0 +1,157 @@
1
+ import { useState, useMemo, useRef, useEffect, useLayoutEffect } from "react";
2
+ import PropTypes from "prop-types";
3
+ import { Icon } from "semantic-ui-react";
4
+
5
+ /** Converts e.g. "workflow_normalizer_ff83ae63.agent_normalize_10015c7d" → "Agent Normalize" */
6
+ export function getSourceLabel(source) {
7
+ if (!source) return "";
8
+ const segment = source.includes(".") ? source.split(".").pop() : source;
9
+ const withoutUuid = segment.replace(/_[0-9a-f]{8,}$/i, "");
10
+ return withoutUuid
11
+ .split("_")
12
+ .filter(Boolean)
13
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
14
+ .join(" ");
15
+ }
16
+
17
+ /**
18
+ * Groups flat log items into sections by priority-1 source.
19
+ * priority-2 log items are nested under the last priority-1 section.
20
+ * `error` events with priority 2 are inline errors in the thinking block (styled in red).
21
+ */
22
+ function buildSections(logItems) {
23
+ const sections = [];
24
+ let current = null;
25
+ logItems.forEach((item) => {
26
+ const p = item.priority ?? 1;
27
+ const isThinkingInlineError = item.eventType === "error" && Number(p) === 2;
28
+ if (isThinkingInlineError) {
29
+ if (!current) {
30
+ current = { source: null, label: "Processing", items: [] };
31
+ }
32
+ current.items.push({ ...item, level: 3 });
33
+ return;
34
+ }
35
+ const level = p <= 1 ? 1 : 2;
36
+ if (level === 1) {
37
+ if (!current || current.source !== item.source) {
38
+ if (current) sections.push(current);
39
+ current = {
40
+ source: item.source,
41
+ label: getSourceLabel(item.source),
42
+ items: [],
43
+ };
44
+ }
45
+ current.items.push({ ...item, level: 1 });
46
+ } else {
47
+ if (!current) {
48
+ current = { source: null, label: "Processing", items: [] };
49
+ }
50
+ current.items.push({ ...item, level: 2 });
51
+ }
52
+ });
53
+ if (current) sections.push(current);
54
+ return sections;
55
+ }
56
+
57
+ function ThinkingSection({ section, isLast, active, collapsed }) {
58
+ const [open, setOpen] = useState(!collapsed);
59
+ const bodyRef = useRef(null);
60
+ const isActive = active && isLast;
61
+
62
+ useEffect(() => {
63
+ if (collapsed) setOpen(false);
64
+ }, [collapsed]);
65
+
66
+ useLayoutEffect(() => {
67
+ if (!open) return;
68
+ const el = bodyRef.current;
69
+ if (!el) return;
70
+ el.scrollTop = el.scrollHeight;
71
+ }, [open, section.items]);
72
+
73
+ return (
74
+ <div className="thinking-section">
75
+ <button
76
+ type="button"
77
+ className="thinking-section__header"
78
+ onClick={() => setOpen((o) => !o)}
79
+ >
80
+ <Icon name={open ? "chevron down" : "chevron right"} />
81
+ <span className="thinking-section__label">{section.label}</span>
82
+ {isActive && (
83
+ <span className="thinking-section__spinner">
84
+ <span className="thinking-section__spinner-dot" />
85
+ </span>
86
+ )}
87
+ </button>
88
+ {open && (
89
+ <div ref={bodyRef} className="thinking-section__body">
90
+ {section.items.map((item, i) => (
91
+ <div
92
+ key={i}
93
+ className={`thinking-section__item thinking-section__item--${item.level}`}
94
+ >
95
+ {item.level === 2 && (
96
+ <span className="thinking-section__item-source">
97
+ {getSourceLabel(item.source)}:{" "}
98
+ </span>
99
+ )}
100
+ {item.level === 3 && item.source && (
101
+ <span className="thinking-section__item-source">
102
+ {getSourceLabel(item.source)}:{" "}
103
+ </span>
104
+ )}
105
+ {item.content}
106
+ </div>
107
+ ))}
108
+ </div>
109
+ )}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ ThinkingSection.propTypes = {
115
+ section: PropTypes.shape({
116
+ source: PropTypes.string,
117
+ label: PropTypes.string,
118
+ items: PropTypes.array,
119
+ }).isRequired,
120
+ isLast: PropTypes.bool,
121
+ active: PropTypes.bool,
122
+ collapsed: PropTypes.bool,
123
+ };
124
+
125
+ /**
126
+ * Renders streamed agent log lines (thinking out loud) grouped into collapsible sections.
127
+ * Expects items shaped like assistant log events: `eventType` "log" or inline "error" with priority 2.
128
+ */
129
+ const ThinkingOutLoud = ({ items, active, collapsed }) => {
130
+ const sections = useMemo(() => buildSections(items), [items]);
131
+ return (
132
+ <div className="thinking-block">
133
+ {sections.map((section, i) => (
134
+ <ThinkingSection
135
+ key={`${section.source ?? "unknown"}-${i}`}
136
+ section={section}
137
+ isLast={i === sections.length - 1}
138
+ active={active}
139
+ collapsed={collapsed}
140
+ />
141
+ ))}
142
+ </div>
143
+ );
144
+ };
145
+
146
+ ThinkingOutLoud.propTypes = {
147
+ items: PropTypes.array.isRequired,
148
+ active: PropTypes.bool,
149
+ collapsed: PropTypes.bool,
150
+ };
151
+
152
+ ThinkingOutLoud.defaultProps = {
153
+ active: false,
154
+ collapsed: false,
155
+ };
156
+
157
+ export default ThinkingOutLoud;
@@ -0,0 +1,290 @@
1
+ import { act, waitFor } from "@testing-library/react";
2
+ import { useParams, useLocation } from "react-router";
3
+ import { useSelector } from "react-redux";
4
+ import { render } from "@truedat/test/render";
5
+ import StructuresSearchResults from "@truedat/dd/components/StructuresSearchResults";
6
+ import useAssistantSocket from "../assistant/hooks/useAssistantSocket";
7
+ import ThinkingOutLoud from "../ThinkingOutLoud";
8
+ import { useAgentLayerRun } from "../../hooks/useAgentLayerRun";
9
+ import StructureSuggestions from "../StructureSuggestions";
10
+
11
+ jest.mock("react-router", () => ({
12
+ ...jest.requireActual("react-router"),
13
+ useParams: jest.fn(),
14
+ useLocation: jest.fn(),
15
+ }));
16
+
17
+ jest.mock("react-redux", () => ({
18
+ ...jest.requireActual("react-redux"),
19
+ useSelector: jest.fn(),
20
+ }));
21
+
22
+ jest.mock("@truedat/dd/selectors", () => ({
23
+ defaultColumnsForStructureSelector: jest.fn(),
24
+ }));
25
+
26
+ jest.mock("@truedat/dd/components/StructuresSearchResults", () =>
27
+ jest.fn(() => <div>MockSearchResults</div>)
28
+ );
29
+
30
+ jest.mock("../ThinkingOutLoud", () => ({
31
+ __esModule: true,
32
+ default: jest.fn(() => <div>MockThinkingOutLoud</div>),
33
+ getSourceLabel: jest.fn((s) => s),
34
+ }));
35
+
36
+ jest.mock("../assistant/hooks/useAssistantSocket", () => jest.fn());
37
+
38
+ jest.mock("../../hooks/useAgentLayerRun", () => ({
39
+ useAgentLayerRun: jest.fn(),
40
+ }));
41
+
42
+ describe("StructureSuggestions", () => {
43
+ const mockFetchSuggestions = jest.fn();
44
+ const mockColumns = [{ name: "name", fieldSelector: (x) => x.name }];
45
+
46
+ beforeEach(() => {
47
+ jest.clearAllMocks();
48
+
49
+ useParams.mockReturnValue({ business_concept_id: "123" });
50
+ useLocation.mockReturnValue({ state: { prompt: "find structures" } });
51
+ useSelector.mockReturnValue(mockColumns);
52
+ useAssistantSocket.mockImplementation(() => {});
53
+
54
+ useAgentLayerRun.mockReturnValue({
55
+ trigger: mockFetchSuggestions,
56
+ data: null,
57
+ });
58
+ });
59
+
60
+ it("calls fetchSuggestions on mount with business_concept_id and prompt", async () => {
61
+ render(
62
+ <StructureSuggestions
63
+ selectedStructure={null}
64
+ handleSelectedStructure={jest.fn()}
65
+ selectable={true}
66
+ />
67
+ );
68
+
69
+ await waitFor(() =>
70
+ expect(mockFetchSuggestions).toHaveBeenCalledWith({
71
+ workflow: "suggest_business_concept_structures_links",
72
+ params: { business_concept_id: "123", prompt: "find structures" },
73
+ })
74
+ );
75
+ });
76
+
77
+ it("calls fetchSuggestions without prompt when prompt is missing", async () => {
78
+ useLocation.mockReturnValue({ state: null });
79
+
80
+ render(
81
+ <StructureSuggestions
82
+ selectedStructure={null}
83
+ handleSelectedStructure={jest.fn()}
84
+ selectable={true}
85
+ />
86
+ );
87
+
88
+ await waitFor(() =>
89
+ expect(mockFetchSuggestions).toHaveBeenCalledWith({
90
+ workflow: "suggest_business_concept_structures_links",
91
+ params: { business_concept_id: "123", prompt: undefined },
92
+ })
93
+ );
94
+ });
95
+
96
+ it("shows spinner while waiting for first log item", () => {
97
+ useAgentLayerRun.mockReturnValue({
98
+ trigger: mockFetchSuggestions,
99
+ data: { data: { data: { agent_state_id: "state-1" } } },
100
+ });
101
+
102
+ const rendered = render(
103
+ <StructureSuggestions
104
+ selectedStructure={null}
105
+ handleSelectedStructure={jest.fn()}
106
+ selectable={true}
107
+ />
108
+ );
109
+
110
+ expect(rendered.queryByText(/mockthinkingoutloud/i)).not.toBeInTheDocument();
111
+ expect(rendered.queryByText(/mocksearchresults/i)).not.toBeInTheDocument();
112
+ });
113
+
114
+ it("shows ThinkingOutLoud once log items arrive", async () => {
115
+ let capturedOnServerMessage;
116
+
117
+ useAssistantSocket.mockImplementation((_, { onServerMessage }) => {
118
+ capturedOnServerMessage = onServerMessage;
119
+ });
120
+
121
+ useAgentLayerRun.mockReturnValue({
122
+ trigger: mockFetchSuggestions,
123
+ data: { data: { data: { agent_state_id: "state-1" } } },
124
+ });
125
+
126
+ const rendered = render(
127
+ <StructureSuggestions
128
+ selectedStructure={null}
129
+ handleSelectedStructure={jest.fn()}
130
+ selectable={true}
131
+ />
132
+ );
133
+
134
+ act(() => {
135
+ capturedOnServerMessage("log", {
136
+ priority: 1,
137
+ source: "agent_x",
138
+ content: "Processing...",
139
+ });
140
+ });
141
+
142
+ await waitFor(() =>
143
+ expect(rendered.queryByText(/mockthinkingoutloud/i)).toBeInTheDocument()
144
+ );
145
+ });
146
+
147
+ it("disables socket when agent_state_id is not yet available", () => {
148
+ render(
149
+ <StructureSuggestions
150
+ selectedStructure={null}
151
+ handleSelectedStructure={jest.fn()}
152
+ selectable={true}
153
+ />
154
+ );
155
+
156
+ expect(useAssistantSocket).toHaveBeenCalledWith(
157
+ true,
158
+ expect.objectContaining({ conversationId: null })
159
+ );
160
+ });
161
+
162
+ it("connects socket with agent_state_id when available", () => {
163
+ useAgentLayerRun.mockReturnValue({
164
+ trigger: mockFetchSuggestions,
165
+ data: { data: { data: { agent_state_id: "state-1" } } },
166
+ });
167
+
168
+ render(
169
+ <StructureSuggestions
170
+ selectedStructure={null}
171
+ handleSelectedStructure={jest.fn()}
172
+ selectable={true}
173
+ />
174
+ );
175
+
176
+ expect(useAssistantSocket).toHaveBeenCalledWith(
177
+ false,
178
+ expect.objectContaining({ conversationId: "state-1" })
179
+ );
180
+ });
181
+
182
+ it("shows StructuresSearchResults when stream priority-0 arrives", async () => {
183
+ const structures = [{ id: "s1" }, { id: "s2" }];
184
+ let capturedOnServerMessage;
185
+
186
+ useAssistantSocket.mockImplementation((_, { onServerMessage }) => {
187
+ capturedOnServerMessage = onServerMessage;
188
+ });
189
+
190
+ useAgentLayerRun.mockReturnValue({
191
+ trigger: mockFetchSuggestions,
192
+ data: { data: { data: { agent_state_id: "state-1" } } },
193
+ });
194
+
195
+ const rendered = render(
196
+ <StructureSuggestions
197
+ selectedStructure={null}
198
+ handleSelectedStructure={jest.fn()}
199
+ selectable={false}
200
+ />
201
+ );
202
+
203
+ act(() => {
204
+ capturedOnServerMessage("stream", {
205
+ priority: 0,
206
+ content: JSON.stringify({ result: structures }),
207
+ });
208
+ });
209
+
210
+ await waitFor(() =>
211
+ expect(rendered.queryByText(/mocksearchresults/i)).toBeInTheDocument()
212
+ );
213
+
214
+ expect(rendered.queryByText(/mockthinkingoutloud/i)).not.toBeInTheDocument();
215
+
216
+ const props = StructuresSearchResults.mock.calls.at(-1)[0];
217
+ expect(props.structures).toEqual(structures);
218
+ expect(props.selectable).toBe(false);
219
+ });
220
+
221
+ it("shows error message when error priority-0 arrives", async () => {
222
+ let capturedOnServerMessage;
223
+
224
+ useAssistantSocket.mockImplementation((_, { onServerMessage }) => {
225
+ capturedOnServerMessage = onServerMessage;
226
+ });
227
+
228
+ useAgentLayerRun.mockReturnValue({
229
+ trigger: mockFetchSuggestions,
230
+ data: { data: { data: { agent_state_id: "state-1" } } },
231
+ });
232
+
233
+ const rendered = render(
234
+ <StructureSuggestions
235
+ selectedStructure={null}
236
+ handleSelectedStructure={jest.fn()}
237
+ selectable={true}
238
+ />
239
+ );
240
+
241
+ act(() => {
242
+ capturedOnServerMessage("error", {
243
+ priority: 0,
244
+ error: { message: "Something failed" },
245
+ });
246
+ });
247
+
248
+ await waitFor(() =>
249
+ expect(rendered.queryByText(/mockthinkingoutloud/i)).not.toBeInTheDocument()
250
+ );
251
+
252
+ expect(rendered.queryByText(/mocksearchresults/i)).not.toBeInTheDocument();
253
+ });
254
+
255
+ it("adds log events to ThinkingOutLoud items", async () => {
256
+ let capturedOnServerMessage;
257
+
258
+ useAssistantSocket.mockImplementation((_, { onServerMessage }) => {
259
+ capturedOnServerMessage = onServerMessage;
260
+ });
261
+
262
+ useAgentLayerRun.mockReturnValue({
263
+ trigger: mockFetchSuggestions,
264
+ data: { data: { data: { agent_state_id: "state-1" } } },
265
+ });
266
+
267
+ render(
268
+ <StructureSuggestions
269
+ selectedStructure={null}
270
+ handleSelectedStructure={jest.fn()}
271
+ selectable={true}
272
+ />
273
+ );
274
+
275
+ act(() => {
276
+ capturedOnServerMessage("log", {
277
+ priority: 1,
278
+ source: "agent_x",
279
+ content: "Processing...",
280
+ });
281
+ });
282
+
283
+ await waitFor(() => {
284
+ const lastCall = ThinkingOutLoud.mock.calls.at(-1)[0];
285
+ expect(lastCall.items).toHaveLength(1);
286
+ expect(lastCall.items[0].content).toBe("Processing...");
287
+ expect(lastCall.items[0].eventType).toBe("log");
288
+ });
289
+ });
290
+ });