plugin-ai-api 1.0.20 → 1.0.21

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.
Files changed (72) hide show
  1. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  2. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  3. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  4. package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
  5. package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
  6. package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  9. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  10. package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
  11. package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
  12. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  13. package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +36 -0
  16. package/dist/externalVersion.js +9 -10
  17. package/dist/locale/en-US.json +28 -9
  18. package/dist/locale/vi-VN.json +20 -1
  19. package/dist/locale/zh-CN.json +20 -1
  20. package/dist/server/collections/ai-api-config.js +6 -0
  21. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  22. package/dist/server/plugin.js +13 -1
  23. package/dist/server/resource/ai-api-config.js +17 -0
  24. package/dist/server/routes/agent-completions.js +62 -51
  25. package/dist/server/routes/auth.js +11 -1
  26. package/dist/server/routes/chat-completions.js +145 -4
  27. package/dist/server/routes/completions.js +8 -1
  28. package/dist/server/routes/models.js +78 -20
  29. package/dist/server/routes/router.js +94 -22
  30. package/dist/server/usage.js +2 -0
  31. package/dist/server/utils/app-observability.js +110 -0
  32. package/dist/server/utils/streaming.js +15 -1
  33. package/dist/server/validation.js +18 -0
  34. package/dist/swagger.js +32 -1
  35. package/package.json +1 -1
  36. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  37. package/src/client/locale.ts +11 -21
  38. package/src/client/plugin.tsx +17 -8
  39. package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
  40. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  41. package/src/client-v2/locale.ts +21 -1
  42. package/src/client-v2/pages/GeneralPage.tsx +13 -0
  43. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  44. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  45. package/src/client-v2/plugin.tsx +41 -1
  46. package/src/constants.ts +21 -0
  47. package/src/locale/en-US.json +28 -9
  48. package/src/locale/vi-VN.json +20 -1
  49. package/src/locale/zh-CN.json +20 -1
  50. package/src/server/__tests__/app-observability.test.ts +98 -0
  51. package/src/server/__tests__/models.test.ts +74 -0
  52. package/src/server/__tests__/request-body.test.ts +310 -0
  53. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  54. package/src/server/collections/ai-api-config.ts +6 -0
  55. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  56. package/src/server/plugin.ts +24 -4
  57. package/src/server/resource/ai-api-config.ts +23 -0
  58. package/src/server/routes/agent-completions.ts +77 -62
  59. package/src/server/routes/auth.ts +14 -1
  60. package/src/server/routes/chat-completions.ts +262 -4
  61. package/src/server/routes/completions.ts +14 -2
  62. package/src/server/routes/models.ts +290 -195
  63. package/src/server/routes/router.ts +136 -26
  64. package/src/server/usage.ts +2 -0
  65. package/src/server/utils/app-observability.ts +105 -0
  66. package/src/server/utils/streaming.ts +13 -1
  67. package/src/server/validation.ts +27 -0
  68. package/src/swagger.ts +38 -1
  69. package/dist/client/302.25edd5d75460acbf.js +0 -10
  70. package/dist/client/778.5c452944cb747975.js +0 -10
  71. package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
  72. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -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
+ }
@@ -1 +1,21 @@
1
- export { tExpr, useT } from '../client/locale';
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
+ }
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import { AiApiRolePermissions } from '../components/AiApiRolePermissions';
3
+
4
+ /**
5
+ * v2 ACL permission tab wrapper.
6
+ *
7
+ * The v2 `settingsUI.addPermissionsTab` loads a component via `componentLoader` and
8
+ * passes it `PermissionTabProps` ({ activeRole, ... }), whereas AiApiRolePermissions
9
+ * takes { role }. This wrapper bridges the prop shape so the shared component can be
10
+ * reused unchanged across both client runtimes.
11
+ */
12
+ export default function RolePermissionsTab({ activeRole }: { activeRole?: { name?: string } | null }) {
13
+ return <AiApiRolePermissions role={activeRole} />;
14
+ }
@@ -1,4 +1,19 @@
1
+ import React from 'react';
1
2
  import { Plugin, Application } from '@nocobase/client-v2';
3
+ import { AI_API_ACL_SNIPPET } from '../constants';
4
+
5
+ interface PermissionTabOptions {
6
+ key: string;
7
+ label: string;
8
+ sort?: number;
9
+ componentLoader: () => Promise<{ default: React.ComponentType<{ activeRole?: { name?: string } | null }> }>;
10
+ }
11
+
12
+ interface AclPluginV2Compat {
13
+ settingsUI?: {
14
+ addPermissionsTab?: (options: PermissionTabOptions) => void;
15
+ };
16
+ }
2
17
 
3
18
  export class PluginAiApiClient extends Plugin<Record<string, never>, Application> {
4
19
  async load() {
@@ -6,13 +21,15 @@ export class PluginAiApiClient extends Plugin<Record<string, never>, Application
6
21
  key: 'ai-api',
7
22
  title: this.t('AI API Gateway'),
8
23
  icon: 'ApiOutlined',
9
- aclSnippet: 'pm.ai-api.configuration',
24
+ aclSnippet: AI_API_ACL_SNIPPET,
10
25
  });
11
26
 
12
27
  this.pluginSettingsManager.addPageTabItem({
13
28
  menuKey: 'ai-api',
14
29
  key: 'index',
15
30
  title: this.t('Configuration'),
31
+ aclSnippet: AI_API_ACL_SNIPPET,
32
+ sort: 1,
16
33
  componentLoader: () => import('./pages/GeneralPage'),
17
34
  });
18
35
 
@@ -20,13 +37,26 @@ export class PluginAiApiClient extends Plugin<Record<string, never>, Application
20
37
  menuKey: 'ai-api',
21
38
  key: 'model-pricing',
22
39
  title: this.t('Model pricing'),
40
+ aclSnippet: AI_API_ACL_SNIPPET,
41
+ sort: 2,
23
42
  componentLoader: () => import('./pages/ModelPricingPage'),
24
43
  });
25
44
 
45
+ this.pluginSettingsManager.addPageTabItem({
46
+ menuKey: 'ai-api',
47
+ key: 'model-metadata',
48
+ title: this.t('Model metadata'),
49
+ aclSnippet: AI_API_ACL_SNIPPET,
50
+ sort: 3,
51
+ componentLoader: () => import('./pages/ModelMetadataPage'),
52
+ });
53
+
26
54
  this.pluginSettingsManager.addPageTabItem({
27
55
  menuKey: 'ai-api',
28
56
  key: 'user-quotas',
29
57
  title: this.t('User quotas'),
58
+ aclSnippet: AI_API_ACL_SNIPPET,
59
+ sort: 4,
30
60
  componentLoader: () => import('./pages/UserQuotasPage'),
31
61
  });
32
62
 
@@ -34,8 +64,18 @@ export class PluginAiApiClient extends Plugin<Record<string, never>, Application
34
64
  menuKey: 'ai-api',
35
65
  key: 'usage',
36
66
  title: this.t('Usage'),
67
+ aclSnippet: AI_API_ACL_SNIPPET,
68
+ sort: 5,
37
69
  componentLoader: () => import('./pages/UsagePage'),
38
70
  });
71
+
72
+ const aclPlugin = this.app.pm.get('@nocobase/plugin-acl') as AclPluginV2Compat | undefined;
73
+ aclPlugin?.settingsUI?.addPermissionsTab?.({
74
+ key: 'aiApi',
75
+ label: this.t('AI API'),
76
+ sort: 25,
77
+ componentLoader: () => import('./pages/RolePermissionsTab'),
78
+ });
39
79
  }
40
80
  }
41
81
 
@@ -0,0 +1,21 @@
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
+ /**
11
+ * The single admin snippet the server registers as `pm.${this.name}.configuration`.
12
+ *
13
+ * `this.name` is the plugin's package name with only `@nocobase/plugin-` or
14
+ * `@nocobase/preset-` stripped (see utils/plugin-package `getPluginNameFromPackageName`),
15
+ * so for this unscoped package it stays `plugin-ai-api` — verified against the running
16
+ * app, which reports `name: "plugin-ai-api"`. Spelling it `pm.ai-api.configuration` on the
17
+ * client names a snippet no role can ever hold.
18
+ *
19
+ * Shared by both client runtimes so a rename cannot silently desynchronise them.
20
+ */
21
+ export const AI_API_ACL_SNIPPET = 'pm.plugin-ai-api.configuration';
@@ -1,11 +1,11 @@
1
- {
2
- "AI API Gateway": "AI API Gateway",
3
- "Configuration": "Configuration",
4
- "Default AI Employee": "Default AI Employee",
5
- "Enabled LLM Services": "Enabled LLM Services",
6
- "Rate Limit": "Rate Limit",
7
- "Save Configuration": "Save Configuration",
8
- "Configuration saved": "Configuration saved",
1
+ {
2
+ "AI API Gateway": "AI API Gateway",
3
+ "Configuration": "Configuration",
4
+ "Default AI Employee": "Default AI Employee",
5
+ "Enabled LLM Services": "Enabled LLM Services",
6
+ "Rate Limit": "Rate Limit",
7
+ "Save Configuration": "Save Configuration",
8
+ "Configuration saved": "Configuration saved",
9
9
  "Failed to save configuration": "Failed to save configuration",
10
10
  "API mode": "API mode",
11
11
  "Direct LLM": "Direct LLM",
@@ -82,5 +82,24 @@
82
82
  "Reset": "Reset",
83
83
  "Requests": "Requests",
84
84
  "Total cost": "Total cost",
85
- "Usage records": "Usage records"
85
+ "Usage records": "Usage records",
86
+ "Model metadata": "Model metadata",
87
+ "Add override": "Add override",
88
+ "Edit override": "Edit override",
89
+ "Delete this override?": "Delete this override?",
90
+ "Context window": "Context window",
91
+ "Max completion tokens": "Max completion tokens",
92
+ "Owned by": "Owned by",
93
+ "Display name": "Display name",
94
+ "Description": "Description",
95
+ "Leave empty to not override": "Leave empty to not override",
96
+ "Total input + output token capacity reported to clients.": "Total input + output token capacity reported to clients.",
97
+ "Maximum output tokens reported to clients.": "Maximum output tokens reported to clients.",
98
+ "AI API": "AI API",
99
+ "Allow this role to use the AI API": "Allow this role to use the AI API",
100
+ "Allow all AI Employees": "Allow all AI Employees",
101
+ "Select which AI Employees this role may use:": "Select which AI Employees this role may use:",
102
+ "Select allowed AI Employees": "Select allowed AI Employees",
103
+ "Max request body size (MB)": "Max request body size (MB)",
104
+ "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."
86
105
  }