plugin-ai-api 1.0.24 → 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.6568d3504ad29352.js +10 -0
- 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.f2bc9cfba07004b0.js +10 -0
- 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/externalVersion.js +8 -8
- package/dist/locale/en-US.json +27 -8
- package/dist/locale/vi-VN.json +27 -8
- package/dist/locale/zh-CN.json +27 -8
- package/dist/server/billing.js +31 -33
- package/dist/server/collections/ai-api-config.js +7 -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 +2 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- 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 +100 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +5 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +121 -42
- package/dist/server/routes/completions.js +48 -29
- 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 +426 -0
- package/dist/server/usage.js +37 -3
- package/dist/server/utils/direct-llm-context.js +163 -26
- package/dist/server/utils/openai-format.js +21 -2
- 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 +48 -10
- 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/pages/UsagePage.tsx +9 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +27 -8
- package/src/locale/vi-VN.json +27 -8
- package/src/locale/zh-CN.json +27 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +209 -10
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/request-body.test.ts +45 -2
- 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 +382 -5
- package/src/server/__tests__/usage.test.ts +57 -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 +36 -39
- package/src/server/collections/ai-api-config.ts +9 -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-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +2 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +68 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- 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 +121 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +5 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +173 -47
- package/src/server/routes/completions.ts +50 -27
- 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/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +513 -0
- package/src/server/usage.ts +51 -1
- package/src/server/utils/direct-llm-context.ts +218 -31
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +83 -82
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +52 -11
- 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/757.a01403fb7a1bea01.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/757.a117ce1cf7119cea.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
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import { Alert, Button, Card, Form, Modal, Popconfirm, Select, Space, Switch, Table, Tag, message } from 'antd';
|
|
3
|
-
import type { ColumnsType } from 'antd/es/table';
|
|
4
|
-
import { useFlowContext } from '@nocobase/flow-engine';
|
|
5
|
-
import { useT } from '../locale';
|
|
6
|
-
import { errorMessage, unwrapData } from './api';
|
|
7
|
-
import type { ApiEnvelope } from './api';
|
|
8
|
-
|
|
9
|
-
interface UserSummary {
|
|
10
|
-
id: string | number;
|
|
11
|
-
nickname?: string;
|
|
12
|
-
username?: string;
|
|
13
|
-
email?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface EnabledLlmService {
|
|
17
|
-
llmService: string;
|
|
18
|
-
llmServiceTitle?: string;
|
|
19
|
-
enabledModels?: { label: string; value: string }[];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface UserPermission {
|
|
23
|
-
id: string | number;
|
|
24
|
-
userId: string | number;
|
|
25
|
-
user?: UserSummary;
|
|
26
|
-
enabled: boolean;
|
|
27
|
-
allowedLlmServices: string[];
|
|
28
|
-
allowAllModels: boolean;
|
|
29
|
-
allowedModels: string[];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** The modal edits everything except the server-assigned id. */
|
|
33
|
-
type UserPermissionFormValues = Omit<UserPermission, 'id' | 'user'>;
|
|
34
|
-
|
|
35
|
-
const PAGE_SIZE = 20;
|
|
36
|
-
|
|
37
|
-
export default function UserPermissionsPage() {
|
|
38
|
-
const ctx = useFlowContext();
|
|
39
|
-
const t = useT();
|
|
40
|
-
const [form] = Form.useForm<UserPermissionFormValues>();
|
|
41
|
-
const selectedServices = Form.useWatch('allowedLlmServices', form);
|
|
42
|
-
const allowAllModels = Form.useWatch('allowAllModels', form);
|
|
43
|
-
const [rows, setRows] = useState<UserPermission[]>([]);
|
|
44
|
-
const [total, setTotal] = useState(0);
|
|
45
|
-
const [page, setPage] = useState(1);
|
|
46
|
-
const [users, setUsers] = useState<UserSummary[]>([]);
|
|
47
|
-
const [userSearch, setUserSearch] = useState('');
|
|
48
|
-
const [services, setServices] = useState<EnabledLlmService[]>([]);
|
|
49
|
-
const [loading, setLoading] = useState(false);
|
|
50
|
-
const [saving, setSaving] = useState(false);
|
|
51
|
-
const [editing, setEditing] = useState<UserPermission>();
|
|
52
|
-
const [open, setOpen] = useState(false);
|
|
53
|
-
|
|
54
|
-
const load = useCallback(async () => {
|
|
55
|
-
setLoading(true);
|
|
56
|
-
try {
|
|
57
|
-
const [permissionsResponse, servicesResponse] = await Promise.all([
|
|
58
|
-
ctx.api.request({
|
|
59
|
-
url: 'aiApiUserPermissions:list',
|
|
60
|
-
method: 'get',
|
|
61
|
-
params: { page, pageSize: PAGE_SIZE, appends: ['user'], sort: '-updatedAt' },
|
|
62
|
-
}),
|
|
63
|
-
ctx.api.request({ url: 'ai:listAllEnabledModels', method: 'get' }),
|
|
64
|
-
]);
|
|
65
|
-
setRows(unwrapData<UserPermission[]>(permissionsResponse, []));
|
|
66
|
-
setTotal((permissionsResponse as ApiEnvelope<UserPermission[]>)?.data?.meta?.count ?? 0);
|
|
67
|
-
setServices(unwrapData<EnabledLlmService[]>(servicesResponse, []));
|
|
68
|
-
} catch (error) {
|
|
69
|
-
message.error(errorMessage(error));
|
|
70
|
-
} finally {
|
|
71
|
-
setLoading(false);
|
|
72
|
-
}
|
|
73
|
-
}, [ctx.api, page]);
|
|
74
|
-
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
load();
|
|
77
|
-
}, [load]);
|
|
78
|
-
|
|
79
|
-
// Served by the plugin's own action rather than `users:list`, which belongs to the
|
|
80
|
-
// pm.plugin-users snippet and would make this page unusable for a role granted only
|
|
81
|
-
// pm.plugin-ai-api.user-permissions.
|
|
82
|
-
const loadUsers = useCallback(
|
|
83
|
-
async (keyword: string) => {
|
|
84
|
-
try {
|
|
85
|
-
const response = await ctx.api.request({
|
|
86
|
-
url: 'aiApiUserPermissions:listUsers',
|
|
87
|
-
method: 'get',
|
|
88
|
-
params: { keyword, pageSize: 50, excludeGranted: !editing },
|
|
89
|
-
});
|
|
90
|
-
setUsers(unwrapData<UserSummary[]>(response, []));
|
|
91
|
-
} catch (error) {
|
|
92
|
-
message.error(errorMessage(error));
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
[ctx.api, editing],
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
useEffect(() => {
|
|
99
|
-
if (!open) return;
|
|
100
|
-
const timer = setTimeout(() => loadUsers(userSearch), 300);
|
|
101
|
-
return () => clearTimeout(timer);
|
|
102
|
-
}, [open, userSearch, loadUsers]);
|
|
103
|
-
|
|
104
|
-
const serviceOptions = useMemo(
|
|
105
|
-
() =>
|
|
106
|
-
services.map((service) => ({ label: service.llmServiceTitle || service.llmService, value: service.llmService })),
|
|
107
|
-
[services],
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
// Model IDs are "serviceName/modelId", matching what the gateway enforces and what
|
|
111
|
-
// GET /v1/models returns, so admins never have to type them by hand.
|
|
112
|
-
const modelOptions = useMemo(() => {
|
|
113
|
-
const picked = new Set(selectedServices || []);
|
|
114
|
-
return services
|
|
115
|
-
.filter((service) => picked.has(service.llmService))
|
|
116
|
-
.flatMap((service) =>
|
|
117
|
-
(service.enabledModels || []).map((model) => ({
|
|
118
|
-
label: `${service.llmServiceTitle || service.llmService} / ${model.label || model.value}`,
|
|
119
|
-
value: `${service.llmService}/${model.value}`,
|
|
120
|
-
})),
|
|
121
|
-
);
|
|
122
|
-
}, [services, selectedServices]);
|
|
123
|
-
|
|
124
|
-
const showCreate = () => {
|
|
125
|
-
setEditing(undefined);
|
|
126
|
-
setUserSearch('');
|
|
127
|
-
form.setFieldsValue({
|
|
128
|
-
enabled: true,
|
|
129
|
-
allowedLlmServices: [],
|
|
130
|
-
allowAllModels: true,
|
|
131
|
-
allowedModels: [],
|
|
132
|
-
});
|
|
133
|
-
setOpen(true);
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const showEdit = (record: UserPermission) => {
|
|
137
|
-
setEditing(record);
|
|
138
|
-
setUserSearch('');
|
|
139
|
-
// Seed the picker with the granted user so the label renders before any search runs.
|
|
140
|
-
setUsers(record.user ? [record.user] : []);
|
|
141
|
-
form.setFieldsValue({
|
|
142
|
-
userId: record.userId,
|
|
143
|
-
enabled: record.enabled,
|
|
144
|
-
allowAllModels: record.allowAllModels,
|
|
145
|
-
allowedLlmServices: record.allowedLlmServices || [],
|
|
146
|
-
allowedModels: record.allowedModels || [],
|
|
147
|
-
});
|
|
148
|
-
setOpen(true);
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const save = async () => {
|
|
152
|
-
const values = await form.validateFields();
|
|
153
|
-
setSaving(true);
|
|
154
|
-
try {
|
|
155
|
-
await ctx.api.request({
|
|
156
|
-
url: editing ? `aiApiUserPermissions:update/${editing.id}` : 'aiApiUserPermissions:create',
|
|
157
|
-
method: 'post',
|
|
158
|
-
data: values,
|
|
159
|
-
});
|
|
160
|
-
message.success(t('Saved successfully'));
|
|
161
|
-
setOpen(false);
|
|
162
|
-
await load();
|
|
163
|
-
} catch (error) {
|
|
164
|
-
message.error(errorMessage(error));
|
|
165
|
-
} finally {
|
|
166
|
-
setSaving(false);
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const remove = async (record: UserPermission) => {
|
|
171
|
-
try {
|
|
172
|
-
await ctx.api.request({ url: `aiApiUserPermissions:destroy/${record.id}`, method: 'post' });
|
|
173
|
-
message.success(t('Deleted successfully'));
|
|
174
|
-
await load();
|
|
175
|
-
} catch (error) {
|
|
176
|
-
message.error(errorMessage(error));
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const userLabel = (user?: UserSummary) => user?.nickname || user?.username || user?.email || String(user?.id ?? '');
|
|
181
|
-
const serviceLabel = (name: string) => serviceOptions.find((option) => option.value === name)?.label || name;
|
|
182
|
-
|
|
183
|
-
const columns: ColumnsType<UserPermission> = [
|
|
184
|
-
{
|
|
185
|
-
title: t('User'),
|
|
186
|
-
key: 'user',
|
|
187
|
-
width: 180,
|
|
188
|
-
render: (_, record) => userLabel(record.user) || String(record.userId),
|
|
189
|
-
},
|
|
190
|
-
{
|
|
191
|
-
title: t('Allowed LLM services'),
|
|
192
|
-
dataIndex: 'allowedLlmServices',
|
|
193
|
-
key: 'allowedLlmServices',
|
|
194
|
-
render: (values: string[]) =>
|
|
195
|
-
values?.length ? (
|
|
196
|
-
<Space size={[0, 4]} wrap>
|
|
197
|
-
{values.map((value) => (
|
|
198
|
-
<Tag key={value}>{serviceLabel(value)}</Tag>
|
|
199
|
-
))}
|
|
200
|
-
</Space>
|
|
201
|
-
) : (
|
|
202
|
-
<Tag color="red">{t('No service allowed')}</Tag>
|
|
203
|
-
),
|
|
204
|
-
},
|
|
205
|
-
{
|
|
206
|
-
title: t('Allowed models'),
|
|
207
|
-
key: 'allowedModels',
|
|
208
|
-
width: 220,
|
|
209
|
-
render: (_, record) =>
|
|
210
|
-
record.allowAllModels ? (
|
|
211
|
-
<Tag color="blue">{t('All models of allowed services')}</Tag>
|
|
212
|
-
) : (
|
|
213
|
-
<Space size={[0, 4]} wrap>
|
|
214
|
-
{(record.allowedModels || []).map((value) => (
|
|
215
|
-
<Tag key={value}>{value}</Tag>
|
|
216
|
-
))}
|
|
217
|
-
</Space>
|
|
218
|
-
),
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
title: t('Status'),
|
|
222
|
-
dataIndex: 'enabled',
|
|
223
|
-
key: 'enabled',
|
|
224
|
-
width: 100,
|
|
225
|
-
render: (enabled: boolean) => (
|
|
226
|
-
<Tag color={enabled ? 'green' : 'default'}>{enabled ? t('Enabled') : t('Disabled')}</Tag>
|
|
227
|
-
),
|
|
228
|
-
},
|
|
229
|
-
{
|
|
230
|
-
title: t('Actions'),
|
|
231
|
-
key: 'actions',
|
|
232
|
-
width: 150,
|
|
233
|
-
fixed: 'right',
|
|
234
|
-
render: (_, record) => (
|
|
235
|
-
<Space size={0}>
|
|
236
|
-
<Button type="link" onClick={() => showEdit(record)}>
|
|
237
|
-
{t('Edit')}
|
|
238
|
-
</Button>
|
|
239
|
-
<Popconfirm title={t('Delete this permission?')} onConfirm={() => remove(record)}>
|
|
240
|
-
<Button type="link" danger>
|
|
241
|
-
{t('Delete')}
|
|
242
|
-
</Button>
|
|
243
|
-
</Popconfirm>
|
|
244
|
-
</Space>
|
|
245
|
-
),
|
|
246
|
-
},
|
|
247
|
-
];
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<Card
|
|
251
|
-
title={t('User LLM permissions')}
|
|
252
|
-
extra={
|
|
253
|
-
<Button type="primary" onClick={showCreate}>
|
|
254
|
-
{t('Add permission')}
|
|
255
|
-
</Button>
|
|
256
|
-
}
|
|
257
|
-
>
|
|
258
|
-
<Alert
|
|
259
|
-
type="info"
|
|
260
|
-
showIcon
|
|
261
|
-
style={{ marginBottom: 16 }}
|
|
262
|
-
message={t(
|
|
263
|
-
'Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.',
|
|
264
|
-
)}
|
|
265
|
-
/>
|
|
266
|
-
<Table
|
|
267
|
-
rowKey="id"
|
|
268
|
-
columns={columns}
|
|
269
|
-
dataSource={rows}
|
|
270
|
-
loading={loading}
|
|
271
|
-
scroll={{ x: 1000 }}
|
|
272
|
-
pagination={{
|
|
273
|
-
current: page,
|
|
274
|
-
pageSize: PAGE_SIZE,
|
|
275
|
-
total,
|
|
276
|
-
showSizeChanger: false,
|
|
277
|
-
onChange: setPage,
|
|
278
|
-
}}
|
|
279
|
-
/>
|
|
280
|
-
<Modal
|
|
281
|
-
title={editing ? t('Edit permission') : t('Add permission')}
|
|
282
|
-
open={open}
|
|
283
|
-
onCancel={() => setOpen(false)}
|
|
284
|
-
onOk={save}
|
|
285
|
-
confirmLoading={saving}
|
|
286
|
-
destroyOnClose
|
|
287
|
-
>
|
|
288
|
-
<Form form={form} layout="vertical" preserve={false}>
|
|
289
|
-
<Form.Item name="userId" label={t('User')} rules={[{ required: true }]}>
|
|
290
|
-
<Select
|
|
291
|
-
disabled={Boolean(editing)}
|
|
292
|
-
showSearch
|
|
293
|
-
// Matching happens on the server, so keep every returned option visible.
|
|
294
|
-
filterOption={false}
|
|
295
|
-
onSearch={setUserSearch}
|
|
296
|
-
notFoundContent={null}
|
|
297
|
-
options={users.map((user) => ({ label: userLabel(user), value: user.id }))}
|
|
298
|
-
/>
|
|
299
|
-
</Form.Item>
|
|
300
|
-
<Form.Item
|
|
301
|
-
name="allowedLlmServices"
|
|
302
|
-
label={t('Allowed LLM services')}
|
|
303
|
-
extra={t('Only services also enabled in the general configuration take effect.')}
|
|
304
|
-
>
|
|
305
|
-
<Select mode="multiple" allowClear showSearch optionFilterProp="label" options={serviceOptions} />
|
|
306
|
-
</Form.Item>
|
|
307
|
-
<Form.Item name="allowAllModels" label={t('Allow all models')} valuePropName="checked">
|
|
308
|
-
<Switch />
|
|
309
|
-
</Form.Item>
|
|
310
|
-
{allowAllModels === false && (
|
|
311
|
-
<Form.Item name="allowedModels" label={t('Allowed models')}>
|
|
312
|
-
<Select mode="multiple" allowClear showSearch optionFilterProp="label" options={modelOptions} />
|
|
313
|
-
</Form.Item>
|
|
314
|
-
)}
|
|
315
|
-
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
|
316
|
-
<Switch />
|
|
317
|
-
</Form.Item>
|
|
318
|
-
</Form>
|
|
319
|
-
</Modal>
|
|
320
|
-
</Card>
|
|
321
|
-
);
|
|
322
|
-
}
|
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
Button,
|
|
4
|
-
Card,
|
|
5
|
-
Form,
|
|
6
|
-
Input,
|
|
7
|
-
InputNumber,
|
|
8
|
-
Modal,
|
|
9
|
-
Popconfirm,
|
|
10
|
-
Select,
|
|
11
|
-
Space,
|
|
12
|
-
Switch,
|
|
13
|
-
Table,
|
|
14
|
-
Tag,
|
|
15
|
-
message,
|
|
16
|
-
} from 'antd';
|
|
17
|
-
import type { ColumnsType } from 'antd/es/table';
|
|
18
|
-
import { useFlowContext } from '@nocobase/flow-engine';
|
|
19
|
-
import { useT } from '../locale';
|
|
20
|
-
import { errorMessage, unwrapData } from './api';
|
|
21
|
-
|
|
22
|
-
interface UserSummary {
|
|
23
|
-
id: string | number;
|
|
24
|
-
nickname?: string;
|
|
25
|
-
username?: string;
|
|
26
|
-
email?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface QuotaPolicy {
|
|
30
|
-
id: string | number;
|
|
31
|
-
userId: string | number;
|
|
32
|
-
user?: UserSummary;
|
|
33
|
-
enabled: boolean;
|
|
34
|
-
periodType: 'daily' | 'monthly';
|
|
35
|
-
timezone: string;
|
|
36
|
-
requestLimit?: string;
|
|
37
|
-
totalTokenLimit?: string;
|
|
38
|
-
costLimit?: string;
|
|
39
|
-
currency: string;
|
|
40
|
-
rejectUnpricedModel: boolean;
|
|
41
|
-
missingUsageBehavior: 'allow' | 'use_reserved';
|
|
42
|
-
contextOverflowBehavior: 'reject' | 'truncate';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export default function UserQuotasPage() {
|
|
46
|
-
const ctx = useFlowContext();
|
|
47
|
-
const t = useT();
|
|
48
|
-
const [form] = Form.useForm<QuotaPolicy>();
|
|
49
|
-
const [rows, setRows] = useState<QuotaPolicy[]>([]);
|
|
50
|
-
const [users, setUsers] = useState<UserSummary[]>([]);
|
|
51
|
-
const [loading, setLoading] = useState(false);
|
|
52
|
-
const [saving, setSaving] = useState(false);
|
|
53
|
-
const [editing, setEditing] = useState<QuotaPolicy>();
|
|
54
|
-
const [open, setOpen] = useState(false);
|
|
55
|
-
|
|
56
|
-
const load = useCallback(async () => {
|
|
57
|
-
setLoading(true);
|
|
58
|
-
try {
|
|
59
|
-
const [policiesResponse, usersResponse] = await Promise.all([
|
|
60
|
-
ctx.api.request({
|
|
61
|
-
url: 'aiApiUserQuotaPolicies:list',
|
|
62
|
-
method: 'get',
|
|
63
|
-
params: { pageSize: 200, appends: ['user'], sort: '-updatedAt' },
|
|
64
|
-
}),
|
|
65
|
-
ctx.api.request({ url: 'users:list', method: 'get', params: { pageSize: 200 } }),
|
|
66
|
-
]);
|
|
67
|
-
setRows(unwrapData<QuotaPolicy[]>(policiesResponse, []));
|
|
68
|
-
setUsers(unwrapData<UserSummary[]>(usersResponse, []));
|
|
69
|
-
} catch (error) {
|
|
70
|
-
message.error(errorMessage(error));
|
|
71
|
-
} finally {
|
|
72
|
-
setLoading(false);
|
|
73
|
-
}
|
|
74
|
-
}, [ctx.api]);
|
|
75
|
-
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
load();
|
|
78
|
-
}, [load]);
|
|
79
|
-
|
|
80
|
-
const showCreate = () => {
|
|
81
|
-
setEditing(undefined);
|
|
82
|
-
form.setFieldsValue({
|
|
83
|
-
enabled: true,
|
|
84
|
-
periodType: 'monthly',
|
|
85
|
-
timezone: 'UTC',
|
|
86
|
-
currency: 'USD',
|
|
87
|
-
rejectUnpricedModel: true,
|
|
88
|
-
missingUsageBehavior: 'use_reserved',
|
|
89
|
-
contextOverflowBehavior: 'reject',
|
|
90
|
-
} as QuotaPolicy);
|
|
91
|
-
setOpen(true);
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const showEdit = (record: QuotaPolicy) => {
|
|
95
|
-
setEditing(record);
|
|
96
|
-
form.setFieldsValue(record);
|
|
97
|
-
setOpen(true);
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const save = async () => {
|
|
101
|
-
const values = await form.validateFields();
|
|
102
|
-
setSaving(true);
|
|
103
|
-
try {
|
|
104
|
-
await ctx.api.request({
|
|
105
|
-
url: editing ? `aiApiUserQuotaPolicies:update/${editing.id}` : 'aiApiUserQuotaPolicies:create',
|
|
106
|
-
method: 'post',
|
|
107
|
-
data: values,
|
|
108
|
-
});
|
|
109
|
-
message.success(t('Saved successfully'));
|
|
110
|
-
setOpen(false);
|
|
111
|
-
await load();
|
|
112
|
-
} catch (error) {
|
|
113
|
-
message.error(errorMessage(error));
|
|
114
|
-
} finally {
|
|
115
|
-
setSaving(false);
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const remove = async (record: QuotaPolicy) => {
|
|
120
|
-
try {
|
|
121
|
-
await ctx.api.request({
|
|
122
|
-
url: `aiApiUserQuotaPolicies:destroy/${record.id}`,
|
|
123
|
-
method: 'post',
|
|
124
|
-
});
|
|
125
|
-
message.success(t('Deleted successfully'));
|
|
126
|
-
await load();
|
|
127
|
-
} catch (error) {
|
|
128
|
-
message.error(errorMessage(error));
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const userLabel = (user?: UserSummary) => user?.nickname || user?.username || user?.email || String(user?.id ?? '');
|
|
133
|
-
const columns: ColumnsType<QuotaPolicy> = [
|
|
134
|
-
{
|
|
135
|
-
title: t('User'),
|
|
136
|
-
key: 'user',
|
|
137
|
-
width: 180,
|
|
138
|
-
render: (_, record) => userLabel(record.user) || String(record.userId),
|
|
139
|
-
},
|
|
140
|
-
{ title: t('Period'), dataIndex: 'periodType', key: 'periodType', width: 100 },
|
|
141
|
-
{
|
|
142
|
-
title: t('Request limit'),
|
|
143
|
-
dataIndex: 'requestLimit',
|
|
144
|
-
key: 'requestLimit',
|
|
145
|
-
width: 130,
|
|
146
|
-
render: (value) => value ?? t('Unlimited'),
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
title: t('Token limit'),
|
|
150
|
-
dataIndex: 'totalTokenLimit',
|
|
151
|
-
key: 'totalTokenLimit',
|
|
152
|
-
width: 130,
|
|
153
|
-
render: (value) => value ?? t('Unlimited'),
|
|
154
|
-
},
|
|
155
|
-
{
|
|
156
|
-
title: t('Cost limit'),
|
|
157
|
-
dataIndex: 'costLimit',
|
|
158
|
-
key: 'costLimit',
|
|
159
|
-
width: 120,
|
|
160
|
-
render: (value, record) => (value == null ? t('Unlimited') : `${value} ${record.currency}`),
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
title: t('Context overflow behavior'),
|
|
164
|
-
dataIndex: 'contextOverflowBehavior',
|
|
165
|
-
key: 'contextOverflowBehavior',
|
|
166
|
-
width: 150,
|
|
167
|
-
render: (value: QuotaPolicy['contextOverflowBehavior']) =>
|
|
168
|
-
value === 'truncate' ? t('Truncate oldest conversation turns') : t('Reject request'),
|
|
169
|
-
},
|
|
170
|
-
{ title: t('Timezone'), dataIndex: 'timezone', key: 'timezone', width: 140 },
|
|
171
|
-
{
|
|
172
|
-
title: t('Status'),
|
|
173
|
-
dataIndex: 'enabled',
|
|
174
|
-
key: 'enabled',
|
|
175
|
-
width: 100,
|
|
176
|
-
render: (enabled: boolean) => (
|
|
177
|
-
<Tag color={enabled ? 'green' : 'default'}>{enabled ? t('Enabled') : t('Disabled')}</Tag>
|
|
178
|
-
),
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
title: t('Actions'),
|
|
182
|
-
key: 'actions',
|
|
183
|
-
width: 150,
|
|
184
|
-
fixed: 'right',
|
|
185
|
-
render: (_, record) => (
|
|
186
|
-
<Space size={0}>
|
|
187
|
-
<Button type="link" onClick={() => showEdit(record)}>
|
|
188
|
-
{t('Edit')}
|
|
189
|
-
</Button>
|
|
190
|
-
<Popconfirm title={t('Delete this quota?')} onConfirm={() => remove(record)}>
|
|
191
|
-
<Button type="link" danger>
|
|
192
|
-
{t('Delete')}
|
|
193
|
-
</Button>
|
|
194
|
-
</Popconfirm>
|
|
195
|
-
</Space>
|
|
196
|
-
),
|
|
197
|
-
},
|
|
198
|
-
];
|
|
199
|
-
|
|
200
|
-
return (
|
|
201
|
-
<Card
|
|
202
|
-
title={t('User quotas')}
|
|
203
|
-
extra={
|
|
204
|
-
<Button type="primary" onClick={showCreate}>
|
|
205
|
-
{t('Add quota')}
|
|
206
|
-
</Button>
|
|
207
|
-
}
|
|
208
|
-
>
|
|
209
|
-
<Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1100 }} />
|
|
210
|
-
<Modal
|
|
211
|
-
title={editing ? t('Edit quota') : t('Add quota')}
|
|
212
|
-
open={open}
|
|
213
|
-
onCancel={() => setOpen(false)}
|
|
214
|
-
onOk={save}
|
|
215
|
-
confirmLoading={saving}
|
|
216
|
-
destroyOnClose
|
|
217
|
-
>
|
|
218
|
-
<Form form={form} layout="vertical" preserve={false}>
|
|
219
|
-
<Form.Item name="userId" label={t('User')} rules={[{ required: true }]}>
|
|
220
|
-
<Select
|
|
221
|
-
disabled={Boolean(editing)}
|
|
222
|
-
showSearch
|
|
223
|
-
optionFilterProp="label"
|
|
224
|
-
options={users.map((user) => ({ label: userLabel(user), value: user.id }))}
|
|
225
|
-
/>
|
|
226
|
-
</Form.Item>
|
|
227
|
-
<Form.Item name="periodType" label={t('Period')} rules={[{ required: true }]}>
|
|
228
|
-
<Select
|
|
229
|
-
options={[
|
|
230
|
-
{ label: t('Daily'), value: 'daily' },
|
|
231
|
-
{ label: t('Monthly'), value: 'monthly' },
|
|
232
|
-
]}
|
|
233
|
-
/>
|
|
234
|
-
</Form.Item>
|
|
235
|
-
<Form.Item name="timezone" label={t('Timezone')} rules={[{ required: true }]}>
|
|
236
|
-
<Input placeholder="UTC" />
|
|
237
|
-
</Form.Item>
|
|
238
|
-
<Form.Item name="requestLimit" label={t('Request limit')}>
|
|
239
|
-
<InputNumber min={0} stringMode style={{ width: '100%' }} />
|
|
240
|
-
</Form.Item>
|
|
241
|
-
<Form.Item name="totalTokenLimit" label={t('Token limit')}>
|
|
242
|
-
<InputNumber min={0} stringMode style={{ width: '100%' }} />
|
|
243
|
-
</Form.Item>
|
|
244
|
-
<Form.Item name="costLimit" label={t('Cost limit')}>
|
|
245
|
-
<InputNumber min={0} stringMode style={{ width: '100%' }} />
|
|
246
|
-
</Form.Item>
|
|
247
|
-
<Form.Item name="currency" label={t('Currency')} rules={[{ required: true }]}>
|
|
248
|
-
<Input />
|
|
249
|
-
</Form.Item>
|
|
250
|
-
<Form.Item name="rejectUnpricedModel" label={t('Reject unpriced models')} valuePropName="checked">
|
|
251
|
-
<Switch />
|
|
252
|
-
</Form.Item>
|
|
253
|
-
<Form.Item name="missingUsageBehavior" label={t('Missing usage behavior')} rules={[{ required: true }]}>
|
|
254
|
-
<Select
|
|
255
|
-
options={[
|
|
256
|
-
{ label: t('Use reserved estimate'), value: 'use_reserved' },
|
|
257
|
-
{ label: t('Allow without token charge'), value: 'allow' },
|
|
258
|
-
]}
|
|
259
|
-
/>
|
|
260
|
-
</Form.Item>
|
|
261
|
-
<Form.Item name="contextOverflowBehavior" label={t('Context overflow behavior')} rules={[{ required: true }]}>
|
|
262
|
-
<Select
|
|
263
|
-
options={[
|
|
264
|
-
{ label: t('Reject request'), value: 'reject' },
|
|
265
|
-
{ label: t('Truncate oldest conversation turns'), value: 'truncate' },
|
|
266
|
-
]}
|
|
267
|
-
/>
|
|
268
|
-
</Form.Item>
|
|
269
|
-
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
|
270
|
-
<Switch />
|
|
271
|
-
</Form.Item>
|
|
272
|
-
</Form>
|
|
273
|
-
</Modal>
|
|
274
|
-
</Card>
|
|
275
|
-
);
|
|
276
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This file is part of the NocoBase (R) project.
|
|
3
|
-
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
-
* Authors: NocoBase Team.
|
|
5
|
-
*
|
|
6
|
-
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
-
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { Context, Next } from '@nocobase/actions';
|
|
11
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
-
import aiApiUserPermissionsResource from '../resource/ai-api-user-permissions';
|
|
13
|
-
|
|
14
|
-
type ActionHandler = (ctx: Context, next: Next) => Promise<void>;
|
|
15
|
-
|
|
16
|
-
const listUsers = aiApiUserPermissionsResource.actions?.listUsers as ActionHandler;
|
|
17
|
-
|
|
18
|
-
function model(values: Record<string, unknown>) {
|
|
19
|
-
return { get: (key: string) => values[key] };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('aiApiUserPermissions:listUsers', () => {
|
|
23
|
-
it('returns the canonical rows shape for NocoBase data wrapping', async () => {
|
|
24
|
-
const user = model({
|
|
25
|
-
id: 7,
|
|
26
|
-
nickname: 'Ada',
|
|
27
|
-
username: 'ada',
|
|
28
|
-
email: 'ada@example.com',
|
|
29
|
-
password: 'must-not-leak',
|
|
30
|
-
});
|
|
31
|
-
const findAndCount = vi.fn(async () => [[user], 1] as const);
|
|
32
|
-
const ctx = {
|
|
33
|
-
action: { params: {} },
|
|
34
|
-
db: { getRepository: () => ({ findAndCount }) },
|
|
35
|
-
} as unknown as Context;
|
|
36
|
-
|
|
37
|
-
await listUsers(ctx, async () => undefined);
|
|
38
|
-
|
|
39
|
-
expect(ctx.body).toEqual({
|
|
40
|
-
rows: [{ id: 7, nickname: 'Ada', username: 'ada', email: 'ada@example.com' }],
|
|
41
|
-
count: 1,
|
|
42
|
-
page: 1,
|
|
43
|
-
pageSize: 50,
|
|
44
|
-
});
|
|
45
|
-
expect(findAndCount).toHaveBeenCalledWith(
|
|
46
|
-
expect.objectContaining({ fields: ['id', 'nickname', 'username', 'email'] }),
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('excludes users that already hold a grant when requested', async () => {
|
|
51
|
-
const findAndCount = vi.fn(async () => [[], 0] as const);
|
|
52
|
-
const ctx = {
|
|
53
|
-
action: { params: { excludeGranted: true } },
|
|
54
|
-
db: {
|
|
55
|
-
getRepository: (name: string) =>
|
|
56
|
-
name === 'aiApiUserPermissions'
|
|
57
|
-
? { find: vi.fn(async () => [model({ userId: 3 }), model({ userId: 8 })]) }
|
|
58
|
-
: { findAndCount },
|
|
59
|
-
},
|
|
60
|
-
} as unknown as Context;
|
|
61
|
-
|
|
62
|
-
await listUsers(ctx, async () => undefined);
|
|
63
|
-
|
|
64
|
-
expect(findAndCount).toHaveBeenCalledWith(expect.objectContaining({ filter: { id: { $notIn: [3, 8] } } }));
|
|
65
|
-
});
|
|
66
|
-
});
|