plugin-ai-api 1.0.25 → 1.0.28
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/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
- package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
- package/dist/client/562.44b16aad4718b4c7.js +10 -0
- package/dist/client/685.ae483e17b6b49c98.js +10 -0
- package/dist/client/{757.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
- package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.3971233415999b2c.js +10 -0
- package/dist/client-v2/562.45d5c504433be38b.js +10 -0
- package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
- package/dist/client-v2/{757.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
- package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
- package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +2 -5
- package/dist/locale/en-US.json +26 -8
- package/dist/locale/vi-VN.json +26 -8
- package/dist/locale/zh-CN.json +26 -8
- package/dist/server/billing.js +25 -32
- package/dist/server/collections/ai-api-config.js +1 -7
- package/dist/server/collections/ai-api-group-members.js +62 -0
- package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
- package/dist/server/collections/ai-api-model-metadata.js +6 -0
- package/dist/server/collections/ai-api-usage-groups.js +74 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
- package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
- package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
- package/dist/server/plugin.js +90 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +0 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +32 -32
- package/dist/server/routes/completions.js +16 -19
- package/dist/server/routes/embeddings.js +2 -1
- package/dist/server/routes/models.js +2 -1
- package/dist/server/routes/router.js +3 -2
- package/dist/server/services/file-processor.js +186 -22
- package/dist/server/usage.js +5 -1
- package/dist/server/utils/direct-llm-context.js +13 -11
- package/dist/server/utils/rate-limiter.js +1 -1
- package/dist/server/utils/request-cache.js +61 -0
- package/dist/server/utils/resolve-service.js +2 -1
- package/dist/server/utils/user-permissions.js +25 -39
- package/dist/server/validation.js +7 -0
- package/dist/swagger.js +6 -7
- package/package.json +1 -1
- package/src/client/__tests__/settings-registration.test.tsx +6 -29
- package/src/client/plugin.tsx +5 -16
- package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
- package/src/client-v2/locale.ts +3 -1
- package/src/client-v2/pages/GeneralPage.tsx +0 -5
- package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
- package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +26 -8
- package/src/locale/vi-VN.json +26 -8
- package/src/locale/zh-CN.json +26 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +122 -4
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/usage-groups.test.ts +160 -0
- package/src/server/__tests__/usage-monitor.test.ts +2 -0
- package/src/server/__tests__/usage-route.test.ts +262 -2
- package/src/server/__tests__/usage.test.ts +38 -0
- package/src/server/__tests__/user-permissions.test.ts +214 -133
- package/src/server/__tests__/validation.test.ts +11 -0
- package/src/server/billing.ts +30 -38
- package/src/server/collections/ai-api-config.ts +1 -7
- package/src/server/collections/ai-api-group-members.ts +41 -0
- package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
- package/src/server/collections/ai-api-model-metadata.ts +7 -0
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/middleware/rate-limit.ts +10 -12
- package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
- package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
- package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
- package/src/server/plugin.ts +101 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +0 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +39 -36
- package/src/server/routes/completions.ts +18 -21
- package/src/server/routes/embeddings.ts +2 -1
- package/src/server/routes/models.ts +4 -3
- package/src/server/routes/router.ts +4 -3
- package/src/server/services/file-processor.ts +214 -24
- package/src/server/usage.ts +5 -1
- package/src/server/utils/direct-llm-context.ts +20 -11
- package/src/server/utils/rate-limiter.ts +1 -1
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +2 -1
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +7 -8
- package/dist/client/123.e6fe04c856ce6417.js +0 -10
- package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
- package/dist/client/562.17a0a299d2e5152c.js +0 -10
- package/dist/client/902.e74518750f1e4201.js +0 -10
- package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
- package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
- package/dist/client-v2/902.c7c00a565085438a.js +0 -10
- package/dist/server/resource/ai-api-user-permissions.js +0 -75
- package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
- package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
- package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
- package/src/server/resource/ai-api-user-permissions.ts +0 -76
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Button,
|
|
5
|
+
Card,
|
|
6
|
+
Form,
|
|
7
|
+
Input,
|
|
8
|
+
InputNumber,
|
|
9
|
+
Modal,
|
|
10
|
+
Popconfirm,
|
|
11
|
+
Select,
|
|
12
|
+
Space,
|
|
13
|
+
Switch,
|
|
14
|
+
Table,
|
|
15
|
+
Tag,
|
|
16
|
+
message,
|
|
17
|
+
Row,
|
|
18
|
+
Col,
|
|
19
|
+
} from 'antd';
|
|
20
|
+
import type { ColumnsType } from 'antd/es/table';
|
|
21
|
+
import { useFlowContext } from '@nocobase/flow-engine';
|
|
22
|
+
import { useT } from '../locale';
|
|
23
|
+
import { errorMessage, unwrapData } from './api';
|
|
24
|
+
|
|
25
|
+
interface UserSummary {
|
|
26
|
+
id: string | number;
|
|
27
|
+
nickname?: string;
|
|
28
|
+
username?: string;
|
|
29
|
+
email?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface EnabledLlmService {
|
|
33
|
+
llmService: string;
|
|
34
|
+
llmServiceTitle?: string;
|
|
35
|
+
enabledModels?: { label: string; value: string }[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface UsageGroup {
|
|
39
|
+
id: string | number;
|
|
40
|
+
name: string;
|
|
41
|
+
isDefault: boolean;
|
|
42
|
+
quotaMode: 'share' | 'per_user';
|
|
43
|
+
rateLimitPerMinute: number;
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
periodType: 'daily' | 'monthly';
|
|
46
|
+
timezone: string;
|
|
47
|
+
requestLimit?: string;
|
|
48
|
+
totalTokenLimit?: string;
|
|
49
|
+
costLimit?: string;
|
|
50
|
+
currency: string;
|
|
51
|
+
rejectUnpricedModel: boolean;
|
|
52
|
+
missingUsageBehavior: 'allow' | 'use_reserved';
|
|
53
|
+
contextOverflowBehavior: 'reject' | 'truncate';
|
|
54
|
+
allowedLlmServices: string[];
|
|
55
|
+
allowAllModels: boolean;
|
|
56
|
+
allowedModels: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface GroupMember {
|
|
60
|
+
id: string | number;
|
|
61
|
+
groupId: string | number;
|
|
62
|
+
userId: string | number;
|
|
63
|
+
user?: UserSummary;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default function UsageGroupsPage() {
|
|
67
|
+
const ctx = useFlowContext();
|
|
68
|
+
const t = useT();
|
|
69
|
+
const [form] = Form.useForm<UsageGroup>();
|
|
70
|
+
const [memberForm] = Form.useForm<{ userId: string | number }>();
|
|
71
|
+
const selectedServices = Form.useWatch('allowedLlmServices', form);
|
|
72
|
+
const allowAllModels = Form.useWatch('allowAllModels', form);
|
|
73
|
+
const [rows, setRows] = useState<UsageGroup[]>([]);
|
|
74
|
+
const [services, setServices] = useState<EnabledLlmService[]>([]);
|
|
75
|
+
const [loading, setLoading] = useState(false);
|
|
76
|
+
const [saving, setSaving] = useState(false);
|
|
77
|
+
const [editing, setEditing] = useState<UsageGroup>();
|
|
78
|
+
const [open, setOpen] = useState(false);
|
|
79
|
+
const [members, setMembers] = useState<GroupMember[]>([]);
|
|
80
|
+
const [membersLoading, setMembersLoading] = useState(false);
|
|
81
|
+
const [unassignedUsers, setUnassignedUsers] = useState<UserSummary[]>([]);
|
|
82
|
+
const [searchUserKeyword, setSearchUserKeyword] = useState('');
|
|
83
|
+
const [searchedGroup, setSearchedGroup] = useState<UsageGroup | null>(null);
|
|
84
|
+
|
|
85
|
+
const load = useCallback(async () => {
|
|
86
|
+
setLoading(true);
|
|
87
|
+
try {
|
|
88
|
+
const response = await ctx.api.request({
|
|
89
|
+
url: 'aiApiUsageGroups:list',
|
|
90
|
+
method: 'get',
|
|
91
|
+
params: { pageSize: 200, sort: '-updatedAt' },
|
|
92
|
+
});
|
|
93
|
+
setRows(unwrapData<UsageGroup[]>(response, []));
|
|
94
|
+
} catch (error) {
|
|
95
|
+
message.error(errorMessage(error));
|
|
96
|
+
} finally {
|
|
97
|
+
setLoading(false);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const servicesResponse = await ctx.api.request({ url: 'ai:listAllEnabledModels', method: 'get' });
|
|
101
|
+
setServices(unwrapData<EnabledLlmService[]>(servicesResponse, []));
|
|
102
|
+
} catch (error) {
|
|
103
|
+
setServices([]);
|
|
104
|
+
message.error(`${t('Failed to load models')}: ${errorMessage(error)}`);
|
|
105
|
+
}
|
|
106
|
+
}, [ctx.api, t]);
|
|
107
|
+
|
|
108
|
+
const loadMembers = useCallback(
|
|
109
|
+
async (groupId: string | number) => {
|
|
110
|
+
setMembersLoading(true);
|
|
111
|
+
try {
|
|
112
|
+
const response = await ctx.api.request({
|
|
113
|
+
url: 'aiApiGroupMembers:list',
|
|
114
|
+
method: 'get',
|
|
115
|
+
params: { filter: { groupId }, pageSize: 1000, appends: ['user'] },
|
|
116
|
+
});
|
|
117
|
+
setMembers(unwrapData<GroupMember[]>(response, []));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
message.error(errorMessage(error));
|
|
120
|
+
} finally {
|
|
121
|
+
setMembersLoading(false);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
[ctx.api],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const loadUnassignedUsers = useCallback(
|
|
128
|
+
async (keyword?: string) => {
|
|
129
|
+
try {
|
|
130
|
+
const response = await ctx.api.request({
|
|
131
|
+
url: 'aiApiUsageGroups:listUnassignedUsers',
|
|
132
|
+
method: 'get',
|
|
133
|
+
params: { keyword, pageSize: 100 },
|
|
134
|
+
});
|
|
135
|
+
const rows = unwrapData<UserSummary[]>(response, []);
|
|
136
|
+
setUnassignedUsers(rows);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
message.error(errorMessage(error));
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
[ctx.api],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
load();
|
|
146
|
+
}, [load]);
|
|
147
|
+
|
|
148
|
+
const showCreate = () => {
|
|
149
|
+
setEditing(undefined);
|
|
150
|
+
setMembers([]);
|
|
151
|
+
form.setFieldsValue({
|
|
152
|
+
name: '',
|
|
153
|
+
quotaMode: 'per_user',
|
|
154
|
+
rateLimitPerMinute: 60,
|
|
155
|
+
enabled: true,
|
|
156
|
+
periodType: 'monthly',
|
|
157
|
+
timezone: 'UTC',
|
|
158
|
+
currency: 'USD',
|
|
159
|
+
rejectUnpricedModel: true,
|
|
160
|
+
missingUsageBehavior: 'use_reserved',
|
|
161
|
+
contextOverflowBehavior: 'reject',
|
|
162
|
+
allowedLlmServices: [],
|
|
163
|
+
allowAllModels: true,
|
|
164
|
+
allowedModels: [],
|
|
165
|
+
} as UsageGroup);
|
|
166
|
+
setOpen(true);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const showEdit = async (record: UsageGroup) => {
|
|
170
|
+
setEditing(record);
|
|
171
|
+
form.setFieldsValue(record);
|
|
172
|
+
setOpen(true);
|
|
173
|
+
await loadMembers(record.id);
|
|
174
|
+
if (!record.isDefault) {
|
|
175
|
+
await loadUnassignedUsers();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const save = async () => {
|
|
180
|
+
const values = await form.validateFields();
|
|
181
|
+
setSaving(true);
|
|
182
|
+
try {
|
|
183
|
+
await ctx.api.request({
|
|
184
|
+
url: editing ? `aiApiUsageGroups:update/${editing.id}` : 'aiApiUsageGroups:create',
|
|
185
|
+
method: 'post',
|
|
186
|
+
data: values,
|
|
187
|
+
});
|
|
188
|
+
message.success(t('Saved successfully'));
|
|
189
|
+
setOpen(false);
|
|
190
|
+
await load();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
message.error(errorMessage(error));
|
|
193
|
+
} finally {
|
|
194
|
+
setSaving(false);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const remove = async (record: UsageGroup) => {
|
|
199
|
+
try {
|
|
200
|
+
await ctx.api.request({
|
|
201
|
+
url: `aiApiUsageGroups:destroy/${record.id}`,
|
|
202
|
+
method: 'post',
|
|
203
|
+
});
|
|
204
|
+
message.success(t('Deleted successfully'));
|
|
205
|
+
await load();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
message.error(errorMessage(error));
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const addMember = async () => {
|
|
212
|
+
if (!editing) return;
|
|
213
|
+
const values = await memberForm.validateFields();
|
|
214
|
+
try {
|
|
215
|
+
await ctx.api.request({
|
|
216
|
+
url: 'aiApiUsageGroups:addMember',
|
|
217
|
+
method: 'post',
|
|
218
|
+
data: { groupId: editing.id, userId: values.userId },
|
|
219
|
+
});
|
|
220
|
+
message.success(t('Member added'));
|
|
221
|
+
memberForm.resetFields();
|
|
222
|
+
await loadMembers(editing.id);
|
|
223
|
+
await loadUnassignedUsers();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
message.error(errorMessage(error));
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const removeMember = async (member: GroupMember) => {
|
|
230
|
+
try {
|
|
231
|
+
await ctx.api.request({
|
|
232
|
+
url: 'aiApiUsageGroups:removeMember',
|
|
233
|
+
method: 'post',
|
|
234
|
+
data: { groupId: member.groupId, userId: member.userId },
|
|
235
|
+
});
|
|
236
|
+
message.success(t('Member removed'));
|
|
237
|
+
if (editing) {
|
|
238
|
+
await loadMembers(editing.id);
|
|
239
|
+
await loadUnassignedUsers();
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
message.error(errorMessage(error));
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const searchGroupByUser = async () => {
|
|
247
|
+
if (!searchUserKeyword.trim()) return;
|
|
248
|
+
try {
|
|
249
|
+
const response = await ctx.api.request({
|
|
250
|
+
url: 'aiApiUsageGroups:searchUsers',
|
|
251
|
+
method: 'get',
|
|
252
|
+
params: { keyword: searchUserKeyword, pageSize: 1 },
|
|
253
|
+
});
|
|
254
|
+
const users = unwrapData<UserSummary[]>(response, []);
|
|
255
|
+
if (users.length === 0) {
|
|
256
|
+
message.warning(t('User not found'));
|
|
257
|
+
setSearchedGroup(null);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const groupResponse = await ctx.api.request({
|
|
261
|
+
url: 'aiApiUsageGroups:getByUser',
|
|
262
|
+
method: 'get',
|
|
263
|
+
params: { userId: users[0].id },
|
|
264
|
+
});
|
|
265
|
+
setSearchedGroup(unwrapData<UsageGroup>(groupResponse, null));
|
|
266
|
+
} catch (error) {
|
|
267
|
+
message.error(errorMessage(error));
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const userLabel = (user?: UserSummary) => user?.nickname || user?.username || user?.email || String(user?.id ?? '');
|
|
272
|
+
|
|
273
|
+
const serviceOptions = useMemo(
|
|
274
|
+
() =>
|
|
275
|
+
services.map((service) => ({ label: service.llmServiceTitle || service.llmService, value: service.llmService })),
|
|
276
|
+
[services],
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Model IDs are "serviceName/modelId", matching what the gateway enforces and what
|
|
280
|
+
// GET /v1/models returns, so admins never have to type them by hand.
|
|
281
|
+
const modelOptions = useMemo(() => {
|
|
282
|
+
const picked = new Set(selectedServices || []);
|
|
283
|
+
return services
|
|
284
|
+
.filter((service) => picked.has(service.llmService))
|
|
285
|
+
.flatMap((service) =>
|
|
286
|
+
(service.enabledModels || []).map((model) => ({
|
|
287
|
+
label: `${service.llmServiceTitle || service.llmService} / ${model.label || model.value}`,
|
|
288
|
+
value: `${service.llmService}/${model.value}`,
|
|
289
|
+
})),
|
|
290
|
+
);
|
|
291
|
+
}, [services, selectedServices]);
|
|
292
|
+
|
|
293
|
+
const serviceLabel = (name: string) => serviceOptions.find((option) => option.value === name)?.label || name;
|
|
294
|
+
|
|
295
|
+
const columns: ColumnsType<UsageGroup> = [
|
|
296
|
+
{ title: t('Name'), dataIndex: 'name', key: 'name', width: 180 },
|
|
297
|
+
{
|
|
298
|
+
title: t('Default'),
|
|
299
|
+
dataIndex: 'isDefault',
|
|
300
|
+
key: 'isDefault',
|
|
301
|
+
width: 100,
|
|
302
|
+
render: (isDefault: boolean) => (isDefault ? <Tag color="blue">{t('Default')}</Tag> : null),
|
|
303
|
+
},
|
|
304
|
+
{ title: t('Mode'), dataIndex: 'quotaMode', key: 'quotaMode', width: 120 },
|
|
305
|
+
{ title: t('Rate limit/min'), dataIndex: 'rateLimitPerMinute', key: 'rateLimitPerMinute', width: 140 },
|
|
306
|
+
{
|
|
307
|
+
title: t('Model access'),
|
|
308
|
+
key: 'modelAccess',
|
|
309
|
+
width: 240,
|
|
310
|
+
render: (_, record) => {
|
|
311
|
+
const groupServices = record.allowedLlmServices || [];
|
|
312
|
+
const groupModels = record.allowedModels || [];
|
|
313
|
+
const openServices = groupServices.length === 0;
|
|
314
|
+
const openModels = record.allowAllModels !== false;
|
|
315
|
+
if (openServices && openModels) {
|
|
316
|
+
return <Tag color="blue">{t('All models')}</Tag>;
|
|
317
|
+
}
|
|
318
|
+
return (
|
|
319
|
+
<Space size={[0, 4]} wrap>
|
|
320
|
+
<Tag>{openServices ? t('All services') : groupServices.map((name) => serviceLabel(name)).join(', ')}</Tag>
|
|
321
|
+
<Tag color={openModels ? 'blue' : undefined}>
|
|
322
|
+
{openModels ? t('All models') : groupModels.length ? groupModels.join(', ') : t('No models')}
|
|
323
|
+
</Tag>
|
|
324
|
+
</Space>
|
|
325
|
+
);
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
title: t('Status'),
|
|
330
|
+
dataIndex: 'enabled',
|
|
331
|
+
key: 'enabled',
|
|
332
|
+
width: 100,
|
|
333
|
+
render: (enabled: boolean) => (
|
|
334
|
+
<Tag color={enabled ? 'green' : 'default'}>{enabled ? t('Enabled') : t('Disabled')}</Tag>
|
|
335
|
+
),
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
title: t('Actions'),
|
|
339
|
+
key: 'actions',
|
|
340
|
+
width: 150,
|
|
341
|
+
fixed: 'right',
|
|
342
|
+
render: (_, record) => (
|
|
343
|
+
<Space size={0}>
|
|
344
|
+
<Button type="link" onClick={() => showEdit(record)}>
|
|
345
|
+
{t('Edit')}
|
|
346
|
+
</Button>
|
|
347
|
+
{!record.isDefault && (
|
|
348
|
+
<Popconfirm title={t('Delete this group?')} onConfirm={() => remove(record)}>
|
|
349
|
+
<Button type="link" danger>
|
|
350
|
+
{t('Delete')}
|
|
351
|
+
</Button>
|
|
352
|
+
</Popconfirm>
|
|
353
|
+
)}
|
|
354
|
+
</Space>
|
|
355
|
+
),
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
const memberColumns: ColumnsType<GroupMember> = [
|
|
360
|
+
{
|
|
361
|
+
title: t('User'),
|
|
362
|
+
key: 'user',
|
|
363
|
+
render: (_, record) => userLabel(record.user) || String(record.userId),
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
title: t('Actions'),
|
|
367
|
+
key: 'actions',
|
|
368
|
+
width: 120,
|
|
369
|
+
render: (_, record) => (
|
|
370
|
+
<Popconfirm title={t('Remove member?')} onConfirm={() => removeMember(record)}>
|
|
371
|
+
<Button type="link" danger>
|
|
372
|
+
{t('Remove')}
|
|
373
|
+
</Button>
|
|
374
|
+
</Popconfirm>
|
|
375
|
+
),
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<Card
|
|
381
|
+
title={t('Usage groups')}
|
|
382
|
+
extra={
|
|
383
|
+
<Button type="primary" onClick={showCreate}>
|
|
384
|
+
{t('Add group')}
|
|
385
|
+
</Button>
|
|
386
|
+
}
|
|
387
|
+
>
|
|
388
|
+
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
389
|
+
<Col span={12}>
|
|
390
|
+
<Input.Search
|
|
391
|
+
placeholder={t('Search group by user')}
|
|
392
|
+
value={searchUserKeyword}
|
|
393
|
+
onChange={(e) => setSearchUserKeyword(e.target.value)}
|
|
394
|
+
onSearch={searchGroupByUser}
|
|
395
|
+
enterButton
|
|
396
|
+
/>
|
|
397
|
+
</Col>
|
|
398
|
+
<Col span={12}>
|
|
399
|
+
{searchedGroup && (
|
|
400
|
+
<Tag color="blue">
|
|
401
|
+
{t('User belongs to')}: {searchedGroup.name}
|
|
402
|
+
</Tag>
|
|
403
|
+
)}
|
|
404
|
+
</Col>
|
|
405
|
+
</Row>
|
|
406
|
+
<Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 700 }} />
|
|
407
|
+
<Modal
|
|
408
|
+
title={editing ? t('Edit group') : t('Add group')}
|
|
409
|
+
open={open}
|
|
410
|
+
onCancel={() => setOpen(false)}
|
|
411
|
+
onOk={save}
|
|
412
|
+
confirmLoading={saving}
|
|
413
|
+
destroyOnClose
|
|
414
|
+
width={720}
|
|
415
|
+
>
|
|
416
|
+
<Form form={form} layout="vertical" preserve={false}>
|
|
417
|
+
<Form.Item name="name" label={t('Name')} rules={[{ required: true }]}>
|
|
418
|
+
<Input disabled={editing?.isDefault} />
|
|
419
|
+
</Form.Item>
|
|
420
|
+
<Form.Item name="quotaMode" label={t('Mode')} rules={[{ required: true }]}>
|
|
421
|
+
<Select
|
|
422
|
+
disabled={Boolean(editing)}
|
|
423
|
+
options={[
|
|
424
|
+
{ label: t('Share'), value: 'share' },
|
|
425
|
+
{ label: t('Per user'), value: 'per_user' },
|
|
426
|
+
]}
|
|
427
|
+
/>
|
|
428
|
+
</Form.Item>
|
|
429
|
+
<Form.Item name="rateLimitPerMinute" label={t('Rate limit per minute')} rules={[{ required: true }]}>
|
|
430
|
+
<InputNumber min={1} style={{ width: '100%' }} />
|
|
431
|
+
</Form.Item>
|
|
432
|
+
<Form.Item name="periodType" label={t('Period')} rules={[{ required: true }]}>
|
|
433
|
+
<Select
|
|
434
|
+
options={[
|
|
435
|
+
{ label: t('Daily'), value: 'daily' },
|
|
436
|
+
{ label: t('Monthly'), value: 'monthly' },
|
|
437
|
+
]}
|
|
438
|
+
/>
|
|
439
|
+
</Form.Item>
|
|
440
|
+
<Form.Item name="timezone" label={t('Timezone')} rules={[{ required: true }]}>
|
|
441
|
+
<Input placeholder="UTC" />
|
|
442
|
+
</Form.Item>
|
|
443
|
+
<Form.Item name="requestLimit" label={t('Request limit')}>
|
|
444
|
+
<InputNumber min={0} stringMode style={{ width: '100%' }} />
|
|
445
|
+
</Form.Item>
|
|
446
|
+
<Form.Item name="totalTokenLimit" label={t('Token limit')}>
|
|
447
|
+
<InputNumber min={0} stringMode style={{ width: '100%' }} />
|
|
448
|
+
</Form.Item>
|
|
449
|
+
<Form.Item name="costLimit" label={t('Cost limit')}>
|
|
450
|
+
<InputNumber min={0} stringMode style={{ width: '100%' }} />
|
|
451
|
+
</Form.Item>
|
|
452
|
+
<Form.Item name="currency" label={t('Currency')} rules={[{ required: true }]}>
|
|
453
|
+
<Input />
|
|
454
|
+
</Form.Item>
|
|
455
|
+
<Form.Item name="rejectUnpricedModel" label={t('Reject unpriced models')} valuePropName="checked">
|
|
456
|
+
<Switch />
|
|
457
|
+
</Form.Item>
|
|
458
|
+
<Form.Item name="missingUsageBehavior" label={t('Missing usage behavior')} rules={[{ required: true }]}>
|
|
459
|
+
<Select
|
|
460
|
+
options={[
|
|
461
|
+
{ label: t('Use reserved estimate'), value: 'use_reserved' },
|
|
462
|
+
{ label: t('Allow without token charge'), value: 'allow' },
|
|
463
|
+
]}
|
|
464
|
+
/>
|
|
465
|
+
</Form.Item>
|
|
466
|
+
<Form.Item name="contextOverflowBehavior" label={t('Context overflow behavior')} rules={[{ required: true }]}>
|
|
467
|
+
<Select
|
|
468
|
+
options={[
|
|
469
|
+
{ label: t('Reject request'), value: 'reject' },
|
|
470
|
+
{ label: t('Truncate oldest conversation turns'), value: 'truncate' },
|
|
471
|
+
]}
|
|
472
|
+
/>
|
|
473
|
+
</Form.Item>
|
|
474
|
+
<Form.Item
|
|
475
|
+
name="allowedLlmServices"
|
|
476
|
+
label={t('Allowed LLM services')}
|
|
477
|
+
extra={t('Leave empty to allow every service enabled in the general configuration.')}
|
|
478
|
+
>
|
|
479
|
+
<Select mode="multiple" allowClear showSearch optionFilterProp="label" options={serviceOptions} />
|
|
480
|
+
</Form.Item>
|
|
481
|
+
<Form.Item name="allowAllModels" label={t('Allow all models')} valuePropName="checked">
|
|
482
|
+
<Switch />
|
|
483
|
+
</Form.Item>
|
|
484
|
+
{allowAllModels === false && (
|
|
485
|
+
<Form.Item name="allowedModels" label={t('Allowed models')}>
|
|
486
|
+
<Select mode="multiple" allowClear showSearch optionFilterProp="label" options={modelOptions} />
|
|
487
|
+
</Form.Item>
|
|
488
|
+
)}
|
|
489
|
+
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
|
490
|
+
<Switch />
|
|
491
|
+
</Form.Item>
|
|
492
|
+
</Form>
|
|
493
|
+
{editing && (
|
|
494
|
+
<Card title={t('Members')} size="small" style={{ marginTop: 24 }}>
|
|
495
|
+
{editing.isDefault ? (
|
|
496
|
+
<>
|
|
497
|
+
<Alert
|
|
498
|
+
type="info"
|
|
499
|
+
showIcon
|
|
500
|
+
message={t(
|
|
501
|
+
'Users who do not belong to any other group automatically use this default group — no need to add members.',
|
|
502
|
+
)}
|
|
503
|
+
style={{ marginBottom: members.length > 0 ? 12 : 0 }}
|
|
504
|
+
/>
|
|
505
|
+
{members.length > 0 && (
|
|
506
|
+
<Table
|
|
507
|
+
rowKey="id"
|
|
508
|
+
columns={memberColumns}
|
|
509
|
+
dataSource={members}
|
|
510
|
+
loading={membersLoading}
|
|
511
|
+
pagination={false}
|
|
512
|
+
size="small"
|
|
513
|
+
/>
|
|
514
|
+
)}
|
|
515
|
+
</>
|
|
516
|
+
) : (
|
|
517
|
+
<>
|
|
518
|
+
<Form form={memberForm} layout="inline">
|
|
519
|
+
<Form.Item name="userId" label={t('User')} rules={[{ required: true }]} style={{ minWidth: 240 }}>
|
|
520
|
+
<Select
|
|
521
|
+
showSearch
|
|
522
|
+
optionFilterProp="label"
|
|
523
|
+
options={unassignedUsers.map((user) => ({ label: userLabel(user), value: user.id }))}
|
|
524
|
+
onFocus={() => loadUnassignedUsers()}
|
|
525
|
+
/>
|
|
526
|
+
</Form.Item>
|
|
527
|
+
<Form.Item>
|
|
528
|
+
<Button type="primary" onClick={addMember}>
|
|
529
|
+
{t('Add member')}
|
|
530
|
+
</Button>
|
|
531
|
+
</Form.Item>
|
|
532
|
+
</Form>
|
|
533
|
+
<Table
|
|
534
|
+
rowKey="id"
|
|
535
|
+
columns={memberColumns}
|
|
536
|
+
dataSource={members}
|
|
537
|
+
loading={membersLoading}
|
|
538
|
+
pagination={false}
|
|
539
|
+
size="small"
|
|
540
|
+
/>
|
|
541
|
+
</>
|
|
542
|
+
)}
|
|
543
|
+
</Card>
|
|
544
|
+
)}
|
|
545
|
+
</Modal>
|
|
546
|
+
</Card>
|
|
547
|
+
);
|
|
548
|
+
}
|
package/src/client-v2/plugin.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Plugin, Application } from '@nocobase/client-v2';
|
|
3
|
-
import { AI_API_ACL_SNIPPET
|
|
3
|
+
import { AI_API_ACL_SNIPPET } from '../constants';
|
|
4
4
|
|
|
5
5
|
interface PermissionTabOptions {
|
|
6
6
|
key: string;
|
|
@@ -53,20 +53,11 @@ export class PluginAiApiClient extends Plugin<Record<string, never>, Application
|
|
|
53
53
|
|
|
54
54
|
this.pluginSettingsManager.addPageTabItem({
|
|
55
55
|
menuKey: 'ai-api',
|
|
56
|
-
key: '
|
|
57
|
-
title: this.t('
|
|
58
|
-
aclSnippet: AI_API_USER_PERMISSIONS_SNIPPET,
|
|
59
|
-
sort: 4,
|
|
60
|
-
componentLoader: () => import('./pages/UserPermissionsPage'),
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
this.pluginSettingsManager.addPageTabItem({
|
|
64
|
-
menuKey: 'ai-api',
|
|
65
|
-
key: 'user-quotas',
|
|
66
|
-
title: this.t('User quotas'),
|
|
56
|
+
key: 'usage-groups',
|
|
57
|
+
title: this.t('Usage groups'),
|
|
67
58
|
aclSnippet: AI_API_ACL_SNIPPET,
|
|
68
59
|
sort: 5,
|
|
69
|
-
componentLoader: () => import('./pages/
|
|
60
|
+
componentLoader: () => import('./pages/UsageGroupsPage'),
|
|
70
61
|
});
|
|
71
62
|
|
|
72
63
|
this.pluginSettingsManager.addPageTabItem({
|
package/src/constants.ts
CHANGED
|
@@ -19,10 +19,3 @@
|
|
|
19
19
|
* Shared by both client runtimes so a rename cannot silently desynchronise them.
|
|
20
20
|
*/
|
|
21
21
|
export const AI_API_ACL_SNIPPET = 'pm.plugin-ai-api.configuration';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Child snippet for the per-user LLM permission surface, deliberately separate from
|
|
25
|
-
* AI_API_ACL_SNIPPET so granting someone the gateway settings does not also let them
|
|
26
|
-
* hand out model access. Same `plugin-ai-api` prefix constraint as above applies.
|
|
27
|
-
*/
|
|
28
|
-
export const AI_API_USER_PERMISSIONS_SNIPPET = 'pm.plugin-ai-api.user-permissions';
|
package/src/locale/en-US.json
CHANGED
|
@@ -99,6 +99,8 @@
|
|
|
99
99
|
"Leave empty to not override": "Leave empty to not override",
|
|
100
100
|
"Total input + output token capacity reported to clients.": "Total input + output token capacity reported to clients.",
|
|
101
101
|
"Maximum output tokens reported to clients.": "Maximum output tokens reported to clients.",
|
|
102
|
+
"Initial system prompt": "Initial system prompt",
|
|
103
|
+
"Prepended as the first system message, before any system prompt sent by the client. If the client sends no system prompt, this becomes the system prompt sent to the provider.": "Prepended as the first system message, before any system prompt sent by the client. If the client sends no system prompt, this becomes the system prompt sent to the provider.",
|
|
102
104
|
"AI API": "AI API",
|
|
103
105
|
"Allow this role to use the AI API": "Allow this role to use the AI API",
|
|
104
106
|
"Allow all AI Employees": "Allow all AI Employees",
|
|
@@ -106,15 +108,31 @@
|
|
|
106
108
|
"Select allowed AI Employees": "Select allowed AI Employees",
|
|
107
109
|
"Max request body size (MB)": "Max request body size (MB)",
|
|
108
110
|
"Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.",
|
|
109
|
-
"
|
|
110
|
-
"Add
|
|
111
|
-
"Edit
|
|
112
|
-
"Delete this
|
|
111
|
+
"Usage groups": "Usage groups",
|
|
112
|
+
"Add group": "Add group",
|
|
113
|
+
"Edit group": "Edit group",
|
|
114
|
+
"Delete this group?": "Delete this group?",
|
|
115
|
+
"Mode": "Mode",
|
|
116
|
+
"Share": "Share",
|
|
117
|
+
"Per user": "Per user",
|
|
118
|
+
"Rate limit per minute": "Rate limit per minute",
|
|
119
|
+
"Members": "Members",
|
|
120
|
+
"Add member": "Add member",
|
|
121
|
+
"Member added": "Member added",
|
|
122
|
+
"Member removed": "Member removed",
|
|
123
|
+
"Remove member?": "Remove member?",
|
|
124
|
+
"Remove": "Remove",
|
|
125
|
+
"Search group by user": "Search group by user",
|
|
126
|
+
"User belongs to": "User belongs to",
|
|
127
|
+
"User not found": "User not found",
|
|
128
|
+
"Default": "Default",
|
|
113
129
|
"Allowed LLM services": "Allowed LLM services",
|
|
114
130
|
"Allow all models": "Allow all models",
|
|
115
131
|
"Allowed models": "Allowed models",
|
|
116
|
-
"
|
|
117
|
-
"All models
|
|
118
|
-
"
|
|
119
|
-
"
|
|
132
|
+
"Model access": "Model access",
|
|
133
|
+
"All models": "All models",
|
|
134
|
+
"All services": "All services",
|
|
135
|
+
"No models": "No models",
|
|
136
|
+
"Leave empty to allow every service enabled in the general configuration.": "Leave empty to allow every service enabled in the general configuration.",
|
|
137
|
+
"Users who do not belong to any other group automatically use this default group — no need to add members.": "Users who do not belong to any other group automatically use this default group — no need to add members."
|
|
120
138
|
}
|