@carlonicora/nextjs-jsonapi 3.0.0 → 3.1.0
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/dist/AbstractApiData-DtitRWPz.d.ts +47 -0
- package/dist/AbstractApiData-XLhBP5Tl.d.mts +47 -0
- package/dist/{BlockNoteEditor-CDTU6DRN.js → BlockNoteEditor-4KBDD2TM.js} +20 -20
- package/dist/{BlockNoteEditor-CDTU6DRN.js.map → BlockNoteEditor-4KBDD2TM.js.map} +1 -1
- package/dist/{BlockNoteEditor-VVAY73V2.mjs → BlockNoteEditor-LJ2KA44Z.mjs} +5 -5
- package/dist/{JsonApiRequest-3UWVIIFL.mjs → JsonApiRequest-2OQCZAK6.mjs} +2 -2
- package/dist/JsonApiRequest-6TYNBVUL.js +26 -0
- package/dist/{JsonApiRequest-QRU3IPIU.js.map → JsonApiRequest-6TYNBVUL.js.map} +1 -1
- package/dist/ai-connection.module-28f-hux0.d.mts +255 -0
- package/dist/ai-connection.module-Bqwh_DnR.d.ts +255 -0
- package/dist/billing/index.js +399 -399
- package/dist/billing/index.mjs +4 -4
- package/dist/{chunk-TWXKAY34.mjs → chunk-CGJW7ZZM.mjs} +310 -5
- package/dist/chunk-CGJW7ZZM.mjs.map +1 -0
- package/dist/{chunk-E5YA5Z5X.mjs → chunk-CMGXH5SA.mjs} +2 -1
- package/dist/chunk-CMGXH5SA.mjs.map +1 -0
- package/dist/{chunk-JKGEJGSD.js → chunk-H7DOSB7W.js} +9 -9
- package/dist/{chunk-JKGEJGSD.js.map → chunk-H7DOSB7W.js.map} +1 -1
- package/dist/{chunk-FPO4DLZN.js → chunk-PFAZPP54.js} +2 -1
- package/dist/chunk-PFAZPP54.js.map +1 -0
- package/dist/{chunk-UGNLKDI6.js → chunk-UYOQG5IW.js} +1628 -959
- package/dist/chunk-UYOQG5IW.js.map +1 -0
- package/dist/{chunk-J54546YC.mjs → chunk-WCRZEBX4.mjs} +1750 -1081
- package/dist/chunk-WCRZEBX4.mjs.map +1 -0
- package/dist/{chunk-53B6NPGA.js → chunk-YUXCJ35V.js} +377 -72
- package/dist/chunk-YUXCJ35V.js.map +1 -0
- package/dist/{chunk-PHEFWL6L.mjs → chunk-ZMYZFQN3.mjs} +3 -3
- package/dist/client/index.js +5 -5
- package/dist/client/index.mjs +4 -4
- package/dist/components/index.d.mts +122 -4
- package/dist/components/index.d.ts +122 -4
- package/dist/components/index.js +31 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +30 -4
- package/dist/{config-BIjrg5wd.d.ts → config-B6UB7j69.d.ts} +1 -46
- package/dist/{config-BRUjtCd1.d.mts → config-n-ZpPmOL.d.mts} +1 -46
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +5 -4
- package/dist/core/index.d.ts +5 -4
- package/dist/core/index.js +11 -3
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +10 -2
- package/dist/features/help/index.js +38 -38
- package/dist/features/help/index.mjs +4 -4
- package/dist/features/tokenusage/index.d.mts +2 -2
- package/dist/features/tokenusage/index.d.ts +2 -2
- package/dist/features/tokenusage/index.js +102 -102
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -3
- package/dist/server/index.js +12 -12
- package/dist/server/index.mjs +2 -2
- package/package.json +1 -1
- package/src/components/index.ts +1 -0
- package/src/core/index.ts +5 -0
- package/src/core/registry/ModuleRegistry.ts +2 -0
- package/src/core/utils/translateResponse.ts +6 -0
- package/src/features/administration/components/AdminIndexContainer.tsx +9 -2
- package/src/features/ai-connection/ai-connection.module.ts +14 -0
- package/src/features/ai-connection/components/containers/AiConnectionsContainer.tsx +109 -0
- package/src/features/ai-connection/components/forms/AiConnectionDeleter.tsx +58 -0
- package/src/features/ai-connection/components/forms/AiConnectionEditor.tsx +329 -0
- package/src/features/ai-connection/components/lists/AiConnectionTypeCard.tsx +223 -0
- package/src/features/ai-connection/contexts/AiConnectionsContext.tsx +111 -0
- package/src/features/ai-connection/data/__tests__/ai-connection.test.ts +202 -0
- package/src/features/ai-connection/data/ai-connection.fields.ts +6 -0
- package/src/features/ai-connection/data/ai-connection.interface.ts +87 -0
- package/src/features/ai-connection/data/ai-connection.service.ts +90 -0
- package/src/features/ai-connection/data/ai-connection.ts +244 -0
- package/src/features/ai-connection/data/index.ts +4 -0
- package/src/features/ai-connection/i18n-keys.ts +66 -0
- package/src/features/ai-connection/index.ts +11 -0
- package/src/features/index.ts +1 -0
- package/src/features/user/components/forms/UserEditor.tsx +11 -2
- package/dist/AuthComponent-Cik96ElA.d.mts +0 -56
- package/dist/AuthComponent-DTTKu3VK.d.ts +0 -56
- package/dist/JsonApiRequest-QRU3IPIU.js +0 -26
- package/dist/chunk-53B6NPGA.js.map +0 -1
- package/dist/chunk-E5YA5Z5X.mjs.map +0 -1
- package/dist/chunk-FPO4DLZN.js.map +0 -1
- package/dist/chunk-J54546YC.mjs.map +0 -1
- package/dist/chunk-TWXKAY34.mjs.map +0 -1
- package/dist/chunk-UGNLKDI6.js.map +0 -1
- /package/dist/{BlockNoteEditor-VVAY73V2.mjs.map → BlockNoteEditor-LJ2KA44Z.mjs.map} +0 -0
- /package/dist/{JsonApiRequest-3UWVIIFL.mjs.map → JsonApiRequest-2OQCZAK6.mjs.map} +0 -0
- /package/dist/{chunk-PHEFWL6L.mjs.map → chunk-ZMYZFQN3.mjs.map} +0 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ReactNode, useCallback, useEffect, useMemo } from "react";
|
|
6
|
+
import { useForm } from "react-hook-form";
|
|
7
|
+
import { v4 } from "uuid";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { EditorSheet, FormCheckbox, FormInput, FormPassword, FormSelect, MicroLabel } from "../../../../components";
|
|
10
|
+
import { Modules } from "../../../../core";
|
|
11
|
+
import { Alert, AlertDescription, AlertTitle } from "../../../../shadcnui";
|
|
12
|
+
import { AiConnectionInput, AiConnectionInterface, AiConnectionService, AiProviderFieldDescriptor } from "../../data";
|
|
13
|
+
import { useAiConnections } from "../../contexts/AiConnectionsContext";
|
|
14
|
+
|
|
15
|
+
/** Fields grouped under the collapsed-by-convention cost block (spec § 4). */
|
|
16
|
+
const COST_FIELDS = [
|
|
17
|
+
"inputCostPer1MTokens",
|
|
18
|
+
"outputCostPer1MTokens",
|
|
19
|
+
"cachedInputCostPer1MTokens",
|
|
20
|
+
"costPerMinute",
|
|
21
|
+
"costPerPage",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
type AiConnectionFormValues = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
provider: string;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
/** Every provider-specific field, keyed by the registry's field id. */
|
|
30
|
+
values: Record<string, any>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type AiConnectionEditorProps = {
|
|
34
|
+
/** Omitted in create mode. */
|
|
35
|
+
connection?: AiConnectionInterface;
|
|
36
|
+
/** Fixed by the card that owns the editor — never editable. */
|
|
37
|
+
connectionType: string;
|
|
38
|
+
/** Slot in the chain. Fixed: reordering is the up/down arrows' job. */
|
|
39
|
+
position: number;
|
|
40
|
+
/** Scope, set on creation only — immutable thereafter (spec § Decisions). */
|
|
41
|
+
companyId?: string;
|
|
42
|
+
trigger?: ReactNode;
|
|
43
|
+
forceShow?: boolean;
|
|
44
|
+
onClose?: () => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Schema-driven editor for one link in a fallback chain.
|
|
49
|
+
*
|
|
50
|
+
* The static half of the form (name / provider / enabled) is a fixed zod shape;
|
|
51
|
+
* everything else is rendered blind from `meta.providerRegistry`, so a new
|
|
52
|
+
* provider field on the backend needs no change here — only an i18n string.
|
|
53
|
+
* Required-ness is enforced by a `superRefine` over the same registry, which
|
|
54
|
+
* keeps failures inline on the field instead of an error toast. The backend
|
|
55
|
+
* re-validates authoritatively.
|
|
56
|
+
*/
|
|
57
|
+
export function AiConnectionEditor({
|
|
58
|
+
connection,
|
|
59
|
+
connectionType,
|
|
60
|
+
position,
|
|
61
|
+
companyId,
|
|
62
|
+
trigger,
|
|
63
|
+
forceShow,
|
|
64
|
+
onClose,
|
|
65
|
+
}: AiConnectionEditorProps) {
|
|
66
|
+
const t = useTranslations();
|
|
67
|
+
const { providerRegistry, refresh } = useAiConnections();
|
|
68
|
+
const isEdit = !!connection;
|
|
69
|
+
|
|
70
|
+
const registryRows = useMemo(() => providerRegistry[connectionType] ?? [], [providerRegistry, connectionType]);
|
|
71
|
+
const providers = useMemo(() => registryRows.map((row) => row.provider), [registryRows]);
|
|
72
|
+
|
|
73
|
+
const fieldsFor = useCallback(
|
|
74
|
+
(provider: string): AiProviderFieldDescriptor[] =>
|
|
75
|
+
registryRows.find((row) => row.provider === provider)?.fields ?? [],
|
|
76
|
+
[registryRows],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/** Seeds the `values` map for a provider: stored value → registry default → empty. */
|
|
80
|
+
const defaultsFor = useCallback(
|
|
81
|
+
(provider: string): Record<string, any> => {
|
|
82
|
+
const stored = connection as unknown as Record<string, unknown> | undefined;
|
|
83
|
+
const seeded: Record<string, any> = {};
|
|
84
|
+
|
|
85
|
+
fieldsFor(provider).forEach((descriptor) => {
|
|
86
|
+
if (descriptor.kind === "boolean") {
|
|
87
|
+
const current = stored?.[descriptor.field];
|
|
88
|
+
seeded[descriptor.field] =
|
|
89
|
+
current !== undefined && current !== null ? current === true : descriptor.default === true;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Secrets are write-only: the API answers with a has-flag, never a value.
|
|
94
|
+
if (descriptor.kind === "secret") {
|
|
95
|
+
seeded[descriptor.field] = "";
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const current = stored?.[descriptor.field];
|
|
100
|
+
if (current !== undefined && current !== null) seeded[descriptor.field] = String(current);
|
|
101
|
+
else seeded[descriptor.field] = descriptor.default !== undefined ? String(descriptor.default) : "";
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return seeded;
|
|
105
|
+
},
|
|
106
|
+
[connection, fieldsFor],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const hasStoredSecret = useCallback(
|
|
110
|
+
(field: string) => {
|
|
111
|
+
if (!connection) return false;
|
|
112
|
+
if (field === "apiKey") return connection.hasApiKey;
|
|
113
|
+
if (field === "googleCredentialsBase64") return connection.hasGoogleCredentials;
|
|
114
|
+
return false;
|
|
115
|
+
},
|
|
116
|
+
[connection],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const formSchema = useMemo(
|
|
120
|
+
() =>
|
|
121
|
+
z
|
|
122
|
+
.object({
|
|
123
|
+
id: z.uuidv4(),
|
|
124
|
+
name: z.string().min(1, { message: t("ai_connections.admin.errors.name") }),
|
|
125
|
+
provider: z.string().min(1, { message: t("ai_connections.admin.errors.required_field") }),
|
|
126
|
+
enabled: z.boolean(),
|
|
127
|
+
values: z.record(z.string(), z.any()),
|
|
128
|
+
})
|
|
129
|
+
.superRefine((data, ctx) => {
|
|
130
|
+
fieldsFor(data.provider)
|
|
131
|
+
.filter((descriptor) => descriptor.required)
|
|
132
|
+
.forEach((descriptor) => {
|
|
133
|
+
const value = data.values?.[descriptor.field];
|
|
134
|
+
// A stored secret counts as filled: blank means "keep it".
|
|
135
|
+
const filled =
|
|
136
|
+
descriptor.kind === "secret"
|
|
137
|
+
? !!value || hasStoredSecret(descriptor.field)
|
|
138
|
+
: descriptor.kind === "boolean"
|
|
139
|
+
? value !== undefined
|
|
140
|
+
: value !== undefined && value !== null && String(value).trim() !== "";
|
|
141
|
+
if (filled) return;
|
|
142
|
+
ctx.addIssue({
|
|
143
|
+
code: "custom",
|
|
144
|
+
message: t("ai_connections.admin.errors.required_field"),
|
|
145
|
+
path: ["values", descriptor.field],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}),
|
|
149
|
+
[t, fieldsFor, hasStoredSecret],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const getDefaultValues = useCallback(
|
|
153
|
+
(): AiConnectionFormValues => ({
|
|
154
|
+
id: connection?.id ?? v4(),
|
|
155
|
+
name: connection?.name ?? "",
|
|
156
|
+
provider: connection?.provider ?? providers[0] ?? "",
|
|
157
|
+
enabled: connection?.enabled ?? true,
|
|
158
|
+
values: defaultsFor(connection?.provider ?? providers[0] ?? ""),
|
|
159
|
+
}),
|
|
160
|
+
[connection, providers, defaultsFor],
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const form = useForm<AiConnectionFormValues>({
|
|
164
|
+
resolver: zodResolver(formSchema),
|
|
165
|
+
defaultValues: getDefaultValues(),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// The registry arrives with the list request, which can resolve after this
|
|
169
|
+
// component mounts — so the provider and its field map are seeded again once
|
|
170
|
+
// it is available. Pre-filling via setValue is the supported create-mode
|
|
171
|
+
// pattern (EditorSheet re-seeds only on edit; see EditorSheet.tsx:163-190).
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (registryRows.length === 0) return;
|
|
174
|
+
|
|
175
|
+
const currentProvider = form.getValues("provider");
|
|
176
|
+
const provider = currentProvider || connection?.provider || providers[0] || "";
|
|
177
|
+
if (!provider) return;
|
|
178
|
+
if (provider !== currentProvider) form.setValue("provider", provider);
|
|
179
|
+
|
|
180
|
+
const currentValues = form.getValues("values");
|
|
181
|
+
if (!currentValues || Object.keys(currentValues).length === 0) form.setValue("values", defaultsFor(provider));
|
|
182
|
+
// Deliberately keyed on the registry alone: re-running on `form` identity
|
|
183
|
+
// would clobber what the user has typed.
|
|
184
|
+
}, [registryRows]);
|
|
185
|
+
|
|
186
|
+
const selectedProvider = form.watch("provider");
|
|
187
|
+
const selectedFields = fieldsFor(selectedProvider);
|
|
188
|
+
const configFields = selectedFields.filter((descriptor) => !COST_FIELDS.includes(descriptor.field));
|
|
189
|
+
const costFields = selectedFields.filter((descriptor) => COST_FIELDS.includes(descriptor.field));
|
|
190
|
+
|
|
191
|
+
/** Registry value → wire value. Numbers arrive from the input as strings. */
|
|
192
|
+
const coerce = (descriptor: AiProviderFieldDescriptor, raw: any) => {
|
|
193
|
+
if (descriptor.kind === "boolean") return raw === true;
|
|
194
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") return undefined;
|
|
195
|
+
if (descriptor.kind === "number") {
|
|
196
|
+
const parsed = Number(String(raw).replace(",", "."));
|
|
197
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
198
|
+
}
|
|
199
|
+
return String(raw);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const renderField = (descriptor: AiProviderFieldDescriptor) => {
|
|
203
|
+
const id = `values.${descriptor.field}`;
|
|
204
|
+
const label = t(`ai_connections.admin.fields.${descriptor.field}`);
|
|
205
|
+
|
|
206
|
+
switch (descriptor.kind) {
|
|
207
|
+
case "secret":
|
|
208
|
+
return (
|
|
209
|
+
<FormPassword
|
|
210
|
+
key={descriptor.field}
|
|
211
|
+
form={form}
|
|
212
|
+
id={id}
|
|
213
|
+
name={label}
|
|
214
|
+
isRequired={descriptor.required}
|
|
215
|
+
placeholder={
|
|
216
|
+
isEdit && hasStoredSecret(descriptor.field)
|
|
217
|
+
? t("ai_connections.admin.placeholders.secret_unchanged")
|
|
218
|
+
: undefined
|
|
219
|
+
}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
case "boolean":
|
|
223
|
+
return <FormCheckbox key={descriptor.field} form={form} id={id} name={label} />;
|
|
224
|
+
case "select":
|
|
225
|
+
return (
|
|
226
|
+
<FormSelect
|
|
227
|
+
key={descriptor.field}
|
|
228
|
+
form={form}
|
|
229
|
+
id={id}
|
|
230
|
+
name={label}
|
|
231
|
+
isRequired={descriptor.required}
|
|
232
|
+
allowEmpty={!descriptor.required}
|
|
233
|
+
values={(descriptor.options ?? []).map((option) => ({ id: option, text: option }))}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
case "number":
|
|
237
|
+
// "decimal", not "number": FormInput's number mode strips everything but
|
|
238
|
+
// digits, which would make a cost of 0.15 unrepresentable.
|
|
239
|
+
return (
|
|
240
|
+
<FormInput
|
|
241
|
+
key={descriptor.field}
|
|
242
|
+
form={form}
|
|
243
|
+
id={id}
|
|
244
|
+
name={label}
|
|
245
|
+
type="decimal"
|
|
246
|
+
isRequired={descriptor.required}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
default:
|
|
250
|
+
return <FormInput key={descriptor.field} form={form} id={id} name={label} isRequired={descriptor.required} />;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<EditorSheet
|
|
256
|
+
form={form}
|
|
257
|
+
entityType={t("ai_connections.admin.entity")}
|
|
258
|
+
entityName={connection?.name}
|
|
259
|
+
isEdit={isEdit}
|
|
260
|
+
module={Modules.AiConnection}
|
|
261
|
+
size="md"
|
|
262
|
+
onSuccess={refresh}
|
|
263
|
+
onSubmit={async (values) => {
|
|
264
|
+
const attributes: Record<string, unknown> = {};
|
|
265
|
+
fieldsFor(values.provider).forEach((descriptor) => {
|
|
266
|
+
const coerced = coerce(descriptor, values.values?.[descriptor.field]);
|
|
267
|
+
if (coerced !== undefined) attributes[descriptor.field] = coerced;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const payload = {
|
|
271
|
+
id: values.id,
|
|
272
|
+
name: values.name,
|
|
273
|
+
connectionType,
|
|
274
|
+
provider: values.provider,
|
|
275
|
+
position,
|
|
276
|
+
enabled: values.enabled,
|
|
277
|
+
...attributes,
|
|
278
|
+
// Scope is fixed at creation; sending it on update would be a no-op at
|
|
279
|
+
// best and a scope change at worst, which the design forbids.
|
|
280
|
+
...(isEdit || !companyId ? {} : { companyId }),
|
|
281
|
+
} as AiConnectionInput;
|
|
282
|
+
|
|
283
|
+
if (isEdit) await AiConnectionService.update(payload);
|
|
284
|
+
else await AiConnectionService.create(payload);
|
|
285
|
+
}}
|
|
286
|
+
onReset={getDefaultValues}
|
|
287
|
+
trigger={trigger}
|
|
288
|
+
forceShow={forceShow}
|
|
289
|
+
onClose={onClose}
|
|
290
|
+
>
|
|
291
|
+
<div className="flex w-full flex-col gap-y-4">
|
|
292
|
+
{connectionType === "embedder" && (
|
|
293
|
+
<Alert variant="destructive">
|
|
294
|
+
<AlertTitle>{t("ai_connections.admin.embedder_warning.title")}</AlertTitle>
|
|
295
|
+
<AlertDescription>{t("ai_connections.admin.embedder_warning.description")}</AlertDescription>
|
|
296
|
+
</Alert>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
<FormInput
|
|
300
|
+
form={form}
|
|
301
|
+
id="name"
|
|
302
|
+
name={t("ai_connections.admin.fields.name")}
|
|
303
|
+
placeholder={t("ai_connections.admin.placeholders.name")}
|
|
304
|
+
isRequired
|
|
305
|
+
/>
|
|
306
|
+
|
|
307
|
+
<FormSelect
|
|
308
|
+
form={form}
|
|
309
|
+
id="provider"
|
|
310
|
+
name={t("ai_connections.admin.fields.provider")}
|
|
311
|
+
isRequired
|
|
312
|
+
values={providers.map((provider) => ({ id: provider, text: provider }))}
|
|
313
|
+
onChange={(provider) => form.setValue("values", defaultsFor(provider))}
|
|
314
|
+
/>
|
|
315
|
+
|
|
316
|
+
<FormCheckbox form={form} id="enabled" name={t("ai_connections.admin.fields.enabled")} />
|
|
317
|
+
|
|
318
|
+
{configFields.map(renderField)}
|
|
319
|
+
|
|
320
|
+
{costFields.length > 0 && (
|
|
321
|
+
<div className="flex w-full flex-col gap-y-4">
|
|
322
|
+
<MicroLabel>{t("ai_connections.admin.fields.costs_section")}</MicroLabel>
|
|
323
|
+
{costFields.map(renderField)}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
</EditorSheet>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronDownIcon, ChevronUpIcon, PlusIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { SectionHeader } from "../../../../components";
|
|
7
|
+
import { errorToast } from "../../../../components/errors";
|
|
8
|
+
import { Badge, Button, Card, CardAction, CardContent, CardHeader, EmptyState, Switch } from "../../../../shadcnui";
|
|
9
|
+
import { cn } from "../../../../utils";
|
|
10
|
+
import { AiConnectionInput, AiConnectionInterface, AiConnectionService } from "../../data";
|
|
11
|
+
import { useAiConnections } from "../../contexts/AiConnectionsContext";
|
|
12
|
+
import { AiConnectionDeleter } from "../forms/AiConnectionDeleter";
|
|
13
|
+
import { AiConnectionEditor } from "../forms/AiConnectionEditor";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Rebuilds the full write payload for an existing connection.
|
|
17
|
+
*
|
|
18
|
+
* Every mutation goes through the model (AiConnectionService.update →
|
|
19
|
+
* AiConnection.createJsonApi), so an inline toggle has to resend the row's
|
|
20
|
+
* current attributes rather than patch a single one. Secrets are deliberately
|
|
21
|
+
* absent: an omitted secret means "keep the stored value" (spec § Decisions),
|
|
22
|
+
* and the API never serialises them back in the first place.
|
|
23
|
+
*/
|
|
24
|
+
function toInput(connection: AiConnectionInterface, overrides: Partial<AiConnectionInput>): AiConnectionInput {
|
|
25
|
+
return {
|
|
26
|
+
id: connection.id,
|
|
27
|
+
name: connection.name,
|
|
28
|
+
connectionType: connection.connectionType,
|
|
29
|
+
provider: connection.provider,
|
|
30
|
+
position: connection.position,
|
|
31
|
+
enabled: connection.enabled,
|
|
32
|
+
model: connection.model,
|
|
33
|
+
url: connection.url,
|
|
34
|
+
region: connection.region,
|
|
35
|
+
instance: connection.instance,
|
|
36
|
+
apiVersion: connection.apiVersion,
|
|
37
|
+
allowFallbacks: connection.allowFallbacks,
|
|
38
|
+
reasoningEffort: connection.reasoningEffort,
|
|
39
|
+
maxOutputTokens: connection.maxOutputTokens,
|
|
40
|
+
dimensions: connection.dimensions,
|
|
41
|
+
inputCostPer1MTokens: connection.inputCostPer1MTokens,
|
|
42
|
+
outputCostPer1MTokens: connection.outputCostPer1MTokens,
|
|
43
|
+
cachedInputCostPer1MTokens: connection.cachedInputCostPer1MTokens,
|
|
44
|
+
costPerMinute: connection.costPerMinute,
|
|
45
|
+
costPerPage: connection.costPerPage,
|
|
46
|
+
directUrl: connection.directUrl,
|
|
47
|
+
language: connection.language,
|
|
48
|
+
directFormat: connection.directFormat,
|
|
49
|
+
directProvider: connection.directProvider,
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type AiConnectionTypeCardProps = {
|
|
55
|
+
/** One of the eight AI connection types — also the card's heading key. */
|
|
56
|
+
connectionType: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One fallback chain, for one connection type, in the currently selected scope.
|
|
61
|
+
*
|
|
62
|
+
* The chain always ends with a read-only ".env fallback" row: the env block is
|
|
63
|
+
* the last candidate the resolver tries, so showing it keeps the page an honest
|
|
64
|
+
* picture of what a call will actually do (spec § 4).
|
|
65
|
+
*/
|
|
66
|
+
export function AiConnectionTypeCard({ connectionType }: AiConnectionTypeCardProps) {
|
|
67
|
+
const t = useTranslations();
|
|
68
|
+
const { connections, envDefaults, scopeCompanyId, refresh } = useAiConnections();
|
|
69
|
+
|
|
70
|
+
const rows = useMemo(
|
|
71
|
+
() =>
|
|
72
|
+
connections
|
|
73
|
+
.filter(
|
|
74
|
+
(connection) =>
|
|
75
|
+
connection.connectionType === connectionType &&
|
|
76
|
+
(scopeCompanyId ? connection.companyId === scopeCompanyId : !connection.companyId),
|
|
77
|
+
)
|
|
78
|
+
.sort((a, b) => a.position - b.position),
|
|
79
|
+
[connections, connectionType, scopeCompanyId],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Shown greyed-out under the "inheriting global chain" hint when a company
|
|
83
|
+
// scope has no chain of its own for this type.
|
|
84
|
+
const globalRows = useMemo(
|
|
85
|
+
() =>
|
|
86
|
+
connections
|
|
87
|
+
.filter((connection) => connection.connectionType === connectionType && !connection.companyId)
|
|
88
|
+
.sort((a, b) => a.position - b.position),
|
|
89
|
+
[connections, connectionType],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const inheritsGlobal = !!scopeCompanyId && rows.length === 0;
|
|
93
|
+
const envDefault = envDefaults[connectionType];
|
|
94
|
+
const typeLabel = t(`ai_connections.admin.types.${connectionType}`);
|
|
95
|
+
|
|
96
|
+
const setEnabled = async (connection: AiConnectionInterface, enabled: boolean) => {
|
|
97
|
+
try {
|
|
98
|
+
await AiConnectionService.update(toInput(connection, { enabled }));
|
|
99
|
+
await refresh();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
errorToast({ title: typeLabel, error });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Moves a row by one slot and renumbers the whole chain server-side. */
|
|
106
|
+
const move = async (index: number, delta: number) => {
|
|
107
|
+
const target = index + delta;
|
|
108
|
+
if (target < 0 || target >= rows.length) return;
|
|
109
|
+
const reordered = [...rows];
|
|
110
|
+
const [moved] = reordered.splice(index, 1);
|
|
111
|
+
reordered.splice(target, 0, moved);
|
|
112
|
+
try {
|
|
113
|
+
await AiConnectionService.reorder({ ids: reordered.map((connection) => connection.id) });
|
|
114
|
+
await refresh();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
errorToast({ title: typeLabel, error });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const renderRow = (connection: AiConnectionInterface, index: number, readOnly: boolean, total: number) => (
|
|
121
|
+
<div
|
|
122
|
+
key={connection.id}
|
|
123
|
+
className={cn("flex items-center gap-3 px-3 py-2", readOnly && "opacity-50")}
|
|
124
|
+
data-testid={`ai-connection-row-${connection.id}`}
|
|
125
|
+
>
|
|
126
|
+
<span className="text-muted-foreground w-5 text-right text-xs tabular-nums">{index + 1}</span>
|
|
127
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
128
|
+
<span className="truncate text-sm">{connection.name}</span>
|
|
129
|
+
<span className="text-muted-foreground truncate text-xs">
|
|
130
|
+
{[connection.provider, connection.model].filter(Boolean).join(" · ")}
|
|
131
|
+
</span>
|
|
132
|
+
</div>
|
|
133
|
+
<Switch
|
|
134
|
+
checked={connection.enabled}
|
|
135
|
+
disabled={readOnly}
|
|
136
|
+
aria-label={t("ai_connections.admin.fields.enabled")}
|
|
137
|
+
onCheckedChange={(checked) => {
|
|
138
|
+
if (!readOnly) void setEnabled(connection, checked === true);
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
<Button
|
|
142
|
+
variant="ghost"
|
|
143
|
+
size="icon-sm"
|
|
144
|
+
className="text-muted-foreground"
|
|
145
|
+
aria-label={t("ai_connections.admin.reorder.up")}
|
|
146
|
+
disabled={readOnly || index === 0}
|
|
147
|
+
onClick={() => void move(index, -1)}
|
|
148
|
+
>
|
|
149
|
+
<ChevronUpIcon />
|
|
150
|
+
</Button>
|
|
151
|
+
<Button
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="icon-sm"
|
|
154
|
+
className="text-muted-foreground"
|
|
155
|
+
aria-label={t("ai_connections.admin.reorder.down")}
|
|
156
|
+
disabled={readOnly || index === total - 1}
|
|
157
|
+
onClick={() => void move(index, 1)}
|
|
158
|
+
>
|
|
159
|
+
<ChevronDownIcon />
|
|
160
|
+
</Button>
|
|
161
|
+
{!readOnly && (
|
|
162
|
+
<>
|
|
163
|
+
<AiConnectionEditor
|
|
164
|
+
connection={connection}
|
|
165
|
+
connectionType={connectionType}
|
|
166
|
+
position={connection.position}
|
|
167
|
+
companyId={connection.companyId}
|
|
168
|
+
/>
|
|
169
|
+
<AiConnectionDeleter connection={connection} />
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Card>
|
|
177
|
+
<CardHeader>
|
|
178
|
+
<SectionHeader>{typeLabel}</SectionHeader>
|
|
179
|
+
<CardAction>
|
|
180
|
+
<AiConnectionEditor
|
|
181
|
+
connectionType={connectionType}
|
|
182
|
+
position={rows.length}
|
|
183
|
+
companyId={scopeCompanyId ?? undefined}
|
|
184
|
+
trigger={
|
|
185
|
+
<Button variant="outline" size="sm">
|
|
186
|
+
<PlusIcon />
|
|
187
|
+
{t("ai_connections.admin.create")}
|
|
188
|
+
</Button>
|
|
189
|
+
}
|
|
190
|
+
/>
|
|
191
|
+
</CardAction>
|
|
192
|
+
</CardHeader>
|
|
193
|
+
<CardContent>
|
|
194
|
+
{inheritsGlobal && (
|
|
195
|
+
<p className="text-muted-foreground mb-2 text-xs">{t("ai_connections.admin.scope.inherits_global")}</p>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
<div className="divide-border flex flex-col divide-y rounded-md border">
|
|
199
|
+
{inheritsGlobal
|
|
200
|
+
? globalRows.map((connection, index) => renderRow(connection, index, true, globalRows.length))
|
|
201
|
+
: rows.map((connection, index) => renderRow(connection, index, false, rows.length))}
|
|
202
|
+
|
|
203
|
+
{!inheritsGlobal && rows.length === 0 && (
|
|
204
|
+
<EmptyState
|
|
205
|
+
className="p-3"
|
|
206
|
+
title={t("ai_connections.admin.empty.title")}
|
|
207
|
+
description={t("ai_connections.admin.empty.description")}
|
|
208
|
+
/>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{/* The env block is always the last candidate the resolver tries, so
|
|
212
|
+
it always closes the chain — read-only, with no controls. */}
|
|
213
|
+
<div className="bg-muted/40 flex flex-wrap items-center gap-2 px-3 py-2">
|
|
214
|
+
<Badge variant="softGray">{t("ai_connections.admin.env_fallback")}</Badge>
|
|
215
|
+
<span className="text-muted-foreground truncate text-xs">
|
|
216
|
+
{[envDefault?.provider, envDefault?.model, envDefault?.url].filter(Boolean).join(" · ")}
|
|
217
|
+
</span>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</CardContent>
|
|
221
|
+
</Card>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { SharedProvider } from "../../../contexts";
|
|
6
|
+
import { usePageUrlGenerator } from "../../../hooks";
|
|
7
|
+
import { BreadcrumbItemData } from "../../../interfaces";
|
|
8
|
+
import { AiConnectionEnvDefaults, AiConnectionInterface, AiConnectionService, AiProviderRegistry } from "../data";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Route the breadcrumb links back to. Constant rather than a prop for the same
|
|
12
|
+
* reason TOKEN_USAGE_ADMIN_PAGE_URL is
|
|
13
|
+
* (features/tokenusage/contexts/TokenUsageAdminContext.tsx): the breadcrumb is
|
|
14
|
+
* built here, not by the consuming app, and the path convention IS the contract.
|
|
15
|
+
*/
|
|
16
|
+
export const AI_CONNECTIONS_ADMIN_PAGE_URL = "/administration/ai-connections";
|
|
17
|
+
|
|
18
|
+
export interface AiConnectionsContextType {
|
|
19
|
+
/** Every connection, both scopes — the cards filter per scope themselves. */
|
|
20
|
+
connections: AiConnectionInterface[];
|
|
21
|
+
/** `meta.providerRegistry` from the list endpoint: connectionType → provider rows. */
|
|
22
|
+
providerRegistry: AiProviderRegistry;
|
|
23
|
+
/** `meta.envDefaults` from the list endpoint: the read-only final chain link. */
|
|
24
|
+
envDefaults: AiConnectionEnvDefaults;
|
|
25
|
+
/** `null` = the global chain; a company id = that company's chain. */
|
|
26
|
+
scopeCompanyId: string | null;
|
|
27
|
+
setScopeCompanyId: (companyId: string | null) => void;
|
|
28
|
+
/** Re-reads the whole list. Called after every mutation. */
|
|
29
|
+
refresh: () => Promise<void>;
|
|
30
|
+
isLoading: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const defaultContextValue: AiConnectionsContextType = {
|
|
34
|
+
connections: [],
|
|
35
|
+
providerRegistry: {},
|
|
36
|
+
envDefaults: {},
|
|
37
|
+
scopeCompanyId: null,
|
|
38
|
+
setScopeCompanyId: () => {},
|
|
39
|
+
refresh: async () => {},
|
|
40
|
+
isLoading: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const AiConnectionsContext = createContext<AiConnectionsContextType | undefined>(undefined);
|
|
44
|
+
|
|
45
|
+
export type AiConnectionsProviderProps = {
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Owns the single list request behind the AI-connections admin page and the
|
|
51
|
+
* scope the page is looking at.
|
|
52
|
+
*
|
|
53
|
+
* It also publishes the page chrome, exactly as TokenUsageAdminContext does:
|
|
54
|
+
* RoundPageContainerTitle and the header breadcrumb read `useSharedContext()`,
|
|
55
|
+
* and that value is supplied far up the tree — a page that publishes nothing
|
|
56
|
+
* silently inherits the previous page's title.
|
|
57
|
+
*/
|
|
58
|
+
export const AiConnectionsProvider = ({ children }: AiConnectionsProviderProps) => {
|
|
59
|
+
const t = useTranslations();
|
|
60
|
+
const generateUrl = usePageUrlGenerator();
|
|
61
|
+
|
|
62
|
+
const [connections, setConnections] = useState<AiConnectionInterface[]>([]);
|
|
63
|
+
const [providerRegistry, setProviderRegistry] = useState<AiProviderRegistry>({});
|
|
64
|
+
const [envDefaults, setEnvDefaults] = useState<AiConnectionEnvDefaults>({});
|
|
65
|
+
const [scopeCompanyId, setScopeCompanyId] = useState<string | null>(null);
|
|
66
|
+
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
67
|
+
|
|
68
|
+
const refresh = useCallback(async () => {
|
|
69
|
+
setIsLoading(true);
|
|
70
|
+
try {
|
|
71
|
+
const response = await AiConnectionService.listConnections();
|
|
72
|
+
setConnections(response.connections);
|
|
73
|
+
setProviderRegistry(response.providerRegistry);
|
|
74
|
+
setEnvDefaults(response.envDefaults);
|
|
75
|
+
} finally {
|
|
76
|
+
setIsLoading(false);
|
|
77
|
+
}
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
void refresh();
|
|
82
|
+
}, [refresh]);
|
|
83
|
+
|
|
84
|
+
const value = useMemo<AiConnectionsContextType>(
|
|
85
|
+
() => ({
|
|
86
|
+
connections,
|
|
87
|
+
providerRegistry,
|
|
88
|
+
envDefaults,
|
|
89
|
+
scopeCompanyId,
|
|
90
|
+
setScopeCompanyId,
|
|
91
|
+
refresh,
|
|
92
|
+
isLoading,
|
|
93
|
+
}),
|
|
94
|
+
[connections, providerRegistry, envDefaults, scopeCompanyId, refresh, isLoading],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const breadcrumbs: BreadcrumbItemData[] = [
|
|
98
|
+
{
|
|
99
|
+
name: t("ai_connections.admin.title"),
|
|
100
|
+
href: generateUrl({ page: AI_CONNECTIONS_ADMIN_PAGE_URL }),
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<SharedProvider value={{ breadcrumbs, title: { type: t("ai_connections.admin.title") } }}>
|
|
106
|
+
<AiConnectionsContext.Provider value={value}>{children}</AiConnectionsContext.Provider>
|
|
107
|
+
</SharedProvider>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const useAiConnections = (): AiConnectionsContextType => useContext(AiConnectionsContext) ?? defaultContextValue;
|