plugin-ai-api 1.0.20 → 1.0.23
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/123.e6fe04c856ce6417.js +10 -0
- package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
- package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
- package/dist/client/562.17a0a299d2e5152c.js +10 -0
- package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
- package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
- package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
- package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
- package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
- package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +39 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +39 -9
- package/dist/locale/vi-VN.json +31 -1
- package/dist/locale/zh-CN.json +31 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/collections/ai-api-user-permissions.js +67 -0
- package/dist/server/plugin.js +45 -1
- package/dist/server/resource/ai-api-config.js +17 -0
- package/dist/server/resource/ai-api-user-permissions.js +75 -0
- package/dist/server/routes/agent-completions.js +67 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +174 -20
- package/dist/server/routes/completions.js +41 -21
- package/dist/server/routes/embeddings.js +6 -14
- package/dist/server/routes/models.js +102 -20
- package/dist/server/routes/router.js +94 -22
- package/dist/server/usage.js +2 -0
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/openai-format.js +17 -3
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/utils/user-permissions.js +160 -0
- package/dist/server/validation.js +18 -0
- package/dist/swagger.js +36 -4
- package/package.json +2 -2
- package/src/client/__tests__/settings-registration.test.tsx +69 -0
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +28 -8
- package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
- package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
- package/src/client-v2/locale.ts +21 -1
- package/src/client-v2/pages/GeneralPage.tsx +13 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
- package/src/client-v2/plugin.tsx +50 -1
- package/src/constants.ts +28 -0
- package/src/locale/en-US.json +39 -9
- package/src/locale/vi-VN.json +31 -1
- package/src/locale/zh-CN.json +31 -1
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/models.test.ts +116 -0
- package/src/server/__tests__/openai-format.test.ts +52 -1
- package/src/server/__tests__/permission-sync.test.ts +109 -0
- package/src/server/__tests__/request-body.test.ts +310 -0
- package/src/server/__tests__/streaming-observability.test.ts +51 -0
- package/src/server/__tests__/usage-route.test.ts +213 -0
- package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
- package/src/server/__tests__/user-permissions.test.ts +284 -0
- package/src/server/collections/ai-api-config.ts +6 -0
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/collections/ai-api-user-permissions.ts +46 -0
- package/src/server/plugin.ts +65 -4
- package/src/server/resource/ai-api-config.ts +23 -0
- package/src/server/resource/ai-api-user-permissions.ts +76 -0
- package/src/server/routes/agent-completions.ts +84 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +294 -20
- package/src/server/routes/completions.ts +54 -20
- package/src/server/routes/embeddings.ts +10 -15
- package/src/server/routes/models.ts +318 -195
- package/src/server/routes/router.ts +136 -26
- package/src/server/usage.ts +2 -0
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/openai-format.ts +26 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/utils/user-permissions.ts +218 -0
- package/src/server/validation.ts +27 -0
- package/src/swagger.ts +47 -4
- package/dist/client/302.25edd5d75460acbf.js +0 -10
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
- package/src/client/AiApiConfigPage.tsx +0 -309
package/src/client/plugin.tsx
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
import { Plugin, lazy } from '@nocobase/client';
|
|
11
11
|
import PluginACLClient from '@nocobase/plugin-acl/client';
|
|
12
|
+
import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
|
|
12
13
|
import React from 'react';
|
|
13
14
|
|
|
14
15
|
const AiApiConfigPage = React.lazy(() => import('../client-v2/pages/GeneralPage'));
|
|
15
16
|
const AiApiModelPricingPage = React.lazy(() => import('../client-v2/pages/ModelPricingPage'));
|
|
17
|
+
const AiApiModelMetadataPage = React.lazy(() => import('../client-v2/pages/ModelMetadataPage'));
|
|
18
|
+
const AiApiUserPermissionsPage = React.lazy(() => import('../client-v2/pages/UserPermissionsPage'));
|
|
16
19
|
const AiApiUserQuotasPage = React.lazy(() => import('../client-v2/pages/UserQuotasPage'));
|
|
17
20
|
const AiApiUsagePage = React.lazy(() => import('../client-v2/pages/UsagePage'));
|
|
18
21
|
const { AiApiRolePermissions } = lazy(() => import('./components/AiApiRolePermissions'), 'AiApiRolePermissions');
|
|
@@ -22,35 +25,52 @@ export class PluginAiApiClient extends Plugin {
|
|
|
22
25
|
this.app.pluginSettingsManager.add('ai-api', {
|
|
23
26
|
icon: 'ApiOutlined',
|
|
24
27
|
title: this.t('AI API Gateway'),
|
|
25
|
-
aclSnippet:
|
|
28
|
+
aclSnippet: AI_API_ACL_SNIPPET,
|
|
26
29
|
});
|
|
27
30
|
|
|
28
31
|
this.app.pluginSettingsManager.add('ai-api.config', {
|
|
29
32
|
title: this.t('Configuration'),
|
|
30
33
|
Component: AiApiConfigPage,
|
|
31
|
-
aclSnippet:
|
|
34
|
+
aclSnippet: AI_API_ACL_SNIPPET,
|
|
32
35
|
sort: 1,
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
this.app.pluginSettingsManager.add('ai-api.model-pricing', {
|
|
36
39
|
title: this.t('Model pricing'),
|
|
37
40
|
Component: AiApiModelPricingPage,
|
|
38
|
-
aclSnippet:
|
|
41
|
+
aclSnippet: AI_API_ACL_SNIPPET,
|
|
39
42
|
sort: 2,
|
|
40
43
|
});
|
|
41
44
|
|
|
45
|
+
this.app.pluginSettingsManager.add('ai-api.model-metadata', {
|
|
46
|
+
title: this.t('Model metadata'),
|
|
47
|
+
Component: AiApiModelMetadataPage,
|
|
48
|
+
aclSnippet: AI_API_ACL_SNIPPET,
|
|
49
|
+
sort: 3,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Keep the legacy settings layout aligned with client-v2. The v1 settings
|
|
53
|
+
// manager evaluates the parent ACL first, so this tab additionally requires
|
|
54
|
+
// access to the AI API settings parent even though it keeps its own snippet.
|
|
55
|
+
this.app.pluginSettingsManager.add('ai-api.user-permissions', {
|
|
56
|
+
title: this.t('User LLM permissions'),
|
|
57
|
+
Component: AiApiUserPermissionsPage,
|
|
58
|
+
aclSnippet: AI_API_USER_PERMISSIONS_SNIPPET,
|
|
59
|
+
sort: 4,
|
|
60
|
+
});
|
|
61
|
+
|
|
42
62
|
this.app.pluginSettingsManager.add('ai-api.user-quotas', {
|
|
43
63
|
title: this.t('User quotas'),
|
|
44
64
|
Component: AiApiUserQuotasPage,
|
|
45
|
-
aclSnippet:
|
|
46
|
-
sort:
|
|
65
|
+
aclSnippet: AI_API_ACL_SNIPPET,
|
|
66
|
+
sort: 5,
|
|
47
67
|
});
|
|
48
68
|
|
|
49
69
|
this.app.pluginSettingsManager.add('ai-api.usage', {
|
|
50
70
|
title: this.t('Usage'),
|
|
51
71
|
Component: AiApiUsagePage,
|
|
52
|
-
aclSnippet:
|
|
53
|
-
sort:
|
|
72
|
+
aclSnippet: AI_API_ACL_SNIPPET,
|
|
73
|
+
sort: 6,
|
|
54
74
|
});
|
|
55
75
|
|
|
56
76
|
// Add "AI API" tab in Settings → Users & Permissions → [Role]
|
|
@@ -58,7 +78,7 @@ export class PluginAiApiClient extends Plugin {
|
|
|
58
78
|
if (aclPlugin?.settingsUI) {
|
|
59
79
|
aclPlugin.settingsUI.addPermissionsTab(({ t, TabLayout, activeRole }) => ({
|
|
60
80
|
key: 'aiApi',
|
|
61
|
-
label: 'AI API',
|
|
81
|
+
label: t('AI API', { ns: ['plugin-ai-api', 'client'] }),
|
|
62
82
|
sort: 25,
|
|
63
83
|
children: (
|
|
64
84
|
<TabLayout>
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { createMockClient } from '@nocobase/client-v2';
|
|
11
|
+
import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../../constants';
|
|
12
|
+
import { PluginAiApiClient } from '../plugin';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Guards the settings registration itself rather than any page's contents.
|
|
16
|
+
*
|
|
17
|
+
* Two regressions are cheap to reintroduce and invisible until a non-admin logs in:
|
|
18
|
+
* omitting `aclSnippet` makes addPageTabItem default to `pm.ai-api.<key>`, a snippet the
|
|
19
|
+
* server never registers, and omitting `sort` silently reorders the tabs alphabetically.
|
|
20
|
+
*
|
|
21
|
+
* The tabs deliberately span two snippets: everything sits under AI_API_ACL_SNIPPET except
|
|
22
|
+
* user permissions, which is gated separately so granting it does not also grant gateway
|
|
23
|
+
* configuration. Each snippet is evaluated independently, so denying one leaves the other's
|
|
24
|
+
* tabs — and therefore the menu — reachable.
|
|
25
|
+
*/
|
|
26
|
+
describe('AI API v2 settings registration', () => {
|
|
27
|
+
async function loadPlugin() {
|
|
28
|
+
const app = createMockClient();
|
|
29
|
+
await new PluginAiApiClient({}, app).load();
|
|
30
|
+
return app;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it('registers every page under a snippet the server exposes', async () => {
|
|
34
|
+
const app = await loadPlugin();
|
|
35
|
+
const menu = app.pluginSettingsManager.get('ai-api', false);
|
|
36
|
+
|
|
37
|
+
expect(menu?.aclSnippet).toBe(AI_API_ACL_SNIPPET);
|
|
38
|
+
for (const child of menu?.children ?? []) {
|
|
39
|
+
expect(app.pluginSettingsManager.getAclSnippet(child.name), child.name).toBe(
|
|
40
|
+
child.name === 'ai-api.user-permissions' ? AI_API_USER_PERMISSIONS_SNIPPET : AI_API_ACL_SNIPPET,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('leaves only the separately-gated tab when the main snippet is denied', async () => {
|
|
46
|
+
const app = await loadPlugin();
|
|
47
|
+
app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`]);
|
|
48
|
+
|
|
49
|
+
// User permissions carries its own snippet, so it survives on its own merit and keeps the
|
|
50
|
+
// menu reachable. That separation is the point: a role can be allowed to hand out model
|
|
51
|
+
// access without also being allowed to reconfigure the gateway.
|
|
52
|
+
expect(app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name)).toEqual([
|
|
53
|
+
'ai-api.user-permissions',
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('hides the menu and every tab when both snippets are denied', async () => {
|
|
58
|
+
const app = await loadPlugin();
|
|
59
|
+
app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`, `!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
|
|
60
|
+
|
|
61
|
+
expect(app.pluginSettingsManager.get('ai-api')).toBeNull();
|
|
62
|
+
expect(app.pluginSettingsManager.getList().map((item) => item.name)).not.toContain('ai-api');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('hides only the user permissions tab when its own snippet is denied', async () => {
|
|
66
|
+
const app = await loadPlugin();
|
|
67
|
+
app.pluginSettingsManager.setAclSnippets([`!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
|
|
68
|
+
|
|
69
|
+
const children = app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name) ?? [];
|
|
70
|
+
expect(children).not.toContain('ai-api.user-permissions');
|
|
71
|
+
expect(children).toContain('ai-api.user-quotas');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps registration order instead of sorting tabs by name', async () => {
|
|
75
|
+
const app = await loadPlugin();
|
|
76
|
+
app.pluginSettingsManager.setAclSnippets([]);
|
|
77
|
+
|
|
78
|
+
expect(app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name)).toEqual([
|
|
79
|
+
'ai-api.index',
|
|
80
|
+
'ai-api.model-pricing',
|
|
81
|
+
'ai-api.model-metadata',
|
|
82
|
+
'ai-api.user-permissions',
|
|
83
|
+
'ai-api.user-quotas',
|
|
84
|
+
'ai-api.usage',
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
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 React, { useCallback, useEffect, useState } from 'react';
|
|
11
|
+
import { useApp } from '@nocobase/client-v2';
|
|
12
|
+
import { Card, Switch, Select, Space, Typography, Spin, Divider } from 'antd';
|
|
13
|
+
import { useT } from '../locale';
|
|
14
|
+
|
|
15
|
+
const { Text } = Typography;
|
|
16
|
+
|
|
17
|
+
interface AiEmployee {
|
|
18
|
+
username: string;
|
|
19
|
+
nickname: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RolePermRecord {
|
|
23
|
+
id?: number;
|
|
24
|
+
roleName: string;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
allowAllEmployees: boolean;
|
|
27
|
+
allowedEmployees: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Permission tab shown in Settings → Users & Permissions → [Role] → "AI API" tab.
|
|
32
|
+
* Lets admins control whether a role can use the AI API and which AI Employees it may access.
|
|
33
|
+
*/
|
|
34
|
+
export function AiApiRolePermissions({ role }: { role?: { name?: string } | null }) {
|
|
35
|
+
const api = useApp().apiClient;
|
|
36
|
+
const t = useT();
|
|
37
|
+
const [loading, setLoading] = useState(true);
|
|
38
|
+
const [saving, setSaving] = useState(false);
|
|
39
|
+
const [employees, setEmployees] = useState<AiEmployee[]>([]);
|
|
40
|
+
const [record, setRecord] = useState<RolePermRecord | null>(null);
|
|
41
|
+
|
|
42
|
+
const roleName = role?.name;
|
|
43
|
+
|
|
44
|
+
const loadData = useCallback(
|
|
45
|
+
async (name: string) => {
|
|
46
|
+
setLoading(true);
|
|
47
|
+
try {
|
|
48
|
+
const [permRes, empRes] = await Promise.all([
|
|
49
|
+
api.request({
|
|
50
|
+
url: 'aiApiRolePermissions',
|
|
51
|
+
params: { filter: { roleName: name }, paginate: false },
|
|
52
|
+
}),
|
|
53
|
+
api.request({ url: 'aiEmployees:list', params: { paginate: false } }),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const existing = permRes?.data?.data?.[0];
|
|
57
|
+
setRecord(
|
|
58
|
+
existing
|
|
59
|
+
? {
|
|
60
|
+
id: existing.id,
|
|
61
|
+
roleName: existing.roleName,
|
|
62
|
+
enabled: !!existing.enabled,
|
|
63
|
+
allowAllEmployees: existing.allowAllEmployees !== false,
|
|
64
|
+
allowedEmployees: existing.allowedEmployees || [],
|
|
65
|
+
}
|
|
66
|
+
: {
|
|
67
|
+
roleName: name,
|
|
68
|
+
enabled: false,
|
|
69
|
+
allowAllEmployees: true,
|
|
70
|
+
allowedEmployees: [],
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
setEmployees(
|
|
75
|
+
(empRes?.data?.data || []).map((employee: AiEmployee) => ({
|
|
76
|
+
username: employee.username,
|
|
77
|
+
nickname: employee.nickname || employee.username,
|
|
78
|
+
})),
|
|
79
|
+
);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error('Failed to load AI API role permissions:', err);
|
|
82
|
+
} finally {
|
|
83
|
+
setLoading(false);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[api],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!roleName) return;
|
|
91
|
+
loadData(roleName);
|
|
92
|
+
}, [roleName, loadData]);
|
|
93
|
+
|
|
94
|
+
const save = async (patch: Partial<RolePermRecord>) => {
|
|
95
|
+
if (!record) return;
|
|
96
|
+
const next = { ...record, ...patch };
|
|
97
|
+
setRecord(next);
|
|
98
|
+
setSaving(true);
|
|
99
|
+
try {
|
|
100
|
+
if (next.id) {
|
|
101
|
+
await api.request({
|
|
102
|
+
url: `aiApiRolePermissions/${next.id}`,
|
|
103
|
+
method: 'PUT',
|
|
104
|
+
data: next,
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
const res = await api.request({
|
|
108
|
+
url: 'aiApiRolePermissions',
|
|
109
|
+
method: 'POST',
|
|
110
|
+
data: next,
|
|
111
|
+
});
|
|
112
|
+
const created = res?.data?.data;
|
|
113
|
+
if (created?.id) {
|
|
114
|
+
setRecord({ ...next, id: created.id });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error('Failed to save AI API role permissions:', err);
|
|
119
|
+
// Revert on error
|
|
120
|
+
setRecord(record);
|
|
121
|
+
} finally {
|
|
122
|
+
setSaving(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (loading) return <Spin />;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Card bordered={false}>
|
|
130
|
+
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
131
|
+
<Space>
|
|
132
|
+
<Switch checked={!!record?.enabled} loading={saving} onChange={(checked) => save({ enabled: checked })} />
|
|
133
|
+
<Text strong>{t('Allow this role to use the AI API')}</Text>
|
|
134
|
+
</Space>
|
|
135
|
+
|
|
136
|
+
{record?.enabled && (
|
|
137
|
+
<>
|
|
138
|
+
<Divider style={{ margin: '8px 0' }} />
|
|
139
|
+
<Space>
|
|
140
|
+
<Switch
|
|
141
|
+
checked={!!record?.allowAllEmployees}
|
|
142
|
+
loading={saving}
|
|
143
|
+
onChange={(checked) => save({ allowAllEmployees: checked })}
|
|
144
|
+
/>
|
|
145
|
+
<Text>{t('Allow all AI Employees')}</Text>
|
|
146
|
+
</Space>
|
|
147
|
+
|
|
148
|
+
{!record?.allowAllEmployees && (
|
|
149
|
+
<div>
|
|
150
|
+
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
|
151
|
+
{t('Select which AI Employees this role may use:')}
|
|
152
|
+
</Text>
|
|
153
|
+
<Select
|
|
154
|
+
mode="multiple"
|
|
155
|
+
allowClear
|
|
156
|
+
style={{ width: '100%', maxWidth: 480 }}
|
|
157
|
+
placeholder={t('Select allowed AI Employees')}
|
|
158
|
+
value={record?.allowedEmployees || []}
|
|
159
|
+
options={employees.map((employee) => ({
|
|
160
|
+
label: `${employee.nickname} (${employee.username})`,
|
|
161
|
+
value: employee.username,
|
|
162
|
+
}))}
|
|
163
|
+
onChange={(vals) => save({ allowedEmployees: vals })}
|
|
164
|
+
disabled={saving}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
</Space>
|
|
171
|
+
</Card>
|
|
172
|
+
);
|
|
173
|
+
}
|
package/src/client-v2/locale.ts
CHANGED
|
@@ -1 +1,21 @@
|
|
|
1
|
-
|
|
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 { tExpr as _tExpr, useFlowEngine } from '@nocobase/flow-engine';
|
|
11
|
+
// @ts-ignore
|
|
12
|
+
import pkg from './../../package.json';
|
|
13
|
+
|
|
14
|
+
export function useT() {
|
|
15
|
+
const engine = useFlowEngine();
|
|
16
|
+
return (str: string) => engine.context.t(str, { ns: [pkg.name, 'client'] });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function tExpr(key: string) {
|
|
20
|
+
return _tExpr(key, { ns: [pkg.name, 'client'] });
|
|
21
|
+
}
|
|
@@ -10,6 +10,7 @@ interface GeneralSettings {
|
|
|
10
10
|
defaultLlmService?: string;
|
|
11
11
|
enabledLlmServices: string[];
|
|
12
12
|
rateLimitPerMinute: number;
|
|
13
|
+
maxRequestBodyMb: number;
|
|
13
14
|
quotaEnabled: boolean;
|
|
14
15
|
defaultReservationOutputTokens: number;
|
|
15
16
|
}
|
|
@@ -28,10 +29,14 @@ const defaults: GeneralSettings = {
|
|
|
28
29
|
mode: 'llm',
|
|
29
30
|
enabledLlmServices: [],
|
|
30
31
|
rateLimitPerMinute: 60,
|
|
32
|
+
maxRequestBodyMb: 10,
|
|
31
33
|
quotaEnabled: false,
|
|
32
34
|
defaultReservationOutputTokens: 4096,
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
/** Mirrors MAX_REQUEST_BODY_MB_LIMIT in server/routes/router.ts, which rejects anything higher. */
|
|
38
|
+
const MAX_REQUEST_BODY_MB = 100;
|
|
39
|
+
|
|
35
40
|
export default function GeneralPage() {
|
|
36
41
|
const ctx = useFlowContext();
|
|
37
42
|
const t = useT();
|
|
@@ -121,6 +126,14 @@ export default function GeneralPage() {
|
|
|
121
126
|
<Form.Item name="rateLimitPerMinute" label={t('Rate Limit')} rules={[{ required: true }]}>
|
|
122
127
|
<InputNumber min={1} style={{ width: '100%' }} />
|
|
123
128
|
</Form.Item>
|
|
129
|
+
<Form.Item
|
|
130
|
+
name="maxRequestBodyMb"
|
|
131
|
+
label={t('Max request body size (MB)')}
|
|
132
|
+
extra={t('Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.')}
|
|
133
|
+
rules={[{ required: true }]}
|
|
134
|
+
>
|
|
135
|
+
<InputNumber min={1} max={MAX_REQUEST_BODY_MB} precision={0} style={{ width: '100%' }} />
|
|
136
|
+
</Form.Item>
|
|
124
137
|
<Form.Item name="quotaEnabled" label={t('Enable user quotas')} valuePropName="checked">
|
|
125
138
|
<Switch />
|
|
126
139
|
</Form.Item>
|
|
@@ -0,0 +1,280 @@
|
|
|
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 ModelMetadata {
|
|
23
|
+
id: string | number;
|
|
24
|
+
llmService: string;
|
|
25
|
+
model: string;
|
|
26
|
+
contextWindow?: number | null;
|
|
27
|
+
maxCompletionTokens?: number | null;
|
|
28
|
+
ownedByOverride?: string | null;
|
|
29
|
+
displayName?: string | null;
|
|
30
|
+
description?: string | null;
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type MetadataFormValues = Omit<ModelMetadata, 'id'>;
|
|
35
|
+
|
|
36
|
+
interface LlmService {
|
|
37
|
+
name: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
provider: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface LlmModel {
|
|
43
|
+
id: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function ModelMetadataPage() {
|
|
47
|
+
const ctx = useFlowContext();
|
|
48
|
+
const t = useT();
|
|
49
|
+
const [form] = Form.useForm<MetadataFormValues>();
|
|
50
|
+
const selectedService = Form.useWatch('llmService', form);
|
|
51
|
+
const [rows, setRows] = useState<ModelMetadata[]>([]);
|
|
52
|
+
const [services, setServices] = useState<LlmService[]>([]);
|
|
53
|
+
const [models, setModels] = useState<LlmModel[]>([]);
|
|
54
|
+
const [modelsLoading, setModelsLoading] = useState(false);
|
|
55
|
+
const [loading, setLoading] = useState(false);
|
|
56
|
+
const [saving, setSaving] = useState(false);
|
|
57
|
+
const [editing, setEditing] = useState<ModelMetadata>();
|
|
58
|
+
const [open, setOpen] = useState(false);
|
|
59
|
+
|
|
60
|
+
const load = useCallback(async () => {
|
|
61
|
+
setLoading(true);
|
|
62
|
+
try {
|
|
63
|
+
const [metadataResponse, servicesResponse] = await Promise.all([
|
|
64
|
+
ctx.api.request({
|
|
65
|
+
url: 'aiApiModelMetadata:list',
|
|
66
|
+
method: 'get',
|
|
67
|
+
params: { pageSize: 200, sort: 'llmService' },
|
|
68
|
+
}),
|
|
69
|
+
ctx.api.request({ url: 'ai:listLLMServices', method: 'get' }),
|
|
70
|
+
]);
|
|
71
|
+
setRows(unwrapData<ModelMetadata[]>(metadataResponse, []));
|
|
72
|
+
setServices(unwrapData<LlmService[]>(servicesResponse, []));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
message.error(errorMessage(error));
|
|
75
|
+
} finally {
|
|
76
|
+
setLoading(false);
|
|
77
|
+
}
|
|
78
|
+
}, [ctx.api]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
load();
|
|
82
|
+
}, [load]);
|
|
83
|
+
|
|
84
|
+
const loadModels = useCallback(
|
|
85
|
+
async (llmService: string, currentModel?: string) => {
|
|
86
|
+
setModelsLoading(true);
|
|
87
|
+
try {
|
|
88
|
+
const response = await ctx.api.request({
|
|
89
|
+
url: 'ai:listModels',
|
|
90
|
+
method: 'get',
|
|
91
|
+
params: { llmService },
|
|
92
|
+
});
|
|
93
|
+
const loadedModels = unwrapData<LlmModel[]>(response, []);
|
|
94
|
+
setModels(
|
|
95
|
+
currentModel && !loadedModels.some((item) => item.id === currentModel)
|
|
96
|
+
? [{ id: currentModel }, ...loadedModels]
|
|
97
|
+
: loadedModels,
|
|
98
|
+
);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
setModels(currentModel ? [{ id: currentModel }] : []);
|
|
101
|
+
message.error(`${t('Failed to load models')}: ${errorMessage(error)}`);
|
|
102
|
+
} finally {
|
|
103
|
+
setModelsLoading(false);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[ctx.api, t],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const showCreate = () => {
|
|
110
|
+
setEditing(undefined);
|
|
111
|
+
setModels([]);
|
|
112
|
+
form.resetFields();
|
|
113
|
+
form.setFieldsValue({ enabled: true } as MetadataFormValues);
|
|
114
|
+
setOpen(true);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const showEdit = (record: ModelMetadata) => {
|
|
118
|
+
setEditing(record);
|
|
119
|
+
form.setFieldsValue({ ...record });
|
|
120
|
+
setOpen(true);
|
|
121
|
+
loadModels(record.llmService, record.model);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const changeService = (llmService: string) => {
|
|
125
|
+
form.setFieldValue('model', undefined);
|
|
126
|
+
setModels([]);
|
|
127
|
+
loadModels(llmService);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const save = async () => {
|
|
131
|
+
const values = await form.validateFields();
|
|
132
|
+
const data = {
|
|
133
|
+
...values,
|
|
134
|
+
contextWindow: values.contextWindow ?? null,
|
|
135
|
+
maxCompletionTokens: values.maxCompletionTokens ?? null,
|
|
136
|
+
ownedByOverride: values.ownedByOverride?.trim() || null,
|
|
137
|
+
displayName: values.displayName?.trim() || null,
|
|
138
|
+
description: values.description?.trim() || null,
|
|
139
|
+
};
|
|
140
|
+
setSaving(true);
|
|
141
|
+
try {
|
|
142
|
+
await ctx.api.request({
|
|
143
|
+
url: editing ? `aiApiModelMetadata:update/${editing.id}` : 'aiApiModelMetadata:create',
|
|
144
|
+
method: 'post',
|
|
145
|
+
data,
|
|
146
|
+
});
|
|
147
|
+
message.success(t('Saved successfully'));
|
|
148
|
+
setOpen(false);
|
|
149
|
+
await load();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
message.error(errorMessage(error));
|
|
152
|
+
} finally {
|
|
153
|
+
setSaving(false);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const remove = async (id: string | number) => {
|
|
158
|
+
try {
|
|
159
|
+
await ctx.api.request({ url: `aiApiModelMetadata:destroy/${id}`, method: 'post' });
|
|
160
|
+
message.success(t('Deleted successfully'));
|
|
161
|
+
await load();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
message.error(errorMessage(error));
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const columns: ColumnsType<ModelMetadata> = [
|
|
168
|
+
{ title: t('LLM service'), dataIndex: 'llmService', key: 'llmService', width: 160 },
|
|
169
|
+
{ title: t('Model'), dataIndex: 'model', key: 'model', width: 180 },
|
|
170
|
+
{ title: t('Context window'), dataIndex: 'contextWindow', key: 'contextWindow', width: 140 },
|
|
171
|
+
{ title: t('Max completion tokens'), dataIndex: 'maxCompletionTokens', key: 'maxCompletionTokens', width: 170 },
|
|
172
|
+
{ title: t('Owned by'), dataIndex: 'ownedByOverride', key: 'ownedByOverride', width: 140 },
|
|
173
|
+
{ title: t('Display name'), dataIndex: 'displayName', key: 'displayName', width: 160 },
|
|
174
|
+
{
|
|
175
|
+
title: t('Status'),
|
|
176
|
+
dataIndex: 'enabled',
|
|
177
|
+
key: 'enabled',
|
|
178
|
+
width: 100,
|
|
179
|
+
render: (enabled: boolean) => (
|
|
180
|
+
<Tag color={enabled ? 'green' : 'default'}>{enabled ? t('Enabled') : t('Disabled')}</Tag>
|
|
181
|
+
),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
title: t('Actions'),
|
|
185
|
+
key: 'actions',
|
|
186
|
+
fixed: 'right',
|
|
187
|
+
width: 150,
|
|
188
|
+
render: (_, record) => (
|
|
189
|
+
<Space>
|
|
190
|
+
<Button type="link" onClick={() => showEdit(record)}>
|
|
191
|
+
{t('Edit')}
|
|
192
|
+
</Button>
|
|
193
|
+
<Popconfirm title={t('Delete this override?')} onConfirm={() => remove(record.id)}>
|
|
194
|
+
<Button type="link" danger>
|
|
195
|
+
{t('Delete')}
|
|
196
|
+
</Button>
|
|
197
|
+
</Popconfirm>
|
|
198
|
+
</Space>
|
|
199
|
+
),
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<Card
|
|
205
|
+
title={t('Model metadata')}
|
|
206
|
+
extra={
|
|
207
|
+
<Button type="primary" onClick={showCreate}>
|
|
208
|
+
{t('Add override')}
|
|
209
|
+
</Button>
|
|
210
|
+
}
|
|
211
|
+
>
|
|
212
|
+
<Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1200 }} />
|
|
213
|
+
<Modal
|
|
214
|
+
title={editing ? t('Edit override') : t('Add override')}
|
|
215
|
+
open={open}
|
|
216
|
+
onCancel={() => setOpen(false)}
|
|
217
|
+
onOk={save}
|
|
218
|
+
confirmLoading={saving}
|
|
219
|
+
destroyOnClose
|
|
220
|
+
>
|
|
221
|
+
<Form form={form} layout="vertical" preserve={false}>
|
|
222
|
+
<Form.Item name="llmService" label={t('LLM service')} rules={[{ required: true }]}>
|
|
223
|
+
<Select
|
|
224
|
+
showSearch
|
|
225
|
+
optionFilterProp="label"
|
|
226
|
+
onChange={changeService}
|
|
227
|
+
options={services.map((service) => ({ label: service.title || service.name, value: service.name }))}
|
|
228
|
+
/>
|
|
229
|
+
</Form.Item>
|
|
230
|
+
<Form.Item name="model" label={t('Model')} rules={[{ required: true }]}>
|
|
231
|
+
<Select
|
|
232
|
+
showSearch
|
|
233
|
+
optionFilterProp="label"
|
|
234
|
+
loading={modelsLoading}
|
|
235
|
+
disabled={!selectedService}
|
|
236
|
+
placeholder={t('Select a model')}
|
|
237
|
+
options={models.map((model) => ({ label: model.id, value: model.id }))}
|
|
238
|
+
/>
|
|
239
|
+
</Form.Item>
|
|
240
|
+
<Form.Item
|
|
241
|
+
name="contextWindow"
|
|
242
|
+
label={t('Context window')}
|
|
243
|
+
tooltip={t('Total input + output token capacity reported to clients.')}
|
|
244
|
+
>
|
|
245
|
+
<InputNumber
|
|
246
|
+
min={1}
|
|
247
|
+
precision={0}
|
|
248
|
+
style={{ width: '100%' }}
|
|
249
|
+
placeholder={t('Leave empty to not override')}
|
|
250
|
+
/>
|
|
251
|
+
</Form.Item>
|
|
252
|
+
<Form.Item
|
|
253
|
+
name="maxCompletionTokens"
|
|
254
|
+
label={t('Max completion tokens')}
|
|
255
|
+
tooltip={t('Maximum output tokens reported to clients.')}
|
|
256
|
+
>
|
|
257
|
+
<InputNumber
|
|
258
|
+
min={1}
|
|
259
|
+
precision={0}
|
|
260
|
+
style={{ width: '100%' }}
|
|
261
|
+
placeholder={t('Leave empty to not override')}
|
|
262
|
+
/>
|
|
263
|
+
</Form.Item>
|
|
264
|
+
<Form.Item name="ownedByOverride" label={t('Owned by')}>
|
|
265
|
+
<Input placeholder={t('Leave empty to not override')} />
|
|
266
|
+
</Form.Item>
|
|
267
|
+
<Form.Item name="displayName" label={t('Display name')}>
|
|
268
|
+
<Input placeholder={t('Leave empty to not override')} />
|
|
269
|
+
</Form.Item>
|
|
270
|
+
<Form.Item name="description" label={t('Description')}>
|
|
271
|
+
<Input.TextArea rows={3} />
|
|
272
|
+
</Form.Item>
|
|
273
|
+
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
|
274
|
+
<Switch />
|
|
275
|
+
</Form.Item>
|
|
276
|
+
</Form>
|
|
277
|
+
</Modal>
|
|
278
|
+
</Card>
|
|
279
|
+
);
|
|
280
|
+
}
|