@truedat/ai 8.14.2 → 8.14.3
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 +4 -4
- package/src/api.js +15 -0
- package/src/components/AiRoutes.js +8 -0
- package/src/components/__tests__/CustomInstructions.spec.js +263 -0
- package/src/components/constants.js +0 -4
- package/src/components/customInstructions/CustomInstructions.js +178 -0
- package/src/components/customInstructions/useLeaveConfirmation.js +50 -0
- package/src/components/providers/EmbeddingModels.js +336 -0
- package/src/components/providers/HelpIcon.js +38 -0
- package/src/components/providers/ProviderEditor.js +114 -34
- package/src/components/providers/Providers.js +73 -20
- package/src/components/providers/__tests__/EmbeddingModels.spec.js +455 -0
- package/src/components/providers/__tests__/ProviderEditor.spec.js +213 -1
- package/src/components/providers/__tests__/Providers.spec.js +251 -12
- package/src/components/providers/__tests__/__snapshots__/ProviderEditor.spec.js.snap +212 -342
- package/src/components/providers/__tests__/__snapshots__/Providers.spec.js.snap +11 -0
- package/src/hooks/__tests__/useIndices.spec.js +75 -0
- package/src/hooks/__tests__/useProviders.spec.js +14 -0
- package/src/hooks/useCustomInstructions.js +22 -0
- package/src/hooks/useIndices.js +37 -0
- package/src/hooks/useProviders.js +17 -1
- package/src/serverErrors.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@truedat/ai",
|
|
3
|
-
"version": "8.14.
|
|
3
|
+
"version": "8.14.3",
|
|
4
4
|
"description": "Truedat Web Artificial Intelligence package",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"module": "src/index.js",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
]
|
|
46
46
|
},
|
|
47
47
|
"transformIgnorePatterns": [
|
|
48
|
-
"/node_modules/(?!marked|turndown|@tiptap|react-router|cookie-es)/"
|
|
48
|
+
"/node_modules/(?!(marked|turndown|@tiptap|react-router|cookie-es|query-string|decode-uri-component|split-on-first|filter-obj)/)"
|
|
49
49
|
]
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"@testing-library/jest-dom": "^6.6.3",
|
|
54
54
|
"@testing-library/react": "^16.3.0",
|
|
55
55
|
"@testing-library/user-event": "^14.6.1",
|
|
56
|
-
"@truedat/test": "8.14.
|
|
56
|
+
"@truedat/test": "8.14.3",
|
|
57
57
|
"identity-obj-proxy": "^3.0.0",
|
|
58
58
|
"jest": "^29.7.0",
|
|
59
59
|
"redux-saga-test-plan": "^4.0.6"
|
|
@@ -83,5 +83,5 @@
|
|
|
83
83
|
"semantic-ui-react": "^3.0.0-beta.2",
|
|
84
84
|
"swr": "^2.3.3"
|
|
85
85
|
},
|
|
86
|
-
"gitHead": "
|
|
86
|
+
"gitHead": "ca5cc79e39ed78a1b5e317d825832e0676b011f6"
|
|
87
87
|
}
|
package/src/api.js
CHANGED
|
@@ -5,8 +5,14 @@ const API_ACTION_SET_ACTIVE = "/api/actions/:id/set_active";
|
|
|
5
5
|
const API_PROMPTS = "/api/prompts";
|
|
6
6
|
const API_PROMPT = "/api/prompts/:id";
|
|
7
7
|
const API_PROMPT_SET_ACTIVE = "/api/prompts/:id/set_active";
|
|
8
|
+
const API_INDICES = "/api/indices";
|
|
9
|
+
const API_INDEX = "/api/indices/:id";
|
|
10
|
+
const API_INDEX_ENABLE = "/api/indices/:id/enable";
|
|
11
|
+
const API_INDEX_DISABLE = "/api/indices/:id/disable";
|
|
8
12
|
const API_PROVIDERS = "/api/providers";
|
|
9
13
|
const API_PROVIDER = "/api/providers/:id";
|
|
14
|
+
const API_PROVIDER_ENABLE = "/api/providers/:id/enable";
|
|
15
|
+
const API_PROVIDER_DISABLE = "/api/providers/:id/disable";
|
|
10
16
|
const API_PROVIDER_CHAT = "/api/providers/:id/chat_completion";
|
|
11
17
|
const API_TRANSLATIONS_AVAILABILITY_CHECK =
|
|
12
18
|
"/api/translations/availability_check";
|
|
@@ -14,6 +20,8 @@ const API_TRANSLATIONS_REQUEST = "/api/translations/request";
|
|
|
14
20
|
const API_AGENT_LAYER_CONVERSATIONS = "/api/agent_layer/conversations";
|
|
15
21
|
const API_AGENT_LAYER_CONVERSATION = "/api/agent_layer/conversation";
|
|
16
22
|
const API_AGENT_LAYER_RUN = "/api/agent_layer/run";
|
|
23
|
+
const API_AGENT_LAYER_CUSTOM_INSTRUCTIONS =
|
|
24
|
+
"/api/agent_layer/custom_instructions";
|
|
17
25
|
const API_SOCKET_ENDPOINT = "/api/socket";
|
|
18
26
|
|
|
19
27
|
export {
|
|
@@ -25,12 +33,19 @@ export {
|
|
|
25
33
|
API_PROMPTS,
|
|
26
34
|
API_PROMPT,
|
|
27
35
|
API_PROMPT_SET_ACTIVE,
|
|
36
|
+
API_INDICES,
|
|
37
|
+
API_INDEX,
|
|
38
|
+
API_INDEX_ENABLE,
|
|
39
|
+
API_INDEX_DISABLE,
|
|
28
40
|
API_PROVIDERS,
|
|
29
41
|
API_PROVIDER,
|
|
42
|
+
API_PROVIDER_ENABLE,
|
|
43
|
+
API_PROVIDER_DISABLE,
|
|
30
44
|
API_PROVIDER_CHAT,
|
|
31
45
|
API_TRANSLATIONS_AVAILABILITY_CHECK,
|
|
32
46
|
API_TRANSLATIONS_REQUEST,
|
|
33
47
|
API_AGENT_LAYER_CONVERSATION,
|
|
34
48
|
API_AGENT_LAYER_RUN,
|
|
49
|
+
API_AGENT_LAYER_CUSTOM_INSTRUCTIONS,
|
|
35
50
|
API_SOCKET_ENDPOINT,
|
|
36
51
|
};
|
|
@@ -5,6 +5,7 @@ import { useCanAssistant } from "./assistant/hooks/useAgentConversation";
|
|
|
5
5
|
|
|
6
6
|
import Prompts from "./prompts/Prompts";
|
|
7
7
|
import Providers from "./providers/Providers";
|
|
8
|
+
import CustomInstructions from "./customInstructions/CustomInstructions";
|
|
8
9
|
import AiSandbox from "./aiSandbox/AiSandbox";
|
|
9
10
|
import Actions from "./actions/Actions";
|
|
10
11
|
import Action from "./actions/Action";
|
|
@@ -35,6 +36,13 @@ export default function AiRoutes() {
|
|
|
35
36
|
<Route index element={<Providers />} />
|
|
36
37
|
</Route>
|
|
37
38
|
|
|
39
|
+
<Route
|
|
40
|
+
path={"custom-instructions"}
|
|
41
|
+
element={<ProtectedRoute authorization={"manage_ai"} />}
|
|
42
|
+
>
|
|
43
|
+
<Route index element={<CustomInstructions />} />
|
|
44
|
+
</Route>
|
|
45
|
+
|
|
38
46
|
<Route
|
|
39
47
|
path={"aiSandbox"}
|
|
40
48
|
element={<ProtectedRoute authorization={"manage_ai"} />}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { MemoryRouter, Routes, Route, Link } from "react-router";
|
|
2
|
+
import { SWRConfig } from "swr";
|
|
3
|
+
import { waitFor, screen } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
import { renderWithIntl as render } from "@truedat/test/renderWithIntl";
|
|
6
|
+
import { apiJson, apiJsonDelete, apiJsonPut } from "@truedat/core/services/api";
|
|
7
|
+
import CustomInstructions from "../customInstructions/CustomInstructions";
|
|
8
|
+
|
|
9
|
+
jest.mock("@truedat/core/services/api", () => ({
|
|
10
|
+
apiJson: jest.fn(),
|
|
11
|
+
apiJsonDelete: jest.fn(),
|
|
12
|
+
apiJsonPut: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const messages = {
|
|
16
|
+
en: {
|
|
17
|
+
"customInstructions.title": "Custom instructions",
|
|
18
|
+
"customInstructions.content": "Instructions",
|
|
19
|
+
"customInstructions.counter": "{count} / {max} characters",
|
|
20
|
+
"customInstructions.notice": "NOTICE guidance only",
|
|
21
|
+
"customInstructions.updatedBy": "Last updated by {user}",
|
|
22
|
+
"customInstructions.saved": "SAVED",
|
|
23
|
+
"customInstructions.removed": "REMOVED",
|
|
24
|
+
"customInstructions.remove": "Remove",
|
|
25
|
+
"customInstructions.remove.confirm.header": "REMOVE HEADER",
|
|
26
|
+
"customInstructions.remove.confirm.content": "REMOVE CONTENT",
|
|
27
|
+
"actions.save": "Save",
|
|
28
|
+
"actions.discard.confirmation.header": "Discard?",
|
|
29
|
+
"actions.discard.confirmation.content": "Unsaved changes",
|
|
30
|
+
"confirmation.yes": "Yes",
|
|
31
|
+
"confirmation.no": "No",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const empty = {
|
|
36
|
+
data: { data: { content: "", updated_by: null, updated_at: null } },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const setup = () =>
|
|
40
|
+
render(
|
|
41
|
+
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
|
|
42
|
+
<MemoryRouter initialEntries={["/custom-instructions"]}>
|
|
43
|
+
<Routes>
|
|
44
|
+
<Route
|
|
45
|
+
path="/custom-instructions"
|
|
46
|
+
element={
|
|
47
|
+
<>
|
|
48
|
+
<CustomInstructions />
|
|
49
|
+
<Link to="/other">leave</Link>
|
|
50
|
+
</>
|
|
51
|
+
}
|
|
52
|
+
/>
|
|
53
|
+
<Route path="/other" element={<div>other page</div>} />
|
|
54
|
+
</Routes>
|
|
55
|
+
</MemoryRouter>
|
|
56
|
+
</SWRConfig>,
|
|
57
|
+
{ messages },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
describe("<CustomInstructions />", () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
jest.clearAllMocks();
|
|
63
|
+
apiJson.mockResolvedValue(empty);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("loads from the API, shows the notice always, empty field, no last changed line", async () => {
|
|
67
|
+
setup();
|
|
68
|
+
expect(screen.getByText("NOTICE guidance only")).toBeInTheDocument();
|
|
69
|
+
const field = await screen.findByRole("textbox");
|
|
70
|
+
expect(field).toHaveValue("");
|
|
71
|
+
expect(apiJson).toHaveBeenCalledWith(
|
|
72
|
+
"/api/agent_layer/custom_instructions",
|
|
73
|
+
);
|
|
74
|
+
expect(screen.queryByText(/Last updated by/)).not.toBeInTheDocument();
|
|
75
|
+
expect(screen.getByText("0 / 2000 characters")).toBeInTheDocument();
|
|
76
|
+
expect(screen.getByText("NOTICE guidance only")).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("shows stored text and last changed line", async () => {
|
|
80
|
+
apiJson.mockResolvedValue({
|
|
81
|
+
data: {
|
|
82
|
+
data: {
|
|
83
|
+
content: "Ignore system2",
|
|
84
|
+
updated_by: "bob",
|
|
85
|
+
updated_at: "2026-10-28T10:00:00Z",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
setup();
|
|
90
|
+
expect(
|
|
91
|
+
await screen.findByDisplayValue("Ignore system2"),
|
|
92
|
+
).toBeInTheDocument();
|
|
93
|
+
expect(screen.getByText(/Last updated by bob/)).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("disables save while unchanged and enables it after editing", async () => {
|
|
97
|
+
setup();
|
|
98
|
+
const field = await screen.findByRole("textbox");
|
|
99
|
+
const save = screen.getByRole("button", { name: "Save" });
|
|
100
|
+
expect(save).toBeDisabled();
|
|
101
|
+
await userEvent.type(field, "abc");
|
|
102
|
+
expect(save).toBeEnabled();
|
|
103
|
+
expect(screen.getByText("3 / 2000 characters")).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("blocks saving above 2000 characters", async () => {
|
|
107
|
+
setup();
|
|
108
|
+
const field = await screen.findByRole("textbox");
|
|
109
|
+
await userEvent.click(field);
|
|
110
|
+
await userEvent.paste("x".repeat(2001));
|
|
111
|
+
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("saves with PUT, shows saved message and updated line", async () => {
|
|
115
|
+
apiJsonPut.mockResolvedValue({
|
|
116
|
+
data: {
|
|
117
|
+
data: {
|
|
118
|
+
content: "abc",
|
|
119
|
+
updated_by: "alice",
|
|
120
|
+
updated_at: "2026-10-28T11:00:00Z",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
setup();
|
|
125
|
+
await userEvent.type(await screen.findByRole("textbox"), "abc");
|
|
126
|
+
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
127
|
+
await waitFor(() =>
|
|
128
|
+
expect(apiJsonPut).toHaveBeenCalledWith(
|
|
129
|
+
"/api/agent_layer/custom_instructions",
|
|
130
|
+
{ custom_instructions: { content: "abc" } },
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
expect(await screen.findByText("SAVED")).toBeInTheDocument();
|
|
134
|
+
expect(screen.getByText(/Last updated by alice/)).toBeInTheDocument();
|
|
135
|
+
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("removing", () => {
|
|
139
|
+
const confirmYes = () =>
|
|
140
|
+
screen.getByRole("button", { name: "modal-affirmative-action" });
|
|
141
|
+
const stored = {
|
|
142
|
+
data: {
|
|
143
|
+
data: {
|
|
144
|
+
content: "Ignore system2",
|
|
145
|
+
updated_by: "bob",
|
|
146
|
+
updated_at: "2026-10-28T10:00:00Z",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
it("has no Remove button when nothing is stored", async () => {
|
|
152
|
+
setup();
|
|
153
|
+
await screen.findByRole("textbox");
|
|
154
|
+
expect(
|
|
155
|
+
screen.queryByRole("button", { name: "Remove" }),
|
|
156
|
+
).not.toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("asks for confirmation, then deletes and clears the field", async () => {
|
|
160
|
+
apiJson.mockResolvedValue(stored);
|
|
161
|
+
apiJsonDelete.mockResolvedValue({
|
|
162
|
+
data: { data: { content: "", updated_by: null, updated_at: null } },
|
|
163
|
+
});
|
|
164
|
+
setup();
|
|
165
|
+
const field = await screen.findByDisplayValue("Ignore system2");
|
|
166
|
+
await userEvent.click(screen.getByRole("button", { name: "Remove" }));
|
|
167
|
+
expect(apiJsonDelete).not.toHaveBeenCalled();
|
|
168
|
+
expect(screen.getByText("REMOVE HEADER")).toBeInTheDocument();
|
|
169
|
+
await userEvent.click(confirmYes());
|
|
170
|
+
await waitFor(() =>
|
|
171
|
+
expect(apiJsonDelete).toHaveBeenCalledWith(
|
|
172
|
+
"/api/agent_layer/custom_instructions",
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
expect(apiJsonPut).not.toHaveBeenCalled();
|
|
176
|
+
expect(await screen.findByText("REMOVED")).toBeInTheDocument();
|
|
177
|
+
expect(screen.queryByText(/Last updated by/)).not.toBeInTheDocument();
|
|
178
|
+
expect(field).toHaveValue("");
|
|
179
|
+
expect(
|
|
180
|
+
screen.queryByRole("button", { name: "Remove" }),
|
|
181
|
+
).not.toBeInTheDocument();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("keeps the text when the confirmation is declined", async () => {
|
|
185
|
+
apiJson.mockResolvedValue(stored);
|
|
186
|
+
setup();
|
|
187
|
+
await screen.findByDisplayValue("Ignore system2");
|
|
188
|
+
await userEvent.click(screen.getByRole("button", { name: "Remove" }));
|
|
189
|
+
await userEvent.click(
|
|
190
|
+
screen.getByRole("button", { name: "modal-negative-action" }),
|
|
191
|
+
);
|
|
192
|
+
expect(apiJsonDelete).not.toHaveBeenCalled();
|
|
193
|
+
expect(screen.getByDisplayValue("Ignore system2")).toBeInTheDocument();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("shows the server error and keeps the text when removing fails", async () => {
|
|
197
|
+
apiJson.mockResolvedValue(stored);
|
|
198
|
+
apiJsonDelete.mockRejectedValue({
|
|
199
|
+
response: { status: 403, data: { errors: { content: ["forbidden"] } } },
|
|
200
|
+
});
|
|
201
|
+
setup();
|
|
202
|
+
await screen.findByDisplayValue("Ignore system2");
|
|
203
|
+
await userEvent.click(screen.getByRole("button", { name: "Remove" }));
|
|
204
|
+
await userEvent.click(confirmYes());
|
|
205
|
+
expect(await screen.findByText(/forbidden/)).toBeInTheDocument();
|
|
206
|
+
expect(screen.getByDisplayValue("Ignore system2")).toBeInTheDocument();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it.each([403, 422])(
|
|
211
|
+
"shows the server message on %s and keeps the text",
|
|
212
|
+
async (status) => {
|
|
213
|
+
apiJsonPut.mockRejectedValue({
|
|
214
|
+
response: { status, data: { errors: { content: ["too long"] } } },
|
|
215
|
+
});
|
|
216
|
+
setup();
|
|
217
|
+
const field = await screen.findByRole("textbox");
|
|
218
|
+
await userEvent.type(field, "abc");
|
|
219
|
+
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
220
|
+
expect(await screen.findByText(/too long/)).toBeInTheDocument();
|
|
221
|
+
expect(field).toHaveValue("abc");
|
|
222
|
+
expect(screen.queryByText("SAVED")).not.toBeInTheDocument();
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
it("asks for confirmation when leaving with unsaved changes", async () => {
|
|
227
|
+
setup();
|
|
228
|
+
await userEvent.type(await screen.findByRole("textbox"), "abc");
|
|
229
|
+
await userEvent.click(screen.getByText("leave"));
|
|
230
|
+
expect(await screen.findByText("Discard?")).toBeInTheDocument();
|
|
231
|
+
expect(screen.queryByText("other page")).not.toBeInTheDocument();
|
|
232
|
+
await userEvent.click(screen.getByLabelText("modal-affirmative-action"));
|
|
233
|
+
expect(await screen.findByText("other page")).toBeInTheDocument();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("leaves without confirmation when unchanged", async () => {
|
|
237
|
+
setup();
|
|
238
|
+
await screen.findByRole("textbox");
|
|
239
|
+
await userEvent.click(screen.getByText("leave"));
|
|
240
|
+
expect(await screen.findByText("other page")).toBeInTheDocument();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("trims before counting and counts an emoji as one character", async () => {
|
|
244
|
+
setup();
|
|
245
|
+
const field = await screen.findByRole("textbox");
|
|
246
|
+
await userEvent.click(field);
|
|
247
|
+
await userEvent.paste(" hi \u{1F44D} \n");
|
|
248
|
+
expect(screen.getByText("4 / 2000 characters")).toBeInTheDocument();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("allows saving 2000 characters plus trailing whitespace and blocks 2001", async () => {
|
|
252
|
+
setup();
|
|
253
|
+
const field = await screen.findByRole("textbox");
|
|
254
|
+
await userEvent.click(field);
|
|
255
|
+
await userEvent.paste("x".repeat(1999) + "\u{1F44D}" + " ");
|
|
256
|
+
expect(screen.getByText("2000 / 2000 characters")).toBeInTheDocument();
|
|
257
|
+
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
|
258
|
+
await userEvent.clear(field);
|
|
259
|
+
await userEvent.paste("x".repeat(2000) + "\u{1F44D} ");
|
|
260
|
+
expect(screen.getByText("2001 / 2000 characters")).toBeInTheDocument();
|
|
261
|
+
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -12,14 +12,10 @@ export const providerTypes = [
|
|
|
12
12
|
"llm_gateway",
|
|
13
13
|
];
|
|
14
14
|
|
|
15
|
-
export const agentTypes = ["fast", "high", "thinking"];
|
|
16
|
-
|
|
17
15
|
export const usePromptResourceTypeOptions = () =>
|
|
18
16
|
useOptions(promptResourceTypes, "resourceMappings.resourceType");
|
|
19
17
|
export const useProviderTypeOptions = () =>
|
|
20
18
|
useOptions(providerTypes, "providers.type");
|
|
21
|
-
export const useAgentTypeOptions = () =>
|
|
22
|
-
useOptions(agentTypes, "providers.agent");
|
|
23
19
|
|
|
24
20
|
const useOptions = (list, formatPrefix) => {
|
|
25
21
|
const { formatMessage } = useIntl();
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import _ from "lodash/fp";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useIntl, FormattedMessage } from "react-intl";
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
Container,
|
|
7
|
+
Form,
|
|
8
|
+
Header,
|
|
9
|
+
Icon,
|
|
10
|
+
Message,
|
|
11
|
+
Segment,
|
|
12
|
+
} from "semantic-ui-react";
|
|
13
|
+
import ConfirmModal from "@truedat/core/components/ConfirmModal";
|
|
14
|
+
import {
|
|
15
|
+
useCustomInstructions,
|
|
16
|
+
useCustomInstructionsDelete,
|
|
17
|
+
useCustomInstructionsUpdate,
|
|
18
|
+
} from "../../hooks/useCustomInstructions";
|
|
19
|
+
import { serverErrors } from "../../serverErrors";
|
|
20
|
+
import { useLeaveConfirmation } from "./useLeaveConfirmation";
|
|
21
|
+
|
|
22
|
+
export const MAX_LENGTH = 2000;
|
|
23
|
+
|
|
24
|
+
// Same rule as td-ai: trim first, then count graphemes.
|
|
25
|
+
const graphemeCount = (text) => {
|
|
26
|
+
const trimmed = text.trim();
|
|
27
|
+
if (typeof Intl !== "undefined" && Intl.Segmenter) {
|
|
28
|
+
return Array.from(new Intl.Segmenter().segment(trimmed)).length;
|
|
29
|
+
}
|
|
30
|
+
return Array.from(trimmed).length;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const errorMessages = (error) => {
|
|
34
|
+
const data = error?.response?.data;
|
|
35
|
+
const fromErrors = _.flow(
|
|
36
|
+
serverErrors,
|
|
37
|
+
_.values,
|
|
38
|
+
_.flatten,
|
|
39
|
+
_.map((e) => (_.isString(e) ? e : JSON.stringify(e))),
|
|
40
|
+
)(error);
|
|
41
|
+
if (!_.isEmpty(fromErrors)) return fromErrors;
|
|
42
|
+
return [data?.message || error?.message || String(error)];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default function CustomInstructions() {
|
|
46
|
+
const { formatMessage } = useIntl();
|
|
47
|
+
const { data, loading, mutate } = useCustomInstructions();
|
|
48
|
+
const { trigger: update, isMutating: isSaving } =
|
|
49
|
+
useCustomInstructionsUpdate();
|
|
50
|
+
const { trigger: remove, isMutating: isRemoving } =
|
|
51
|
+
useCustomInstructionsDelete();
|
|
52
|
+
const isMutating = isSaving || isRemoving;
|
|
53
|
+
const [draft, setDraft] = useState(null);
|
|
54
|
+
const [errors, setErrors] = useState([]);
|
|
55
|
+
const [saved, setSaved] = useState(false);
|
|
56
|
+
const [removing, setRemoving] = useState(false);
|
|
57
|
+
|
|
58
|
+
const stored = data?.content || "";
|
|
59
|
+
const value = draft ?? stored;
|
|
60
|
+
const isDirty = draft !== null && draft !== stored;
|
|
61
|
+
const length = graphemeCount(value);
|
|
62
|
+
const tooLong = length > MAX_LENGTH;
|
|
63
|
+
const leave = useLeaveConfirmation(isDirty);
|
|
64
|
+
|
|
65
|
+
const onChange = (_e, { value }) => {
|
|
66
|
+
setDraft(value);
|
|
67
|
+
setSaved(false);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const request = async (send, message) => {
|
|
71
|
+
setErrors([]);
|
|
72
|
+
try {
|
|
73
|
+
const response = await send();
|
|
74
|
+
await mutate(response, false);
|
|
75
|
+
setDraft(null);
|
|
76
|
+
setSaved(message);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
setSaved(false);
|
|
79
|
+
setErrors(errorMessages(error));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const onSave = () =>
|
|
84
|
+
request(
|
|
85
|
+
() => update({ custom_instructions: { content: value } }),
|
|
86
|
+
"customInstructions.saved",
|
|
87
|
+
);
|
|
88
|
+
const onRemove = () => {
|
|
89
|
+
setRemoving(false);
|
|
90
|
+
return request(() => remove(), "customInstructions.removed");
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Segment loading={loading}>
|
|
95
|
+
<Header as="h2">
|
|
96
|
+
<Icon
|
|
97
|
+
circular
|
|
98
|
+
name={formatMessage({
|
|
99
|
+
id: "customInstructions.header.icon",
|
|
100
|
+
defaultMessage: "clipboard list",
|
|
101
|
+
})}
|
|
102
|
+
/>
|
|
103
|
+
<Header.Content>
|
|
104
|
+
<FormattedMessage id="customInstructions.title" />
|
|
105
|
+
</Header.Content>
|
|
106
|
+
</Header>
|
|
107
|
+
<Message
|
|
108
|
+
info
|
|
109
|
+
content={formatMessage({ id: "customInstructions.notice" })}
|
|
110
|
+
/>
|
|
111
|
+
<Form>
|
|
112
|
+
<Form.TextArea
|
|
113
|
+
label={formatMessage({ id: "customInstructions.content" })}
|
|
114
|
+
placeholder={formatMessage({ id: "customInstructions.placeholder" })}
|
|
115
|
+
rows={12}
|
|
116
|
+
value={value}
|
|
117
|
+
onChange={onChange}
|
|
118
|
+
error={tooLong}
|
|
119
|
+
/>
|
|
120
|
+
<div style={{ textAlign: "right" }}>
|
|
121
|
+
{formatMessage(
|
|
122
|
+
{ id: "customInstructions.counter" },
|
|
123
|
+
{ count: length, max: MAX_LENGTH },
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
{data?.updated_by && (
|
|
127
|
+
<p>
|
|
128
|
+
{formatMessage(
|
|
129
|
+
{ id: "customInstructions.updatedBy" },
|
|
130
|
+
{ user: data.updated_by },
|
|
131
|
+
)}
|
|
132
|
+
{data.updated_at &&
|
|
133
|
+
` - ${new Date(data.updated_at).toLocaleString()}`}
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
{!_.isEmpty(errors) && <Message negative list={errors} />}
|
|
137
|
+
{saved && <Message positive content={formatMessage({ id: saved })} />}
|
|
138
|
+
<Container textAlign="right">
|
|
139
|
+
<Button
|
|
140
|
+
primary
|
|
141
|
+
loading={isMutating}
|
|
142
|
+
disabled={!isDirty || tooLong || isMutating}
|
|
143
|
+
onClick={onSave}
|
|
144
|
+
content={formatMessage({ id: "actions.save" })}
|
|
145
|
+
/>
|
|
146
|
+
{!_.isEmpty(stored) && (
|
|
147
|
+
<Button
|
|
148
|
+
color="red"
|
|
149
|
+
basic
|
|
150
|
+
disabled={isMutating}
|
|
151
|
+
onClick={() => setRemoving(true)}
|
|
152
|
+
content={formatMessage({ id: "customInstructions.remove" })}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
</Container>
|
|
156
|
+
</Form>
|
|
157
|
+
<ConfirmModal
|
|
158
|
+
open={removing}
|
|
159
|
+
onClose={() => setRemoving(false)}
|
|
160
|
+
onConfirm={onRemove}
|
|
161
|
+
header={formatMessage({
|
|
162
|
+
id: "customInstructions.remove.confirm.header",
|
|
163
|
+
})}
|
|
164
|
+
content={formatMessage({
|
|
165
|
+
id: "customInstructions.remove.confirm.content",
|
|
166
|
+
})}
|
|
167
|
+
/>
|
|
168
|
+
<ConfirmModal
|
|
169
|
+
open={leave.open}
|
|
170
|
+
onOpen={_.noop}
|
|
171
|
+
onClose={leave.onCancel}
|
|
172
|
+
onConfirm={leave.onConfirm}
|
|
173
|
+
header={formatMessage({ id: "actions.discard.confirmation.header" })}
|
|
174
|
+
content={formatMessage({ id: "actions.discard.confirmation.content" })}
|
|
175
|
+
/>
|
|
176
|
+
</Segment>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router";
|
|
3
|
+
|
|
4
|
+
// The app runs under BrowserRouter, which has no navigation blocker, so
|
|
5
|
+
// in-app link clicks are intercepted while the form is dirty.
|
|
6
|
+
export const useLeaveConfirmation = (isDirty) => {
|
|
7
|
+
const navigate = useNavigate();
|
|
8
|
+
const [target, setTarget] = useState(null);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!isDirty) return undefined;
|
|
12
|
+
const onClick = (e) => {
|
|
13
|
+
const anchor = e.target?.closest?.("a[href]");
|
|
14
|
+
if (
|
|
15
|
+
!anchor ||
|
|
16
|
+
e.defaultPrevented ||
|
|
17
|
+
e.button !== 0 ||
|
|
18
|
+
e.metaKey ||
|
|
19
|
+
e.ctrlKey ||
|
|
20
|
+
e.shiftKey ||
|
|
21
|
+
e.altKey ||
|
|
22
|
+
(anchor.target && anchor.target !== "_self")
|
|
23
|
+
) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const url = new URL(anchor.href, window.location.href);
|
|
27
|
+
if (url.origin !== window.location.origin) return;
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
e.stopPropagation();
|
|
30
|
+
setTarget(`${url.pathname}${url.search}${url.hash}`);
|
|
31
|
+
};
|
|
32
|
+
const onBeforeUnload = (e) => e.preventDefault();
|
|
33
|
+
document.addEventListener("click", onClick, true);
|
|
34
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
35
|
+
return () => {
|
|
36
|
+
document.removeEventListener("click", onClick, true);
|
|
37
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
38
|
+
};
|
|
39
|
+
}, [isDirty]);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
open: target !== null,
|
|
43
|
+
onCancel: () => setTarget(null),
|
|
44
|
+
onConfirm: () => {
|
|
45
|
+
const to = target;
|
|
46
|
+
setTarget(null);
|
|
47
|
+
navigate(to);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
};
|