@truedat/ai 8.8.7 → 8.8.8

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.8.7",
3
+ "version": "8.8.8",
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.8.7",
53
+ "@truedat/test": "8.8.8",
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": "ece3f2e166776ff7827198a134bad5a28b029dc7"
83
+ "gitHead": "850b1a20250c39f4781aea0174aeebd383898eea"
84
84
  }
package/src/api.js CHANGED
@@ -10,9 +10,6 @@ const API_PROMPT_SET_ACTIVE = "/api/prompts/:id/set_active";
10
10
  const API_PROVIDERS = "/api/providers";
11
11
  const API_PROVIDER = "/api/providers/:id";
12
12
  const API_PROVIDER_CHAT = "/api/providers/:id/chat_completion";
13
- const API_SUGGESTIONS_AVAILABILITY_CHECK =
14
- "/api/suggestions/availability_check";
15
- const API_SUGGESTIONS_REQUEST = "/api/suggestions/request";
16
13
  const API_TRANSLATIONS_AVAILABILITY_CHECK =
17
14
  "/api/translations/availability_check";
18
15
  const API_TRANSLATIONS_REQUEST = "/api/translations/request";
@@ -35,8 +32,6 @@ export {
35
32
  API_PROVIDERS,
36
33
  API_PROVIDER,
37
34
  API_PROVIDER_CHAT,
38
- API_SUGGESTIONS_AVAILABILITY_CHECK,
39
- API_SUGGESTIONS_REQUEST,
40
35
  API_TRANSLATIONS_AVAILABILITY_CHECK,
41
36
  API_TRANSLATIONS_REQUEST,
42
37
  API_AGENT_LAYER_CONVERSATION,
@@ -1,31 +1,98 @@
1
1
  import _ from "lodash/fp";
2
- import { useState } from "react";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import PropTypes from "prop-types";
4
4
  import {
5
5
  Button,
6
6
  Checkbox,
7
7
  Container,
8
8
  Divider,
9
+ Form,
9
10
  Icon,
10
11
  List,
11
12
  Message,
12
13
  Segment,
13
14
  Popup,
14
15
  } from "semantic-ui-react";
15
- import { FormattedMessage } from "react-intl";
16
+ import { FormattedMessage, useIntl } from "react-intl";
17
+ import { MarkdownReader } from "@truedat/core/components";
18
+ import { useWebContext } from "@truedat/core/webContext";
19
+ import { useAgentLayerRun } from "../../hooks/useAgentLayerRun";
20
+ import { useAvailabilityCheck } from "../../hooks/useSuggestions";
21
+ import useAssistantSocket from "../assistant/hooks/useAssistantSocket";
22
+ import ThinkingOutLoud from "../ThinkingOutLoud";
23
+
24
+ const parseStreamResult = (content) => {
25
+ try {
26
+ const raw = typeof content === "string" ? JSON.parse(content) : content;
27
+ return raw?.result ?? raw ?? {};
28
+ } catch {
29
+ return {};
30
+ }
31
+ };
32
+
33
+ const formatLogContent = (log) =>
34
+ log && typeof log === "object"
35
+ ? (log.message ?? log.level ?? JSON.stringify(log))
36
+ : String(log ?? "");
37
+
38
+ const formatErrorContent = (err) =>
39
+ err && typeof err === "object"
40
+ ? (err.message ?? err.reason ?? JSON.stringify(err))
41
+ : String(err ?? "");
16
42
 
17
43
  export default function SuggestionsWidget({
18
- requestAiSuggestion,
44
+ suggestionParams,
19
45
  applySuggestions,
20
46
  template,
21
47
  isModification,
22
48
  enableRequestButton = true,
23
49
  disabledButtonMessage = "",
24
50
  }) {
25
- const [loadingSuggestion, setLoadingSuggestion] = useState();
51
+ const { formatMessage } = useIntl();
52
+
53
+ const [requested, setRequested] = useState(false);
54
+ const [promptPopupOpen, setPromptPopupOpen] = useState(false);
55
+ const [prompt, setPrompt] = useState("");
26
56
  const [suggestions, setSuggestions] = useState();
27
57
  const [suggestionsError, setSuggestionsError] = useState();
28
58
  const [selectedSuggestions, setSelectedSuggestions] = useState([]);
59
+ const [logItems, setLogItems] = useState([]);
60
+
61
+ const { disable_td_ai } = useWebContext();
62
+ const { trigger: checkAvailability } = useAvailabilityCheck();
63
+ const availabilityRequestId = useRef(0);
64
+ const [available, setAvailable] = useState(false);
65
+
66
+ const availabilityTemplateId = suggestionParams?.template_id;
67
+ const availabilityResourceType = suggestionParams?.resource_type;
68
+ const availabilityDomainIds = suggestionParams?.domain_ids;
69
+ const availabilityDomainKey = JSON.stringify(availabilityDomainIds ?? []);
70
+
71
+ useEffect(() => {
72
+ if (disable_td_ai || !availabilityTemplateId) {
73
+ setAvailable(false);
74
+ return;
75
+ }
76
+ // Only apply the latest response; params can change while a check is pending.
77
+ availabilityRequestId.current += 1;
78
+ const requestId = availabilityRequestId.current;
79
+ setAvailable(false);
80
+ checkAvailability({
81
+ template_id: availabilityTemplateId,
82
+ domain_ids: availabilityDomainIds || [],
83
+ resource_type: availabilityResourceType,
84
+ }).then((res) => {
85
+ if (availabilityRequestId.current === requestId) {
86
+ setAvailable(res.available);
87
+ }
88
+ });
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ }, [
91
+ disable_td_ai,
92
+ availabilityTemplateId,
93
+ availabilityResourceType,
94
+ availabilityDomainKey,
95
+ ]);
29
96
 
30
97
  const suggestionKeys = _.keys(suggestions);
31
98
  const fieldInSuggestions = ({ name }) => _.includes(name)(suggestionKeys);
@@ -49,33 +116,82 @@ export default function SuggestionsWidget({
49
116
  )(template);
50
117
 
51
118
  const validFields = _.keys(fieldTypes);
119
+ // Keeps handleServerMessage's identity stable (see below) while still
120
+ // reading the latest validFields when a stream result arrives.
121
+ const validFieldsRef = useRef(validFields);
122
+ validFieldsRef.current = validFields;
52
123
 
53
- const handleRequestSuggestions = () => {
54
- setLoadingSuggestion(true);
55
- setSuggestionsError(null);
56
- setSelectedSuggestions([]);
57
- setSuggestions(null);
124
+ const { trigger: fetchSuggestions, data: runData } = useAgentLayerRun();
125
+ const agentStateId = runData?.data?.data?.agent_state_id
126
+ ? String(runData.data.data.agent_state_id)
127
+ : null;
128
+
129
+ const handleServerMessage = useCallback((event, payload) => {
130
+ const priority = payload?.priority ?? null;
58
131
 
59
- requestAiSuggestion(({ data: { data: suggestions } }) => {
60
- if (_.prop("[0]")(suggestions) === "error") {
61
- setSuggestionsError(
62
- _.prop("[1].error.message")(suggestions) ||
63
- _.prop("[1]")(suggestions),
64
- );
65
- } else {
66
- setSuggestions(suggestions);
67
- const selectedFields = _.flow(
132
+ if (event === "stream" && Number(priority) === 0) {
133
+ const parsed = parseStreamResult(payload?.content);
134
+ setSuggestions(parsed);
135
+ setSelectedSuggestions(
136
+ _.flow(
68
137
  _.keys,
69
138
  _.filter(
70
139
  (key) =>
71
- _.includes(key)(validFields) && !_.isEmpty(suggestions[key]),
140
+ _.includes(key)(validFieldsRef.current) &&
141
+ !_.isEmpty(parsed[key]),
72
142
  ),
73
- )(suggestions);
74
- setSelectedSuggestions(selectedFields);
75
- }
76
- setLoadingSuggestion(false);
143
+ )(parsed),
144
+ );
145
+ return;
146
+ }
147
+
148
+ if (event === "error" && Number(priority) !== 2) {
149
+ setSuggestionsError(formatErrorContent(payload?.error));
150
+ return;
151
+ }
152
+
153
+ if (event === "log" || (event === "error" && Number(priority) === 2)) {
154
+ const source = payload?.source ?? null;
155
+ const content =
156
+ event === "log"
157
+ ? formatLogContent(payload?.content)
158
+ : formatErrorContent(payload?.error);
159
+
160
+ setLogItems((prev) => [
161
+ ...prev,
162
+ { eventType: event, content: content || " ", priority, source },
163
+ ]);
164
+ }
165
+ }, []);
166
+
167
+ useAssistantSocket(!agentStateId, {
168
+ conversationId: agentStateId,
169
+ onServerMessage: handleServerMessage,
170
+ });
171
+
172
+ const inProgress = requested && !suggestions && !suggestionsError;
173
+ const requestDisabled = !enableRequestButton || inProgress;
174
+
175
+ const resetState = () => {
176
+ setRequested(false);
177
+ setSuggestions(null);
178
+ setSuggestionsError(null);
179
+ setSelectedSuggestions([]);
180
+ setLogItems([]);
181
+ };
182
+
183
+ const handleRequestSuggestions = (requestPrompt) => {
184
+ setSuggestionsError(null);
185
+ setSelectedSuggestions([]);
186
+ setSuggestions(null);
187
+ setLogItems([]);
188
+ setRequested(true);
189
+ fetchSuggestions({
190
+ workflow: "suggest_content",
191
+ params: { ...suggestionParams, prompt: requestPrompt },
77
192
  });
78
193
  };
194
+
79
195
  const handleApplySuggestions = () => {
80
196
  _.flow(
81
197
  _.pick(selectedSuggestions),
@@ -91,8 +207,7 @@ export default function SuggestionsWidget({
91
207
  applySuggestions,
92
208
  )(suggestions);
93
209
 
94
- setSelectedSuggestions([]);
95
- setSuggestions(null);
210
+ resetState();
96
211
  };
97
212
 
98
213
  const toggleSelectedSuggestion = (name) => {
@@ -103,8 +218,66 @@ export default function SuggestionsWidget({
103
218
  );
104
219
  };
105
220
 
221
+ const renderRequestControl = () => {
222
+ if (!enableRequestButton && disabledButtonMessage) {
223
+ return (
224
+ <Popup
225
+ content={disabledButtonMessage}
226
+ trigger={
227
+ <span>
228
+ <Button disabled>
229
+ <FormattedMessage id="actions.ai_suggestion" />
230
+ </Button>
231
+ </span>
232
+ }
233
+ />
234
+ );
235
+ }
236
+ return (
237
+ <Popup
238
+ on="click"
239
+ open={promptPopupOpen}
240
+ onOpen={() => setPromptPopupOpen(true)}
241
+ onClose={() => setPromptPopupOpen(false)}
242
+ disabled={requestDisabled}
243
+ content={
244
+ <Form style={{ minWidth: "300px" }}>
245
+ <Form.Field>
246
+ <label>{formatMessage({ id: "suggestions.prompt.label" })}</label>
247
+ <Form.Input
248
+ value={prompt}
249
+ onChange={(_e, { value }) => setPrompt(value)}
250
+ placeholder={formatMessage({
251
+ id: "suggestions.prompt.placeholder",
252
+ })}
253
+ />
254
+ </Form.Field>
255
+ <Button
256
+ primary
257
+ content={formatMessage({ id: "suggestions.prompt.submit" })}
258
+ onClick={() => {
259
+ setPromptPopupOpen(false);
260
+ handleRequestSuggestions(prompt);
261
+ }}
262
+ />
263
+ </Form>
264
+ }
265
+ position="bottom center"
266
+ trigger={
267
+ <span>
268
+ <Button disabled={requestDisabled}>
269
+ <FormattedMessage id="actions.ai_suggestion" />
270
+ </Button>
271
+ </span>
272
+ }
273
+ />
274
+ );
275
+ };
276
+
277
+ if (!available) return null;
278
+
106
279
  return (
107
- <Segment loading={loadingSuggestion}>
280
+ <Segment>
108
281
  <Container style={{ display: "flex", justifyContent: "space-between" }}>
109
282
  <h4>
110
283
  {suggestions ? (
@@ -115,33 +288,27 @@ export default function SuggestionsWidget({
115
288
  <FormattedMessage id="structure_note.ai_suggestion.header" />
116
289
  </h4>
117
290
  <Divider hidden />
118
- {suggestions ? null : !enableRequestButton && disabledButtonMessage ? (
119
- <Popup
120
- content={disabledButtonMessage}
121
- trigger={
122
- <span>
123
- <Button onClick={handleRequestSuggestions} disabled>
124
- <FormattedMessage id="actions.ai_suggestion" />
125
- </Button>
126
- </span>
127
- }
128
- />
129
- ) : (
130
- <Button
131
- onClick={handleRequestSuggestions}
132
- disabled={!enableRequestButton}
133
- >
134
- <FormattedMessage id="actions.ai_suggestion" />
135
- </Button>
136
- )}
291
+ {renderRequestControl()}
137
292
  </Container>
138
293
  {suggestionsError ? (
139
- <Message negative>
140
- <Message.Header>
141
- <FormattedMessage id="structure_note.ai_suggestion.error" />
142
- </Message.Header>
143
- <p>{suggestionsError}</p>
144
- </Message>
294
+ <>
295
+ <Message negative>
296
+ <Message.Header>
297
+ <FormattedMessage id="structure_note.ai_suggestion.error" />
298
+ </Message.Header>
299
+ <p>{suggestionsError}</p>
300
+ </Message>
301
+ <Container textAlign="right">
302
+ <Button basic onClick={resetState}>
303
+ <FormattedMessage id="actions.discard_ai_suggestion" />
304
+ </Button>
305
+ </Container>
306
+ </>
307
+ ) : null}
308
+ {inProgress ? (
309
+ <Segment loading={logItems.length === 0} basic>
310
+ <ThinkingOutLoud items={logItems} active />
311
+ </Segment>
145
312
  ) : null}
146
313
  {suggestions ? (
147
314
  <>
@@ -150,12 +317,12 @@ export default function SuggestionsWidget({
150
317
  <>
151
318
  <h5>{groupName ? groupName : ""}</h5>
152
319
  <List>
153
- {_.map(({ name, label, editable = true }) => (
320
+ {_.map(({ name, label, editable = true, widget }) => (
154
321
  <List.Item
155
322
  key={name}
156
323
  style={{
157
324
  display: "flex",
158
- alignItems: "center",
325
+ alignItems: "flex-start",
159
326
  gap: "12px",
160
327
  }}
161
328
  >
@@ -175,9 +342,13 @@ export default function SuggestionsWidget({
175
342
  >
176
343
  <List.Header>{label}</List.Header>
177
344
  <List.Description>
178
- {_.isEmpty(suggestions[name])
179
- ? "-"
180
- : suggestions[name]}
345
+ {_.isEmpty(suggestions[name]) ? (
346
+ "-"
347
+ ) : widget === "markdown" ? (
348
+ <MarkdownReader content={suggestions[name]} />
349
+ ) : (
350
+ suggestions[name]
351
+ )}
181
352
  </List.Description>
182
353
  </label>
183
354
  </List.Item>
@@ -188,6 +359,13 @@ export default function SuggestionsWidget({
188
359
  </Container>
189
360
  ))(templateFields)}
190
361
  <Container textAlign="right">
362
+ <Button
363
+ className="td-icon-text-control"
364
+ secondary
365
+ onClick={resetState}
366
+ >
367
+ <FormattedMessage id="actions.discard_ai_suggestion" />
368
+ </Button>
191
369
  <Button
192
370
  primary
193
371
  onClick={handleApplySuggestions}
@@ -203,7 +381,7 @@ export default function SuggestionsWidget({
203
381
  }
204
382
 
205
383
  SuggestionsWidget.propTypes = {
206
- requestAiSuggestion: PropTypes.func,
384
+ suggestionParams: PropTypes.object,
207
385
  applySuggestions: PropTypes.func,
208
386
  template: PropTypes.object,
209
387
  isModification: PropTypes.bool,
@@ -1,153 +1,442 @@
1
- import { Suspense } from "react";
2
- import { waitFor } from "@testing-library/react";
1
+ import { act, waitFor } from "@testing-library/react";
3
2
  import userEvent from "@testing-library/user-event";
4
3
  import { render } from "@truedat/test/render";
4
+ import useAssistantSocket from "../../assistant/hooks/useAssistantSocket";
5
+ import { useAgentLayerRun } from "../../../hooks/useAgentLayerRun";
6
+ import { useAvailabilityCheck } from "../../../hooks/useSuggestions";
5
7
  import SuggestionsWidget from "../SuggestionsWidget";
6
8
 
7
- const mockSuggestions = {
8
- editable_field: "editable_field_value",
9
- non_editable_field: "non_editable_field_value",
10
- unselect_field: "unselect_field_value",
11
- };
9
+ jest.mock("../../assistant/hooks/useAssistantSocket", () => jest.fn());
10
+
11
+ jest.mock("../../../hooks/useAgentLayerRun", () => ({
12
+ useAgentLayerRun: jest.fn(),
13
+ }));
14
+
15
+ jest.mock("../../../hooks/useSuggestions", () => ({
16
+ useAvailabilityCheck: jest.fn(),
17
+ }));
18
+
19
+ const mockCheckAvailability = jest.fn();
12
20
 
13
21
  const messages = {
14
22
  en: {
15
- "actions.cancel": "cancel",
16
- "actions.save": "save",
17
- "structure_note.ai_suggestion.header": "header",
18
23
  "actions.apply_ai_suggestion": "actions.apply_ai_suggestion",
24
+ "actions.discard_ai_suggestion": "actions.discard_ai_suggestion",
19
25
  "actions.ai_suggestion": "actions.ai_suggestion",
26
+ "structure_note.ai_suggestion.header": "header",
20
27
  "structure_note.ai_suggestion.error": "error",
28
+ "suggestions.prompt.label": "suggestions.prompt.label",
29
+ "suggestions.prompt.placeholder": "suggestions.prompt.placeholder",
30
+ "suggestions.prompt.submit": "suggestions.prompt.submit",
21
31
  },
22
32
  };
23
33
 
24
- const requestAiSuggestion = (mock) => (callback) => {
25
- callback({
26
- data: {
27
- data:
28
- mock === "suggestions"
29
- ? mockSuggestions
30
- : mock === "error_message"
31
- ? ["error", { error: { message: "ERROR MESSAGE" } }]
32
- : ["error", "ERROR TEXT"],
34
+ const template = {
35
+ content: [
36
+ {
37
+ fields: [
38
+ { name: "editable_field", label: "EditableField", editable: true },
39
+ ],
33
40
  },
34
- });
41
+ {
42
+ name: "Group2",
43
+ fields: [
44
+ {
45
+ name: "non_editable_field",
46
+ label: "NonEditableField",
47
+ editable: false,
48
+ },
49
+ {
50
+ name: "unselect_field",
51
+ label: "UnselectedField",
52
+ editable: true,
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ };
58
+
59
+ const mockSuggestions = {
60
+ editable_field: "editable_field_value",
61
+ non_editable_field: "non_editable_field_value",
62
+ unselect_field: "unselect_field_value",
63
+ };
64
+
65
+ const user = userEvent.setup({ delay: null });
66
+
67
+ const requestSuggestions = async (findByText) => {
68
+ await user.click(await findByText(/actions.ai_suggestion/i));
69
+ await user.click(await findByText(/suggestions.prompt.submit/i));
70
+ };
71
+
72
+ // Reads the onServerMessage callback the component most recently registered
73
+ // with the mocked useAssistantSocket, instead of capturing it via a `let`.
74
+ const emitServerMessage = (event, payload) => {
75
+ const [, options] = useAssistantSocket.mock.calls.at(-1);
76
+ act(() => options.onServerMessage(event, payload));
35
77
  };
36
78
 
37
79
  describe("<SuggestionsWidget />", () => {
38
- it("matches the latest snapshot", () => {
80
+ const mockFetchSuggestions = jest.fn();
81
+
82
+ beforeEach(() => {
83
+ jest.clearAllMocks();
84
+ useAgentLayerRun.mockReturnValue({
85
+ trigger: mockFetchSuggestions,
86
+ data: null,
87
+ });
88
+ mockCheckAvailability.mockResolvedValue({ available: true });
89
+ useAvailabilityCheck.mockReturnValue({ trigger: mockCheckAvailability });
90
+ });
91
+
92
+ it("renders nothing when suggestions are not available", async () => {
93
+ mockCheckAvailability.mockResolvedValue({ available: false });
39
94
  const props = {
40
- template: {},
41
- requestAiSuggestion: requestAiSuggestion("suggestions"),
95
+ template,
96
+ suggestionParams: { template_id: 1, resource_type: "data_structure" },
42
97
  };
43
- const { container } = render(
44
- <Suspense fallback={null}>
45
- <SuggestionsWidget {...props} />
46
- </Suspense>,
98
+ const { queryByText } = render(<SuggestionsWidget {...props} />, {
99
+ messages,
100
+ });
101
+
102
+ await waitFor(() =>
103
+ expect(mockCheckAvailability).toHaveBeenCalledWith({
104
+ template_id: 1,
105
+ domain_ids: [],
106
+ resource_type: "data_structure",
107
+ }),
108
+ );
109
+ expect(queryByText(/actions.ai_suggestion/i)).not.toBeInTheDocument();
110
+ });
111
+
112
+ it("ignores a stale availability response when the params change", async () => {
113
+ const notAllowed = Promise.resolve({ available: false });
114
+ // template 1 is allowed but settles after template 2's "not allowed"
115
+ mockCheckAvailability
116
+ .mockReturnValueOnce(notAllowed.then(() => ({ available: true })))
117
+ .mockReturnValueOnce(notAllowed);
118
+
119
+ const { rerender, queryByText } = render(
120
+ <SuggestionsWidget
121
+ template={template}
122
+ suggestionParams={{ template_id: 1, resource_type: "data_structure" }}
123
+ />,
47
124
  { messages },
48
125
  );
49
- expect(container).toMatchSnapshot();
126
+
127
+ rerender(
128
+ <SuggestionsWidget
129
+ template={template}
130
+ suggestionParams={{ template_id: 2, resource_type: "data_structure" }}
131
+ />,
132
+ );
133
+
134
+ // Flush both availability responses (incl. the late, stale "allowed" one).
135
+ await act(async () => {
136
+ await Promise.resolve();
137
+ await Promise.resolve();
138
+ await Promise.resolve();
139
+ });
140
+
141
+ expect(queryByText(/actions.ai_suggestion/i)).not.toBeInTheDocument();
142
+ });
143
+
144
+ it("requests suggestions with suggestionParams and the entered prompt", async () => {
145
+ const props = { template, suggestionParams: { template_id: 7 } };
146
+ const { findByText } = render(<SuggestionsWidget {...props} />, {
147
+ messages,
148
+ });
149
+
150
+ await requestSuggestions(findByText);
151
+
152
+ await waitFor(() =>
153
+ expect(mockFetchSuggestions).toHaveBeenCalledWith({
154
+ workflow: "suggest_content",
155
+ params: { template_id: 7, prompt: "" },
156
+ }),
157
+ );
158
+ });
159
+
160
+ it("disables the request control when enableRequestButton is false", async () => {
161
+ const props = {
162
+ template,
163
+ suggestionParams: { template_id: 1 },
164
+ enableRequestButton: false,
165
+ disabledButtonMessage: "name required",
166
+ };
167
+ const { findByText } = render(<SuggestionsWidget {...props} />, {
168
+ messages,
169
+ });
170
+
171
+ const button = await findByText(/actions.ai_suggestion/i);
172
+ expect(button.closest("button")).toBeDisabled();
50
173
  });
51
174
 
52
- it("component lifecycle", async () => {
175
+ it("shows a spinner and disables the request control once requested", async () => {
176
+ const props = { template, suggestionParams: { template_id: 1 } };
177
+ const { findByText, container } = render(<SuggestionsWidget {...props} />, {
178
+ messages,
179
+ });
180
+
181
+ await requestSuggestions(findByText);
182
+
183
+ await waitFor(() => {
184
+ const button = container.querySelector("button");
185
+ expect(button).toBeDisabled();
186
+ expect(container.querySelector(".segment.loading")).toBeInTheDocument();
187
+ });
188
+ });
189
+
190
+ it("connects the socket using the agent_state_id returned by the run", async () => {
191
+ useAgentLayerRun.mockReturnValue({
192
+ trigger: mockFetchSuggestions,
193
+ data: { data: { data: { agent_state_id: "state-1" } } },
194
+ });
195
+
196
+ const props = { template, suggestionParams: { template_id: 1 } };
197
+ render(<SuggestionsWidget {...props} />, { messages });
198
+
199
+ expect(useAssistantSocket).toHaveBeenCalledWith(
200
+ false,
201
+ expect.objectContaining({ conversationId: "state-1" }),
202
+ );
203
+ });
204
+
205
+ it("renders selectable suggestions once a priority-0 stream event arrives", async () => {
206
+ useAgentLayerRun.mockReturnValue({
207
+ trigger: mockFetchSuggestions,
208
+ data: { data: { data: { agent_state_id: "state-1" } } },
209
+ });
210
+
53
211
  const applySuggestions = jest.fn();
54
212
  const props = {
55
- requestAiSuggestion: requestAiSuggestion("suggestions"),
213
+ template,
214
+ suggestionParams: { template_id: 1 },
56
215
  applySuggestions,
57
- template: {
58
- content: [
59
- {
60
- fields: [
61
- {
62
- name: "editable_field",
63
- label: "EditableField",
64
- editable: true,
65
- },
66
- ],
67
- },
68
- {
69
- name: "Group2",
70
- fields: [
71
- {
72
- name: "non_editable_field",
73
- label: "NonEditableField",
74
- editable: false,
75
- },
76
- {
77
- name: "unselect_field",
78
- label: "UnselectedField",
79
- editable: true,
80
- },
81
- ],
82
- },
83
- ],
84
- },
85
216
  isModification: true,
86
217
  };
87
- const { container, findByText, queryByText } = render(
88
- <Suspense fallback={null}>
89
- <SuggestionsWidget {...props} />
90
- </Suspense>,
218
+ const { findByText, queryByText } = render(
219
+ <SuggestionsWidget {...props} />,
91
220
  { messages },
92
221
  );
93
222
 
94
- userEvent.click(await findByText(/actions.ai_suggestion/i));
223
+ emitServerMessage("stream", {
224
+ priority: 0,
225
+ content: JSON.stringify({ result: mockSuggestions }),
226
+ });
227
+
95
228
  await waitFor(() =>
96
- expect(queryByText(/actions.ai_suggestion/i)).not.toBeInTheDocument(),
229
+ // exact text: a regex match here would also match "NonEditableField"
230
+ expect(queryByText("EditableField")).toBeInTheDocument(),
97
231
  );
98
232
 
99
- expect(container).toMatchSnapshot();
233
+ await user.click(await findByText("UnselectedField"));
234
+ await user.click(await findByText("UnselectedField"));
235
+ await user.click(await findByText("UnselectedField"));
100
236
 
101
- userEvent.click(await findByText(/UnselectedField/i));
102
- userEvent.click(await findByText(/UnselectedField/i));
103
- userEvent.click(await findByText(/UnselectedField/i));
237
+ await user.click(await findByText(/actions.apply_ai_suggestion/i));
104
238
 
105
- userEvent.click(await findByText(/actions.apply_ai_suggestion/i));
106
- await waitFor(() =>
107
- expect(
108
- queryByText(/actions.apply_ai_suggestion/i),
109
- ).not.toBeInTheDocument(),
110
- );
111
239
  expect(applySuggestions).toHaveBeenCalledWith(
112
240
  expect.objectContaining({
113
241
  editable_field: { origin: "ai", value: "editable_field_value" },
114
242
  }),
115
243
  );
244
+ });
245
+
246
+ it("renders markdown suggestion values with the markdown reader", async () => {
247
+ useAgentLayerRun.mockReturnValue({
248
+ trigger: mockFetchSuggestions,
249
+ data: { data: { data: { agent_state_id: "state-1" } } },
250
+ });
116
251
 
117
- expect(container).toMatchSnapshot();
252
+ const markdownTemplate = {
253
+ content: [
254
+ {
255
+ fields: [
256
+ {
257
+ name: "rich_field",
258
+ label: "RichField",
259
+ editable: true,
260
+ widget: "markdown",
261
+ },
262
+ ],
263
+ },
264
+ ],
265
+ };
266
+ const props = {
267
+ template: markdownTemplate,
268
+ suggestionParams: { template_id: 1 },
269
+ };
270
+ const { container, queryByText } = render(
271
+ <SuggestionsWidget {...props} />,
272
+ {
273
+ messages,
274
+ },
275
+ );
276
+
277
+ emitServerMessage("stream", {
278
+ priority: 0,
279
+ content: JSON.stringify({ result: { rich_field: "# Heading\n\nbody" } }),
280
+ });
281
+
282
+ await waitFor(() =>
283
+ expect(container.querySelector(".markdown-reader")).toBeInTheDocument(),
284
+ );
285
+ // rendered as HTML, so the raw markdown source is not present as text
286
+ expect(queryByText("# Heading")).not.toBeInTheDocument();
118
287
  });
119
288
 
120
- it("renders error with message", async () => {
289
+ it("returns to the request state after applying suggestions", async () => {
290
+ useAgentLayerRun.mockReturnValue({
291
+ trigger: mockFetchSuggestions,
292
+ data: { data: { data: { agent_state_id: "state-1" } } },
293
+ });
294
+
121
295
  const props = {
122
- requestAiSuggestion: requestAiSuggestion("error_message"),
296
+ template,
297
+ suggestionParams: { template_id: 1 },
298
+ applySuggestions: jest.fn(),
123
299
  };
124
- const { container, findByText, queryByText } = render(
125
- <Suspense fallback={null}>
126
- <SuggestionsWidget {...props} />
127
- </Suspense>,
300
+ const { findByText, queryByText } = render(
301
+ <SuggestionsWidget {...props} />,
302
+ { messages },
303
+ );
304
+
305
+ emitServerMessage("stream", {
306
+ priority: 0,
307
+ content: JSON.stringify({ result: mockSuggestions }),
308
+ });
309
+ await waitFor(() =>
310
+ expect(queryByText("EditableField")).toBeInTheDocument(),
311
+ );
312
+
313
+ await user.click(await findByText(/actions.apply_ai_suggestion/i));
314
+
315
+ await waitFor(() =>
316
+ expect(queryByText("EditableField")).not.toBeInTheDocument(),
317
+ );
318
+ const requestButton = (await findByText(/actions.ai_suggestion/i)).closest(
319
+ "button",
320
+ );
321
+ expect(requestButton).not.toBeDisabled();
322
+ });
323
+
324
+ it("discards the suggestions and returns to the request state", async () => {
325
+ useAgentLayerRun.mockReturnValue({
326
+ trigger: mockFetchSuggestions,
327
+ data: { data: { data: { agent_state_id: "state-1" } } },
328
+ });
329
+
330
+ const props = { template, suggestionParams: { template_id: 1 } };
331
+ const { findByText, queryByText } = render(
332
+ <SuggestionsWidget {...props} />,
333
+ { messages },
334
+ );
335
+
336
+ emitServerMessage("stream", {
337
+ priority: 0,
338
+ content: JSON.stringify({ result: mockSuggestions }),
339
+ });
340
+ await waitFor(() =>
341
+ expect(queryByText("EditableField")).toBeInTheDocument(),
342
+ );
343
+
344
+ await user.click(await findByText(/actions.discard_ai_suggestion/i));
345
+
346
+ await waitFor(() =>
347
+ expect(queryByText("EditableField")).not.toBeInTheDocument(),
348
+ );
349
+ expect(queryByText(/actions.apply_ai_suggestion/i)).not.toBeInTheDocument();
350
+ });
351
+
352
+ it("dismisses an error and returns to the request state", async () => {
353
+ useAgentLayerRun.mockReturnValue({
354
+ trigger: mockFetchSuggestions,
355
+ data: { data: { data: { agent_state_id: "state-1" } } },
356
+ });
357
+
358
+ const props = { template, suggestionParams: { template_id: 1 } };
359
+ const { findByText, queryByText } = render(
360
+ <SuggestionsWidget {...props} />,
361
+ { messages },
362
+ );
363
+
364
+ await requestSuggestions(findByText);
365
+ emitServerMessage("error", { priority: 0, error: "ERROR TEXT" });
366
+ await waitFor(() => expect(queryByText(/ERROR TEXT/i)).toBeInTheDocument());
367
+
368
+ await user.click(await findByText(/actions.discard_ai_suggestion/i));
369
+
370
+ await waitFor(() =>
371
+ expect(queryByText(/ERROR TEXT/i)).not.toBeInTheDocument(),
372
+ );
373
+ });
374
+
375
+ it("shows ThinkingOutLoud-driven log items while the agent is reasoning", async () => {
376
+ useAgentLayerRun.mockReturnValue({
377
+ trigger: mockFetchSuggestions,
378
+ data: { data: { data: { agent_state_id: "state-1" } } },
379
+ });
380
+
381
+ const props = { template, suggestionParams: { template_id: 1 } };
382
+ const { findByText, queryByText } = render(
383
+ <SuggestionsWidget {...props} />,
384
+ { messages },
385
+ );
386
+
387
+ await requestSuggestions(findByText);
388
+
389
+ emitServerMessage("log", {
390
+ priority: 1,
391
+ source: "agent_x",
392
+ content: "Processing...",
393
+ });
394
+
395
+ await waitFor(() =>
396
+ expect(queryByText(/Processing.../i)).toBeInTheDocument(),
397
+ );
398
+ });
399
+
400
+ it("renders error with message", async () => {
401
+ useAgentLayerRun.mockReturnValue({
402
+ trigger: mockFetchSuggestions,
403
+ data: { data: { data: { agent_state_id: "state-1" } } },
404
+ });
405
+
406
+ const props = { template, suggestionParams: { template_id: 1 } };
407
+ const { findByText, queryByText } = render(
408
+ <SuggestionsWidget {...props} />,
128
409
  { messages },
129
410
  );
130
411
 
131
- userEvent.click(await findByText(/actions.ai_suggestion/i));
412
+ await requestSuggestions(findByText);
413
+
414
+ emitServerMessage("error", {
415
+ priority: 0,
416
+ error: { message: "ERROR MESSAGE" },
417
+ });
418
+
132
419
  await waitFor(() =>
133
- expect(queryByText(/error message/i)).toBeInTheDocument(),
420
+ expect(queryByText(/ERROR MESSAGE/i)).toBeInTheDocument(),
134
421
  );
135
- expect(container).toMatchSnapshot();
136
422
  });
137
423
 
138
424
  it("renders error with text", async () => {
139
- const props = {
140
- requestAiSuggestion: requestAiSuggestion("error_text"),
141
- };
142
- const { container, findByText, queryByText } = render(
143
- <Suspense fallback={null}>
144
- <SuggestionsWidget {...props} />
145
- </Suspense>,
425
+ useAgentLayerRun.mockReturnValue({
426
+ trigger: mockFetchSuggestions,
427
+ data: { data: { data: { agent_state_id: "state-1" } } },
428
+ });
429
+
430
+ const props = { template, suggestionParams: { template_id: 1 } };
431
+ const { findByText, queryByText } = render(
432
+ <SuggestionsWidget {...props} />,
146
433
  { messages },
147
434
  );
148
435
 
149
- userEvent.click(await findByText(/actions.ai_suggestion/i));
150
- await waitFor(() => expect(queryByText(/error text/i)).toBeInTheDocument());
151
- expect(container).toMatchSnapshot();
436
+ await requestSuggestions(findByText);
437
+
438
+ emitServerMessage("error", { priority: 0, error: "ERROR TEXT" });
439
+
440
+ await waitFor(() => expect(queryByText(/ERROR TEXT/i)).toBeInTheDocument());
152
441
  });
153
442
  });
@@ -1,5 +1,5 @@
1
1
  export const useAvailabilityCheck = jest.fn(() => ({
2
2
  trigger: () => ({
3
- then: () => ({ data: { data: { status: "ok" } } }),
3
+ then: () => ({ available: true }),
4
4
  }),
5
5
  }));
@@ -1,9 +1,17 @@
1
+ import _ from "lodash/fp";
1
2
  import useSWRMutations from "swr/mutation";
2
- import { apiJsonPost } from "@truedat/core/services/api";
3
- import { API_SUGGESTIONS_AVAILABILITY_CHECK } from "../api";
3
+ import { apiJsonOptions } from "@truedat/core/services/api";
4
+ import { API_AGENT_LAYER_RUN } from "../api";
4
5
 
5
- export const useAvailabilityCheck = () => {
6
- return useSWRMutations(API_SUGGESTIONS_AVAILABILITY_CHECK, (url, { arg }) =>
7
- apiJsonPost(url, arg),
6
+ // The suggestion availability gate is an OPTIONS request: the backend answers
7
+ // with an `Allow` header listing the methods enabled for the caller. POST being
8
+ // allowed means the "AI suggest content" button should be shown.
9
+ const isPostAllowed = ({ headers }) =>
10
+ _.includes("POST")(_.getOr("", "allow", headers));
11
+
12
+ export const useAvailabilityCheck = () =>
13
+ useSWRMutations(API_AGENT_LAYER_RUN, (url, { arg }) =>
14
+ apiJsonOptions(url, {
15
+ params: { workflow: "suggest_content", ...arg },
16
+ }).then((res) => ({ available: isPostAllowed(res) })),
8
17
  );
9
- };
@@ -1,313 +0,0 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
2
-
3
- exports[`<SuggestionsWidget /> component lifecycle 1`] = `
4
- <div>
5
- <div
6
- class="ui segment"
7
- >
8
- <div
9
- class="ui container"
10
- style="display: flex; justify-content: space-between;"
11
- >
12
- <h4>
13
- <i
14
- aria-hidden="true"
15
- class="lightbulb outline icon"
16
- />
17
- header
18
- </h4>
19
- <div
20
- class="ui hidden divider"
21
- />
22
- </div>
23
- <div
24
- class="ui container"
25
- >
26
- <h5 />
27
- <div
28
- class="ui list"
29
- role="list"
30
- >
31
- <div
32
- class="item"
33
- role="listitem"
34
- style="display: flex; align-items: center; gap: 12px;"
35
- >
36
- <div
37
- class="ui checked fitted checkbox"
38
- >
39
- <input
40
- checked=""
41
- class="hidden"
42
- id="editable_field"
43
- readonly=""
44
- tabindex="0"
45
- type="checkbox"
46
- />
47
- <label
48
- for="editable_field"
49
- />
50
- </div>
51
- <label
52
- disabled=""
53
- for="editable_field"
54
- style="cursor: pointer;"
55
- >
56
- <div
57
- class="header"
58
- >
59
- EditableField
60
- </div>
61
- <div
62
- class="description"
63
- >
64
- editable_field_value
65
- </div>
66
- </label>
67
- </div>
68
- </div>
69
- <div
70
- class="ui hidden divider"
71
- />
72
- </div>
73
- <div
74
- class="ui container"
75
- >
76
- <h5>
77
- Group2
78
- </h5>
79
- <div
80
- class="ui list"
81
- role="list"
82
- >
83
- <div
84
- class="item"
85
- role="listitem"
86
- style="display: flex; align-items: center; gap: 12px;"
87
- >
88
- <div
89
- class="ui disabled fitted checkbox"
90
- >
91
- <input
92
- class="hidden"
93
- disabled=""
94
- id="non_editable_field"
95
- readonly=""
96
- tabindex="-1"
97
- type="checkbox"
98
- />
99
- <label
100
- for="non_editable_field"
101
- />
102
- </div>
103
- <label
104
- disabled=""
105
- for="non_editable_field"
106
- style="cursor: pointer;"
107
- >
108
- <div
109
- class="header"
110
- >
111
- NonEditableField
112
- </div>
113
- <div
114
- class="description"
115
- >
116
- non_editable_field_value
117
- </div>
118
- </label>
119
- </div>
120
- <div
121
- class="item"
122
- role="listitem"
123
- style="display: flex; align-items: center; gap: 12px;"
124
- >
125
- <div
126
- class="ui checked fitted checkbox"
127
- >
128
- <input
129
- checked=""
130
- class="hidden"
131
- id="unselect_field"
132
- readonly=""
133
- tabindex="0"
134
- type="checkbox"
135
- />
136
- <label
137
- for="unselect_field"
138
- />
139
- </div>
140
- <label
141
- disabled=""
142
- for="unselect_field"
143
- style="cursor: pointer;"
144
- >
145
- <div
146
- class="header"
147
- >
148
- UnselectedField
149
- </div>
150
- <div
151
- class="description"
152
- >
153
- unselect_field_value
154
- </div>
155
- </label>
156
- </div>
157
- </div>
158
- <div
159
- class="ui hidden divider"
160
- />
161
- </div>
162
- <div
163
- class="ui right aligned container"
164
- >
165
- <button
166
- class="ui primary button"
167
- >
168
- actions.apply_ai_suggestion
169
- </button>
170
- </div>
171
- </div>
172
- </div>
173
- `;
174
-
175
- exports[`<SuggestionsWidget /> component lifecycle 2`] = `
176
- <div>
177
- <div
178
- class="ui segment"
179
- >
180
- <div
181
- class="ui container"
182
- style="display: flex; justify-content: space-between;"
183
- >
184
- <h4>
185
- <i
186
- aria-hidden="true"
187
- class="lightbulb icon"
188
- />
189
- header
190
- </h4>
191
- <div
192
- class="ui hidden divider"
193
- />
194
- <button
195
- class="ui button"
196
- >
197
- actions.ai_suggestion
198
- </button>
199
- </div>
200
- </div>
201
- </div>
202
- `;
203
-
204
- exports[`<SuggestionsWidget /> matches the latest snapshot 1`] = `
205
- <div>
206
- <div
207
- class="ui segment"
208
- >
209
- <div
210
- class="ui container"
211
- style="display: flex; justify-content: space-between;"
212
- >
213
- <h4>
214
- <i
215
- aria-hidden="true"
216
- class="lightbulb icon"
217
- />
218
- header
219
- </h4>
220
- <div
221
- class="ui hidden divider"
222
- />
223
- <button
224
- class="ui button"
225
- >
226
- actions.ai_suggestion
227
- </button>
228
- </div>
229
- </div>
230
- </div>
231
- `;
232
-
233
- exports[`<SuggestionsWidget /> renders error with message 1`] = `
234
- <div>
235
- <div
236
- class="ui segment"
237
- >
238
- <div
239
- class="ui container"
240
- style="display: flex; justify-content: space-between;"
241
- >
242
- <h4>
243
- <i
244
- aria-hidden="true"
245
- class="lightbulb icon"
246
- />
247
- header
248
- </h4>
249
- <div
250
- class="ui hidden divider"
251
- />
252
- <button
253
- class="ui button"
254
- >
255
- actions.ai_suggestion
256
- </button>
257
- </div>
258
- <div
259
- class="ui negative message"
260
- >
261
- <div
262
- class="header"
263
- >
264
- error
265
- </div>
266
- <p>
267
- ERROR MESSAGE
268
- </p>
269
- </div>
270
- </div>
271
- </div>
272
- `;
273
-
274
- exports[`<SuggestionsWidget /> renders error with text 1`] = `
275
- <div>
276
- <div
277
- class="ui segment"
278
- >
279
- <div
280
- class="ui container"
281
- style="display: flex; justify-content: space-between;"
282
- >
283
- <h4>
284
- <i
285
- aria-hidden="true"
286
- class="lightbulb icon"
287
- />
288
- header
289
- </h4>
290
- <div
291
- class="ui hidden divider"
292
- />
293
- <button
294
- class="ui button"
295
- >
296
- actions.ai_suggestion
297
- </button>
298
- </div>
299
- <div
300
- class="ui negative message"
301
- >
302
- <div
303
- class="header"
304
- >
305
- error
306
- </div>
307
- <p>
308
- ERROR TEXT
309
- </p>
310
- </div>
311
- </div>
312
- </div>
313
- `;