@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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Icon,
|
|
10
10
|
Label,
|
|
11
11
|
List,
|
|
12
|
+
Message,
|
|
12
13
|
Segment,
|
|
13
14
|
} from "semantic-ui-react";
|
|
14
15
|
|
|
@@ -16,39 +17,62 @@ import {
|
|
|
16
17
|
useProviders,
|
|
17
18
|
useProviderCreate,
|
|
18
19
|
useProviderDelete,
|
|
20
|
+
useProviderEnable,
|
|
19
21
|
useProviderUpdate,
|
|
20
22
|
} from "@truedat/ai/hooks/useProviders";
|
|
23
|
+
import { useIndices } from "@truedat/ai/hooks/useIndices";
|
|
24
|
+
import { serverErrors } from "../../serverErrors";
|
|
21
25
|
import ProviderEditor from "./ProviderEditor";
|
|
22
26
|
|
|
23
|
-
const agentColors = {
|
|
24
|
-
fast: "yellow",
|
|
25
|
-
high: "blue",
|
|
26
|
-
thinking: "purple",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
27
|
const NEW_PROVIDER = {
|
|
30
28
|
name: "",
|
|
31
29
|
type: "openai",
|
|
32
30
|
properties: {},
|
|
33
31
|
};
|
|
34
32
|
|
|
33
|
+
const deleteErrorMessages = (error, formatMessage) => {
|
|
34
|
+
const errors = serverErrors(error);
|
|
35
|
+
return errors.indices
|
|
36
|
+
? [
|
|
37
|
+
formatMessage({
|
|
38
|
+
id: "providers.embeddingModels.error.providerHasModels",
|
|
39
|
+
}),
|
|
40
|
+
]
|
|
41
|
+
: _.flatten(_.values(errors));
|
|
42
|
+
};
|
|
43
|
+
|
|
35
44
|
export default function Providers() {
|
|
36
45
|
const { formatMessage } = useIntl();
|
|
37
46
|
const { data, loading, mutate } = useProviders();
|
|
38
47
|
const [selectedProvider, setSelectedProvider] = useState();
|
|
39
48
|
const [isDirty, setDirty] = useState(false);
|
|
49
|
+
const [deleteError, setDeleteError] = useState();
|
|
40
50
|
|
|
41
51
|
const providers = _.flow(_.prop("data"), _.orderBy(["id"], ["asc"]))(data);
|
|
52
|
+
const noneEnabled = !_.isEmpty(providers) && !_.some("enabled_at")(providers);
|
|
53
|
+
const { data: indices } = useIndices();
|
|
54
|
+
const enabledModelCounts = _.flow(
|
|
55
|
+
_.filter("enabled_at"),
|
|
56
|
+
_.countBy("provider"),
|
|
57
|
+
)(indices);
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
// A delete error belongs to the provider it failed on
|
|
60
|
+
const selectProvider = (provider) => {
|
|
61
|
+
setDeleteError(undefined);
|
|
62
|
+
setSelectedProvider(provider);
|
|
63
|
+
};
|
|
64
|
+
const setStateNewProvider = () => selectProvider(NEW_PROVIDER);
|
|
44
65
|
const clearForm = () => {
|
|
45
66
|
setSelectedProvider(null);
|
|
67
|
+
setDeleteError(undefined);
|
|
46
68
|
setDirty(false);
|
|
47
69
|
};
|
|
48
70
|
|
|
49
71
|
const { trigger: createProvider, isMutating: isCreating } =
|
|
50
72
|
useProviderCreate();
|
|
51
73
|
|
|
74
|
+
const { trigger: enableProvider } = useProviderEnable();
|
|
75
|
+
|
|
52
76
|
const { trigger: updateProvider, isMutating: isUpdating } =
|
|
53
77
|
useProviderUpdate(selectedProvider);
|
|
54
78
|
|
|
@@ -56,17 +80,27 @@ export default function Providers() {
|
|
|
56
80
|
useProviderDelete(selectedProvider);
|
|
57
81
|
|
|
58
82
|
const isSubmitting = isCreating || isUpdating || isDeleting;
|
|
59
|
-
const onProviderCreate = async (provider) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
const onProviderCreate = async (provider, { enable } = {}) => {
|
|
84
|
+
if (selectedProvider?.id) {
|
|
85
|
+
await updateProvider({ provider });
|
|
86
|
+
} else {
|
|
87
|
+
const response = await createProvider({ provider });
|
|
88
|
+
const created = response?.data?.data;
|
|
89
|
+
if (enable && created?.id && !created.enabled_at) {
|
|
90
|
+
await enableProvider({ id: created.id });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
64
93
|
clearForm();
|
|
65
94
|
mutate();
|
|
66
95
|
};
|
|
67
96
|
|
|
68
97
|
const onProviderDelete = async (provider) => {
|
|
69
|
-
|
|
98
|
+
try {
|
|
99
|
+
await deleteProvider({ provider });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
setDeleteError(deleteErrorMessages(error, formatMessage));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
70
104
|
clearForm();
|
|
71
105
|
mutate();
|
|
72
106
|
};
|
|
@@ -88,6 +122,12 @@ export default function Providers() {
|
|
|
88
122
|
</Header.Subheader>
|
|
89
123
|
</Header.Content>
|
|
90
124
|
</Header>
|
|
125
|
+
{noneEnabled ? (
|
|
126
|
+
<Message
|
|
127
|
+
warning
|
|
128
|
+
content={formatMessage({ id: "providers.enabled.banner" })}
|
|
129
|
+
/>
|
|
130
|
+
) : null}
|
|
91
131
|
<Grid>
|
|
92
132
|
<GridColumn width={4}>
|
|
93
133
|
<Button fluid onClick={setStateNewProvider} disabled={isDirty}>
|
|
@@ -98,7 +138,7 @@ export default function Providers() {
|
|
|
98
138
|
providers.map((provider, key) => (
|
|
99
139
|
<List.Item
|
|
100
140
|
key={key}
|
|
101
|
-
onClick={() => !isDirty &&
|
|
141
|
+
onClick={() => !isDirty && selectProvider(provider)}
|
|
102
142
|
>
|
|
103
143
|
<List.Content>
|
|
104
144
|
<List.Header>
|
|
@@ -106,18 +146,28 @@ export default function Providers() {
|
|
|
106
146
|
formatMessage({
|
|
107
147
|
id: "providers.form.name.new",
|
|
108
148
|
})}
|
|
109
|
-
{provider.
|
|
149
|
+
{provider.enabled_at && (
|
|
110
150
|
<Label
|
|
111
|
-
color=
|
|
151
|
+
color="yellow"
|
|
112
152
|
size="small"
|
|
113
153
|
style={{ marginLeft: "0.5em" }}
|
|
114
154
|
>
|
|
115
|
-
{formatMessage({
|
|
116
|
-
id: `providers.agent.${provider.agents}`,
|
|
117
|
-
defaultMessage: provider.agents,
|
|
118
|
-
})}
|
|
155
|
+
{formatMessage({ id: "providers.enabled.badge" })}
|
|
119
156
|
</Label>
|
|
120
157
|
)}
|
|
158
|
+
{provider.type !== "anthropic" &&
|
|
159
|
+
enabledModelCounts[provider.id] > 0 && (
|
|
160
|
+
<Label
|
|
161
|
+
basic
|
|
162
|
+
size="small"
|
|
163
|
+
style={{ marginLeft: "0.5em" }}
|
|
164
|
+
>
|
|
165
|
+
{formatMessage(
|
|
166
|
+
{ id: "providers.embeddingModels.enabledCount" },
|
|
167
|
+
{ count: enabledModelCounts[provider.id] },
|
|
168
|
+
)}
|
|
169
|
+
</Label>
|
|
170
|
+
)}
|
|
121
171
|
</List.Header>
|
|
122
172
|
</List.Content>
|
|
123
173
|
</List.Item>
|
|
@@ -143,7 +193,10 @@ export default function Providers() {
|
|
|
143
193
|
onSubmit={onProviderCreate}
|
|
144
194
|
onDelete={selectedProvider?.id ? onProviderDelete : null}
|
|
145
195
|
isSubmitting={isSubmitting}
|
|
196
|
+
deleteError={deleteError}
|
|
197
|
+
onDismissDeleteError={() => setDeleteError(undefined)}
|
|
146
198
|
setDirty={setDirty}
|
|
199
|
+
onRefresh={mutate}
|
|
147
200
|
/>
|
|
148
201
|
) : (
|
|
149
202
|
<Header as="h2" icon textAlign="center">
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import userEvent from "@testing-library/user-event";
|
|
2
|
+
import { waitFor } from "@testing-library/react";
|
|
3
|
+
import { renderWithIntl as render } from "@truedat/test/renderWithIntl";
|
|
4
|
+
import EmbeddingModels from "../EmbeddingModels";
|
|
5
|
+
|
|
6
|
+
const mockCreate = jest.fn();
|
|
7
|
+
const mockEnable = jest.fn();
|
|
8
|
+
const mockDisable = jest.fn();
|
|
9
|
+
const mockMutate = jest.fn();
|
|
10
|
+
let mockIndices = [];
|
|
11
|
+
|
|
12
|
+
jest.mock("@truedat/ai/hooks/useIndices", () => ({
|
|
13
|
+
useIndices: () => ({ data: mockIndices, loading: false, mutate: mockMutate }),
|
|
14
|
+
useIndexCreate: () => ({ trigger: mockCreate, isMutating: false }),
|
|
15
|
+
useIndexEnable: () => ({ trigger: mockEnable, isMutating: false }),
|
|
16
|
+
useIndexDisable: () => ({ trigger: mockDisable, isMutating: false }),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const provider = { id: 5, name: "gemini", type: "gemini" };
|
|
20
|
+
const idx = (over) => ({
|
|
21
|
+
id: 1,
|
|
22
|
+
embedding: "gemini-embedding-001",
|
|
23
|
+
index_type: "suggestions",
|
|
24
|
+
index_params: { dims: 2048 },
|
|
25
|
+
provider: 5,
|
|
26
|
+
enabled_at: null,
|
|
27
|
+
...over,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const setup = (p = provider) => {
|
|
31
|
+
const user = userEvent.setup({ delay: null });
|
|
32
|
+
const rendered = render(<EmbeddingModels provider={p} />);
|
|
33
|
+
return { user, ...rendered };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("<EmbeddingModels />", () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
jest.useRealTimers();
|
|
39
|
+
jest.clearAllMocks();
|
|
40
|
+
mockCreate.mockResolvedValue({});
|
|
41
|
+
mockEnable.mockResolvedValue({});
|
|
42
|
+
mockDisable.mockResolvedValue({});
|
|
43
|
+
mockIndices = [];
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("shows the unsupported message and no list for Anthropic (06-4)", () => {
|
|
47
|
+
mockIndices = [idx({ provider: 9 })];
|
|
48
|
+
const { getByText, queryByText } = setup({ id: 9, type: "anthropic" });
|
|
49
|
+
expect(getByText("providers.embeddingModels.unsupported")).toBeVisible();
|
|
50
|
+
expect(queryByText("gemini-embedding-001")).not.toBeInTheDocument();
|
|
51
|
+
expect(
|
|
52
|
+
queryByText("providers.embeddingModels.add"),
|
|
53
|
+
).not.toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("lists only the models of the provider with name, purpose, dims and state (06-4)", () => {
|
|
57
|
+
mockIndices = [
|
|
58
|
+
idx({ id: 1, enabled_at: "2026-01-01T00:00:00Z" }),
|
|
59
|
+
idx({
|
|
60
|
+
id: 2,
|
|
61
|
+
embedding: "other-model",
|
|
62
|
+
index_type: "rag",
|
|
63
|
+
index_params: { dims: 768 },
|
|
64
|
+
enabled_at: "2026-01-02T00:00:00Z",
|
|
65
|
+
}),
|
|
66
|
+
idx({ id: 3, embedding: "off-model" }),
|
|
67
|
+
idx({ id: 4, embedding: "foreign", provider: 99 }),
|
|
68
|
+
];
|
|
69
|
+
const { getByText, queryByText, getAllByText, getAllByRole } = setup();
|
|
70
|
+
expect(getByText("gemini-embedding-001")).toBeVisible();
|
|
71
|
+
expect(getByText("other-model")).toBeVisible();
|
|
72
|
+
expect(getByText("off-model")).toBeVisible();
|
|
73
|
+
expect(queryByText("foreign")).not.toBeInTheDocument();
|
|
74
|
+
expect(getAllByText("2048")).toHaveLength(2);
|
|
75
|
+
expect(getByText("768")).toBeVisible();
|
|
76
|
+
expect(getAllByText("providers.embeddingModels.purpose.rag")).toHaveLength(
|
|
77
|
+
1,
|
|
78
|
+
);
|
|
79
|
+
expect(
|
|
80
|
+
getAllByRole("checkbox", { name: "providers.embeddingModels.disable" }),
|
|
81
|
+
).toHaveLength(2);
|
|
82
|
+
expect(
|
|
83
|
+
getAllByRole("checkbox", { name: "providers.embeddingModels.enable" }),
|
|
84
|
+
).toHaveLength(1);
|
|
85
|
+
expect(queryByText(/providers.embeddingModels.state/)).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("shows the purpose as a teal or violet label (13-3)", () => {
|
|
89
|
+
mockIndices = [
|
|
90
|
+
idx({ id: 1 }),
|
|
91
|
+
idx({ id: 2, embedding: "r", index_type: "rag" }),
|
|
92
|
+
];
|
|
93
|
+
const { getByText } = setup();
|
|
94
|
+
const suggestions = getByText(
|
|
95
|
+
"providers.embeddingModels.purpose.suggestions",
|
|
96
|
+
);
|
|
97
|
+
const rag = getByText("providers.embeddingModels.purpose.rag");
|
|
98
|
+
expect(suggestions).toHaveClass("ui", "label", "teal");
|
|
99
|
+
expect(rag).toHaveClass("ui", "label", "violet");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("has no delete control and keeps the locked hint behind a ? icon (13-4, 13-7)", async () => {
|
|
103
|
+
mockIndices = [idx()];
|
|
104
|
+
const {
|
|
105
|
+
user,
|
|
106
|
+
queryByRole,
|
|
107
|
+
queryByText,
|
|
108
|
+
findByText,
|
|
109
|
+
getByTestId,
|
|
110
|
+
container,
|
|
111
|
+
} = setup();
|
|
112
|
+
expect(
|
|
113
|
+
queryByRole("button", { name: "providers.embeddingModels.delete" }),
|
|
114
|
+
).toBeNull();
|
|
115
|
+
expect(queryByText("providers.embeddingModels.delete")).toBeNull();
|
|
116
|
+
expect(container.querySelector(".trash")).toBeNull();
|
|
117
|
+
expect(queryByText("providers.embeddingModels.locked")).toBeNull();
|
|
118
|
+
await user.hover(getByTestId("help-locked"));
|
|
119
|
+
expect(await findByText("providers.embeddingModels.locked")).toBeVisible();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("shows the empty text and the locked hint icon (06-6)", () => {
|
|
123
|
+
const { getByText, getByTestId } = setup();
|
|
124
|
+
expect(getByText("providers.embeddingModels.empty")).toBeVisible();
|
|
125
|
+
expect(getByTestId("help-locked")).toBeVisible();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("shows headers without ? icons, and the collection name", () => {
|
|
129
|
+
mockIndices = [idx({ collection_name: "gemini_suggestions" })];
|
|
130
|
+
const { getByText, getByRole, container } = setup();
|
|
131
|
+
for (const key of [
|
|
132
|
+
"providers.embeddingModels.form.embedding",
|
|
133
|
+
"providers.embeddingModels.form.collection",
|
|
134
|
+
"providers.embeddingModels.form.purpose",
|
|
135
|
+
"providers.embeddingModels.form.dims",
|
|
136
|
+
"providers.form.enabled",
|
|
137
|
+
]) {
|
|
138
|
+
expect(getByRole("columnheader", { name: key })).toBeVisible();
|
|
139
|
+
}
|
|
140
|
+
expect(getByText("gemini_suggestions")).toBeVisible();
|
|
141
|
+
expect(container.querySelector("thead .icon")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("creates a model with collection_name, index_type, provider_id and dims (13-6)", async () => {
|
|
145
|
+
const {
|
|
146
|
+
user,
|
|
147
|
+
getByText,
|
|
148
|
+
getByPlaceholderText,
|
|
149
|
+
getByRole,
|
|
150
|
+
getByTestId,
|
|
151
|
+
findByText,
|
|
152
|
+
} = setup();
|
|
153
|
+
await user.click(getByText("providers.embeddingModels.add"));
|
|
154
|
+
await user.type(
|
|
155
|
+
getByPlaceholderText(
|
|
156
|
+
"providers.embeddingModels.form.collection.placeholder",
|
|
157
|
+
),
|
|
158
|
+
"catalog_rag",
|
|
159
|
+
);
|
|
160
|
+
await user.type(
|
|
161
|
+
getByPlaceholderText("providers.embeddingModels.form.embedding"),
|
|
162
|
+
"text-embedding-3-small",
|
|
163
|
+
);
|
|
164
|
+
await user.click(getByRole("listbox"));
|
|
165
|
+
await user.click(
|
|
166
|
+
getByRole("option", { name: "providers.embeddingModels.purpose.rag" }),
|
|
167
|
+
);
|
|
168
|
+
await user.type(
|
|
169
|
+
getByPlaceholderText("providers.embeddingModels.form.dims"),
|
|
170
|
+
"1536",
|
|
171
|
+
);
|
|
172
|
+
await user.hover(getByTestId("help-dims"));
|
|
173
|
+
expect(
|
|
174
|
+
await findByText("providers.embeddingModels.form.dims.hint"),
|
|
175
|
+
).toBeVisible();
|
|
176
|
+
await user.click(getByRole("button", { name: "actions.save" }));
|
|
177
|
+
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
|
178
|
+
expect(mockCreate.mock.calls[0][0]).toEqual({
|
|
179
|
+
index: {
|
|
180
|
+
embedding: "text-embedding-3-small",
|
|
181
|
+
collection_name: "catalog_rag",
|
|
182
|
+
index_type: "rag",
|
|
183
|
+
provider_id: 5,
|
|
184
|
+
index_params: { dims: 1536 },
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
await waitFor(() => expect(mockMutate).toHaveBeenCalled());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("omits index_params when dims is empty (06-5)", async () => {
|
|
191
|
+
const { user, getByText, getByPlaceholderText, getByRole } = setup();
|
|
192
|
+
await user.click(getByText("providers.embeddingModels.add"));
|
|
193
|
+
await user.type(
|
|
194
|
+
getByPlaceholderText("providers.embeddingModels.form.embedding"),
|
|
195
|
+
"m",
|
|
196
|
+
);
|
|
197
|
+
await user.type(
|
|
198
|
+
getByPlaceholderText(
|
|
199
|
+
"providers.embeddingModels.form.collection.placeholder",
|
|
200
|
+
),
|
|
201
|
+
"col",
|
|
202
|
+
);
|
|
203
|
+
await user.click(getByRole("button", { name: "actions.save" }));
|
|
204
|
+
await waitFor(() => expect(mockCreate).toHaveBeenCalled());
|
|
205
|
+
expect(mockCreate.mock.calls[0][0]).toEqual({
|
|
206
|
+
index: {
|
|
207
|
+
embedding: "m",
|
|
208
|
+
collection_name: "col",
|
|
209
|
+
index_type: "suggestions",
|
|
210
|
+
provider_id: 5,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("shows an index_params server error next to dimensions (06-5)", async () => {
|
|
216
|
+
mockCreate.mockRejectedValue({
|
|
217
|
+
response: { data: { errors: { index_params: ["dims is required"] } } },
|
|
218
|
+
});
|
|
219
|
+
const { user, getByText, getByPlaceholderText, getByRole, findByText } =
|
|
220
|
+
setup();
|
|
221
|
+
await user.click(getByText("providers.embeddingModels.add"));
|
|
222
|
+
await user.type(
|
|
223
|
+
getByPlaceholderText("providers.embeddingModels.form.embedding"),
|
|
224
|
+
"unknown",
|
|
225
|
+
);
|
|
226
|
+
await user.type(
|
|
227
|
+
getByPlaceholderText(
|
|
228
|
+
"providers.embeddingModels.form.collection.placeholder",
|
|
229
|
+
),
|
|
230
|
+
"col",
|
|
231
|
+
);
|
|
232
|
+
await user.click(getByRole("button", { name: "actions.save" }));
|
|
233
|
+
expect(await findByText("dims is required")).toBeVisible();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("shows a collection_name server error once, under the field (13-6)", async () => {
|
|
237
|
+
mockCreate.mockRejectedValue({
|
|
238
|
+
response: {
|
|
239
|
+
data: { errors: { collection_name: ["has already been taken"] } },
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const { user, getByText, getByPlaceholderText, getByRole, findAllByText } =
|
|
243
|
+
setup();
|
|
244
|
+
await user.click(getByText("providers.embeddingModels.add"));
|
|
245
|
+
await user.type(
|
|
246
|
+
getByPlaceholderText("providers.embeddingModels.form.embedding"),
|
|
247
|
+
"m",
|
|
248
|
+
);
|
|
249
|
+
await user.type(
|
|
250
|
+
getByPlaceholderText(
|
|
251
|
+
"providers.embeddingModels.form.collection.placeholder",
|
|
252
|
+
),
|
|
253
|
+
"taken",
|
|
254
|
+
);
|
|
255
|
+
await user.click(getByRole("button", { name: "actions.save" }));
|
|
256
|
+
expect(await findAllByText("has already been taken")).toHaveLength(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("clearing server errors", () => {
|
|
260
|
+
const fillAndFail = async (errors) => {
|
|
261
|
+
mockCreate.mockRejectedValue({ response: { data: { errors } } });
|
|
262
|
+
const r = setup();
|
|
263
|
+
await r.user.click(r.getByText("providers.embeddingModels.add"));
|
|
264
|
+
await r.user.type(
|
|
265
|
+
r.getByPlaceholderText("providers.embeddingModels.form.embedding"),
|
|
266
|
+
"m",
|
|
267
|
+
);
|
|
268
|
+
await r.user.type(
|
|
269
|
+
r.getByPlaceholderText(
|
|
270
|
+
"providers.embeddingModels.form.collection.placeholder",
|
|
271
|
+
),
|
|
272
|
+
"taken",
|
|
273
|
+
);
|
|
274
|
+
await r.user.click(r.getByRole("button", { name: "actions.save" }));
|
|
275
|
+
return r;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
it("clears the collection name error as soon as the field changes", async () => {
|
|
279
|
+
const r = await fillAndFail({
|
|
280
|
+
collection_name: ["has already been taken"],
|
|
281
|
+
embedding: ["can't be blank"],
|
|
282
|
+
});
|
|
283
|
+
expect(await r.findByText("has already been taken")).toBeVisible();
|
|
284
|
+
await r.user.type(
|
|
285
|
+
r.getByPlaceholderText(
|
|
286
|
+
"providers.embeddingModels.form.collection.placeholder",
|
|
287
|
+
),
|
|
288
|
+
"2",
|
|
289
|
+
);
|
|
290
|
+
expect(r.queryByText("has already been taken")).not.toBeInTheDocument();
|
|
291
|
+
// Other fields keep their own error
|
|
292
|
+
expect(r.getByText("can't be blank")).toBeVisible();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("clears the dimensions error when the model name or the dimensions change", async () => {
|
|
296
|
+
const r = await fillAndFail({ index_params: ["dims is required"] });
|
|
297
|
+
expect(await r.findByText("dims is required")).toBeVisible();
|
|
298
|
+
await r.user.type(
|
|
299
|
+
r.getByPlaceholderText("providers.embeddingModels.form.embedding"),
|
|
300
|
+
"x",
|
|
301
|
+
);
|
|
302
|
+
expect(r.queryByText("dims is required")).not.toBeInTheDocument();
|
|
303
|
+
|
|
304
|
+
mockCreate.mockRejectedValue({
|
|
305
|
+
response: { data: { errors: { index_params: ["dims is required"] } } },
|
|
306
|
+
});
|
|
307
|
+
await r.user.click(r.getByRole("button", { name: "actions.save" }));
|
|
308
|
+
expect(await r.findByText("dims is required")).toBeVisible();
|
|
309
|
+
await r.user.type(
|
|
310
|
+
r.getByPlaceholderText("providers.embeddingModels.form.dims"),
|
|
311
|
+
"8",
|
|
312
|
+
);
|
|
313
|
+
expect(r.queryByText("dims is required")).not.toBeInTheDocument();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("lets the general form error be dismissed and keeps the field errors", async () => {
|
|
317
|
+
const r = await fillAndFail({
|
|
318
|
+
provider_id: ["anthropic providers do not offer embedding models"],
|
|
319
|
+
collection_name: ["has already been taken"],
|
|
320
|
+
});
|
|
321
|
+
expect(
|
|
322
|
+
await r.findByText("anthropic providers do not offer embedding models"),
|
|
323
|
+
).toBeVisible();
|
|
324
|
+
await r.user.click(r.container.querySelector(".message .close.icon"));
|
|
325
|
+
expect(
|
|
326
|
+
r.queryByText("anthropic providers do not offer embedding models"),
|
|
327
|
+
).not.toBeInTheDocument();
|
|
328
|
+
expect(r.getByText("has already been taken")).toBeVisible();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("lets the enable and disable errors be dismissed", async () => {
|
|
333
|
+
mockIndices = [idx({ id: 3 })];
|
|
334
|
+
mockEnable.mockRejectedValue({
|
|
335
|
+
response: { data: { errors: { detail: ["Not allowed"] } } },
|
|
336
|
+
});
|
|
337
|
+
const { user, getByRole, findByText, queryByText, container } = setup();
|
|
338
|
+
await user.click(
|
|
339
|
+
getByRole("checkbox", { name: "providers.embeddingModels.enable" }),
|
|
340
|
+
);
|
|
341
|
+
expect(await findByText("Not allowed")).toBeVisible();
|
|
342
|
+
await user.click(container.querySelector(".message .close.icon"));
|
|
343
|
+
expect(queryByText("Not allowed")).not.toBeInTheDocument();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("validates the collection name as typed and gates Save (13-5)", async () => {
|
|
347
|
+
const { user, getByText, getByPlaceholderText, getByRole, queryByText } =
|
|
348
|
+
setup();
|
|
349
|
+
await user.click(getByText("providers.embeddingModels.add"));
|
|
350
|
+
const save = getByRole("button", { name: "actions.save" });
|
|
351
|
+
const hint = "providers.embeddingModels.form.collection.hint";
|
|
352
|
+
const collection = getByPlaceholderText(
|
|
353
|
+
"providers.embeddingModels.form.collection.placeholder",
|
|
354
|
+
);
|
|
355
|
+
expect(
|
|
356
|
+
getByText("providers.embeddingModels.form.collection"),
|
|
357
|
+
).toBeVisible();
|
|
358
|
+
expect(queryByText(hint)).toBeNull();
|
|
359
|
+
await user.type(
|
|
360
|
+
getByPlaceholderText("providers.embeddingModels.form.embedding"),
|
|
361
|
+
"m",
|
|
362
|
+
);
|
|
363
|
+
expect(save).toBeDisabled();
|
|
364
|
+
await user.type(collection, "Bad-Name");
|
|
365
|
+
expect(save).toBeDisabled();
|
|
366
|
+
await user.clear(collection);
|
|
367
|
+
await user.type(collection, "good_name1");
|
|
368
|
+
expect(save).toBeEnabled();
|
|
369
|
+
await user.clear(collection);
|
|
370
|
+
await user.type(collection, "a".repeat(61));
|
|
371
|
+
expect(save).toBeDisabled();
|
|
372
|
+
await user.clear(collection);
|
|
373
|
+
await user.type(collection, "a".repeat(60));
|
|
374
|
+
expect(save).toBeEnabled();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("enables at once and disables only after confirmation (13-1, 13-2)", async () => {
|
|
378
|
+
mockIndices = [
|
|
379
|
+
idx({ id: 1, enabled_at: "2026-01-01T00:00:00Z" }),
|
|
380
|
+
idx({ id: 3, embedding: "off-model" }),
|
|
381
|
+
];
|
|
382
|
+
const { user, getByRole, getByText } = setup();
|
|
383
|
+
await user.click(
|
|
384
|
+
getByRole("checkbox", { name: "providers.embeddingModels.disable" }),
|
|
385
|
+
);
|
|
386
|
+
expect(mockDisable).not.toHaveBeenCalled();
|
|
387
|
+
expect(
|
|
388
|
+
getByText("providers.embeddingModels.disable.confirm.header"),
|
|
389
|
+
).toBeVisible();
|
|
390
|
+
expect(
|
|
391
|
+
getByText("providers.embeddingModels.disable.confirm.content"),
|
|
392
|
+
).toBeVisible();
|
|
393
|
+
await user.click(
|
|
394
|
+
getByRole("button", { name: /modal-affirmative-action/i }),
|
|
395
|
+
);
|
|
396
|
+
await waitFor(() => expect(mockDisable).toHaveBeenCalledWith({ id: 1 }));
|
|
397
|
+
await waitFor(() => expect(mockMutate).toHaveBeenCalled());
|
|
398
|
+
await user.click(
|
|
399
|
+
getByRole("checkbox", { name: "providers.embeddingModels.enable" }),
|
|
400
|
+
);
|
|
401
|
+
await waitFor(() => expect(mockEnable).toHaveBeenCalledWith({ id: 3 }));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it.each([["suggestions"], ["rag"]])(
|
|
405
|
+
"shows the %s backfill notice after enabling until dismissed (06-7)",
|
|
406
|
+
async (purpose) => {
|
|
407
|
+
mockIndices = [idx({ id: 3, index_type: purpose })];
|
|
408
|
+
const { user, getByRole, findByText, queryByText, container } = setup();
|
|
409
|
+
expect(
|
|
410
|
+
queryByText(`providers.embeddingModels.backfill.${purpose}`),
|
|
411
|
+
).not.toBeInTheDocument();
|
|
412
|
+
await user.click(
|
|
413
|
+
getByRole("checkbox", { name: "providers.embeddingModels.enable" }),
|
|
414
|
+
);
|
|
415
|
+
expect(
|
|
416
|
+
await findByText(`providers.embeddingModels.backfill.${purpose}`),
|
|
417
|
+
).toBeVisible();
|
|
418
|
+
await user.click(container.querySelector(".message .close.icon"));
|
|
419
|
+
expect(
|
|
420
|
+
queryByText(`providers.embeddingModels.backfill.${purpose}`),
|
|
421
|
+
).not.toBeInTheDocument();
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
it("shows the server error and no backfill notice when enabling fails", async () => {
|
|
426
|
+
mockIndices = [idx({ id: 3 })];
|
|
427
|
+
mockEnable.mockRejectedValue({
|
|
428
|
+
response: { data: { errors: { detail: ["Not allowed"] } } },
|
|
429
|
+
});
|
|
430
|
+
const { user, getByRole, findByText, queryByText } = setup();
|
|
431
|
+
await user.click(
|
|
432
|
+
getByRole("checkbox", { name: "providers.embeddingModels.enable" }),
|
|
433
|
+
);
|
|
434
|
+
expect(await findByText("Not allowed")).toBeVisible();
|
|
435
|
+
expect(
|
|
436
|
+
queryByText("providers.embeddingModels.backfill.suggestions"),
|
|
437
|
+
).not.toBeInTheDocument();
|
|
438
|
+
expect(mockMutate).toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("shows the server error and keeps the notice when disabling fails", async () => {
|
|
442
|
+
mockIndices = [idx({ id: 1, enabled_at: "2026-01-01T00:00:00Z" })];
|
|
443
|
+
mockDisable.mockRejectedValue({
|
|
444
|
+
response: { data: { errors: { detail: ["Cannot disable"] } } },
|
|
445
|
+
});
|
|
446
|
+
const { user, getByRole, findByText } = setup();
|
|
447
|
+
await user.click(
|
|
448
|
+
getByRole("checkbox", { name: "providers.embeddingModels.disable" }),
|
|
449
|
+
);
|
|
450
|
+
await user.click(
|
|
451
|
+
getByRole("button", { name: /modal-affirmative-action/i }),
|
|
452
|
+
);
|
|
453
|
+
expect(await findByText("Cannot disable")).toBeVisible();
|
|
454
|
+
});
|
|
455
|
+
});
|