plugin-ai-api 1.0.15 → 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.
- 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.a01403fb7a1bea01.js +10 -0
- package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
- package/dist/client/97.72979a11a067a7c9.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
- package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
- package/dist/client-v2/757.a117ce1cf7119cea.js +10 -0
- package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/97.29c663318eebbd57.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +36 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +105 -10
- package/dist/locale/vi-VN.json +105 -0
- package/dist/locale/zh-CN.json +105 -10
- package/dist/server/billing.js +331 -0
- package/dist/server/collections/ai-api-config.js +18 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/collections/ai-api-model-prices.js +55 -0
- package/dist/server/collections/ai-api-usage-records.js +9 -0
- package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
- package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
- package/dist/server/plugin.js +36 -3
- package/dist/server/resource/ai-api-config.js +25 -0
- package/dist/server/resource/ai-api-usage-monitor.js +86 -0
- package/dist/server/routes/agent-completions.js +62 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +157 -6
- package/dist/server/routes/completions.js +20 -3
- package/dist/server/routes/models.js +78 -20
- package/dist/server/routes/router.js +108 -23
- package/dist/server/usage.js +19 -2
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/validation.js +120 -0
- package/dist/swagger.js +32 -1
- package/package.json +1 -1
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +82 -48
- package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
- package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
- package/src/client-v2/locale.ts +21 -0
- package/src/client-v2/pages/GeneralPage.tsx +183 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/pages/UsagePage.tsx +248 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
- package/src/client-v2/pages/api.ts +16 -0
- package/src/client-v2/plugin.tsx +62 -4
- package/src/constants.ts +21 -0
- package/src/locale/en-US.json +105 -10
- package/src/locale/vi-VN.json +105 -0
- package/src/locale/zh-CN.json +105 -10
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/billing-quota.test.ts +134 -0
- package/src/server/__tests__/billing.test.ts +33 -0
- package/src/server/__tests__/models.test.ts +74 -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-monitor.test.ts +63 -0
- package/src/server/__tests__/usage-route.test.ts +4 -0
- package/src/server/billing.ts +387 -0
- package/src/server/collections/ai-api-config.ts +69 -51
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/collections/ai-api-model-prices.ts +25 -0
- package/src/server/collections/ai-api-usage-records.ts +9 -0
- package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
- package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
- package/src/server/plugin.ts +47 -5
- package/src/server/resource/ai-api-config.ts +105 -74
- package/src/server/resource/ai-api-usage-monitor.ts +74 -0
- package/src/server/routes/agent-completions.ts +77 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +275 -6
- package/src/server/routes/completions.ts +27 -4
- package/src/server/routes/models.ts +290 -195
- package/src/server/routes/router.ts +152 -27
- package/src/server/usage.ts +19 -1
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/validation.ts +89 -0
- package/src/swagger.ts +38 -1
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client/950.83390c5f1d5a97fb.js +0 -10
- package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
- 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
|
+
}
|
|
@@ -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
|
+
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
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { Alert, Button, Card, Form, InputNumber, Select, Space, Switch, Typography, message } from 'antd';
|
|
3
|
+
import { useFlowContext } from '@nocobase/flow-engine';
|
|
4
|
+
import { useT } from '../locale';
|
|
5
|
+
import { errorMessage, unwrapData } from './api';
|
|
6
|
+
|
|
7
|
+
interface GeneralSettings {
|
|
8
|
+
mode: 'llm' | 'agent';
|
|
9
|
+
defaultAiEmployee?: string;
|
|
10
|
+
defaultLlmService?: string;
|
|
11
|
+
enabledLlmServices: string[];
|
|
12
|
+
rateLimitPerMinute: number;
|
|
13
|
+
maxRequestBodyMb: number;
|
|
14
|
+
quotaEnabled: boolean;
|
|
15
|
+
defaultReservationOutputTokens: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface LlmService {
|
|
19
|
+
name: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface AiEmployee {
|
|
24
|
+
username: string;
|
|
25
|
+
nickname?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaults: GeneralSettings = {
|
|
29
|
+
mode: 'llm',
|
|
30
|
+
enabledLlmServices: [],
|
|
31
|
+
rateLimitPerMinute: 60,
|
|
32
|
+
maxRequestBodyMb: 10,
|
|
33
|
+
quotaEnabled: false,
|
|
34
|
+
defaultReservationOutputTokens: 4096,
|
|
35
|
+
};
|
|
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
|
+
|
|
40
|
+
export default function GeneralPage() {
|
|
41
|
+
const ctx = useFlowContext();
|
|
42
|
+
const t = useT();
|
|
43
|
+
const [form] = Form.useForm<GeneralSettings>();
|
|
44
|
+
const mode = Form.useWatch('mode', form);
|
|
45
|
+
const [loading, setLoading] = useState(true);
|
|
46
|
+
const [saving, setSaving] = useState(false);
|
|
47
|
+
const [services, setServices] = useState<LlmService[]>([]);
|
|
48
|
+
const [employees, setEmployees] = useState<AiEmployee[]>([]);
|
|
49
|
+
const [loadError, setLoadError] = useState<string>();
|
|
50
|
+
|
|
51
|
+
const load = useCallback(async () => {
|
|
52
|
+
setLoading(true);
|
|
53
|
+
setLoadError(undefined);
|
|
54
|
+
try {
|
|
55
|
+
const [configResponse, servicesResponse, employeesResponse] = await Promise.all([
|
|
56
|
+
ctx.api.request({ url: 'aiApiConfig:get', method: 'get' }),
|
|
57
|
+
ctx.api.request({ url: 'ai:listLLMServices', method: 'get' }),
|
|
58
|
+
ctx.api.request({ url: 'aiEmployees:list', method: 'get', params: { paginate: false } }),
|
|
59
|
+
]);
|
|
60
|
+
form.setFieldsValue({ ...defaults, ...unwrapData<Partial<GeneralSettings>>(configResponse, {}) });
|
|
61
|
+
setServices(unwrapData<LlmService[]>(servicesResponse, []));
|
|
62
|
+
setEmployees(unwrapData<AiEmployee[]>(employeesResponse, []));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
setLoadError(errorMessage(error));
|
|
65
|
+
} finally {
|
|
66
|
+
setLoading(false);
|
|
67
|
+
}
|
|
68
|
+
}, [ctx.api, form]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
load();
|
|
72
|
+
}, [load]);
|
|
73
|
+
|
|
74
|
+
const save = async () => {
|
|
75
|
+
const values = await form.validateFields();
|
|
76
|
+
setSaving(true);
|
|
77
|
+
try {
|
|
78
|
+
await ctx.api.request({ url: 'aiApiConfig:save', method: 'post', data: values });
|
|
79
|
+
message.success(t('Configuration saved'));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
message.error(`${t('Failed to save configuration')}: ${errorMessage(error)}`);
|
|
82
|
+
} finally {
|
|
83
|
+
setSaving(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const serviceOptions = services.map((service) => ({
|
|
88
|
+
label: service.title || service.name,
|
|
89
|
+
value: service.name,
|
|
90
|
+
}));
|
|
91
|
+
const employeeOptions = employees.map((employee) => ({
|
|
92
|
+
label: employee.nickname ? `${employee.nickname} (${employee.username})` : employee.username,
|
|
93
|
+
value: employee.username,
|
|
94
|
+
}));
|
|
95
|
+
const baseUrl = `${window.location.origin}/api/ai-llm/v1`;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Card title={t('Configuration')} loading={loading}>
|
|
99
|
+
{loadError ? <Alert type="error" showIcon message={loadError} style={{ marginBottom: 16 }} /> : null}
|
|
100
|
+
<Form form={form} layout="vertical" style={{ maxWidth: 720 }} initialValues={defaults}>
|
|
101
|
+
<Form.Item name="mode" label={t('API mode')} rules={[{ required: true }]}>
|
|
102
|
+
<Select
|
|
103
|
+
options={[
|
|
104
|
+
{ label: t('Direct LLM'), value: 'llm' },
|
|
105
|
+
{ label: t('AI Employee agent'), value: 'agent' },
|
|
106
|
+
]}
|
|
107
|
+
/>
|
|
108
|
+
</Form.Item>
|
|
109
|
+
<Form.Item name="defaultLlmService" label={t('Default LLM service')}>
|
|
110
|
+
<Select allowClear showSearch optionFilterProp="label" options={serviceOptions} />
|
|
111
|
+
</Form.Item>
|
|
112
|
+
<Form.Item name="enabledLlmServices" label={t('Enabled LLM Services')}>
|
|
113
|
+
<Select mode="multiple" allowClear showSearch optionFilterProp="label" options={serviceOptions} />
|
|
114
|
+
</Form.Item>
|
|
115
|
+
{mode === 'agent' ? (
|
|
116
|
+
<Form.Item name="defaultAiEmployee" label={t('Default AI Employee')}>
|
|
117
|
+
<Select
|
|
118
|
+
allowClear
|
|
119
|
+
showSearch
|
|
120
|
+
optionFilterProp="label"
|
|
121
|
+
placeholder={t('Select an AI Employee')}
|
|
122
|
+
options={employeeOptions}
|
|
123
|
+
/>
|
|
124
|
+
</Form.Item>
|
|
125
|
+
) : null}
|
|
126
|
+
<Form.Item name="rateLimitPerMinute" label={t('Rate Limit')} rules={[{ required: true }]}>
|
|
127
|
+
<InputNumber min={1} style={{ width: '100%' }} />
|
|
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>
|
|
137
|
+
<Form.Item name="quotaEnabled" label={t('Enable user quotas')} valuePropName="checked">
|
|
138
|
+
<Switch />
|
|
139
|
+
</Form.Item>
|
|
140
|
+
<Form.Item
|
|
141
|
+
name="defaultReservationOutputTokens"
|
|
142
|
+
label={t('Default reserved output tokens')}
|
|
143
|
+
rules={[{ required: true }]}
|
|
144
|
+
>
|
|
145
|
+
<InputNumber min={1} style={{ width: '100%' }} />
|
|
146
|
+
</Form.Item>
|
|
147
|
+
<Space>
|
|
148
|
+
<Button type="primary" loading={saving} onClick={save}>
|
|
149
|
+
{t('Save Configuration')}
|
|
150
|
+
</Button>
|
|
151
|
+
<Button onClick={load}>{t('Refresh')}</Button>
|
|
152
|
+
</Space>
|
|
153
|
+
</Form>
|
|
154
|
+
<Card title={t('Usage guide')} size="small" style={{ marginTop: 24 }}>
|
|
155
|
+
<Alert
|
|
156
|
+
type="info"
|
|
157
|
+
showIcon
|
|
158
|
+
message={t('OpenAI-compatible endpoint')}
|
|
159
|
+
description={
|
|
160
|
+
<Space direction="vertical" size={4}>
|
|
161
|
+
<Typography.Text>{t('Base URL')}</Typography.Text>
|
|
162
|
+
<Typography.Text code copyable>
|
|
163
|
+
{baseUrl}
|
|
164
|
+
</Typography.Text>
|
|
165
|
+
<Typography.Text>{t('Use a NocoBase API key as the Bearer token.')}</Typography.Text>
|
|
166
|
+
</Space>
|
|
167
|
+
}
|
|
168
|
+
style={{ marginBottom: 16 }}
|
|
169
|
+
/>
|
|
170
|
+
<Typography.Paragraph>{t('List available models')}</Typography.Paragraph>
|
|
171
|
+
<Typography.Paragraph
|
|
172
|
+
code
|
|
173
|
+
copyable
|
|
174
|
+
>{`curl ${baseUrl}/models -H "Authorization: Bearer <your-api-key>"`}</Typography.Paragraph>
|
|
175
|
+
<Typography.Paragraph>{t('Send a chat completion')}</Typography.Paragraph>
|
|
176
|
+
<Typography.Paragraph code copyable>{`curl ${baseUrl}/chat/completions \\
|
|
177
|
+
-H "Authorization: Bearer <your-api-key>" \\
|
|
178
|
+
-H "Content-Type: application/json" \\
|
|
179
|
+
-d '{"model":"<service>/<model>","messages":[{"role":"user","content":"Hello"}]}'`}</Typography.Paragraph>
|
|
180
|
+
</Card>
|
|
181
|
+
</Card>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -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
|
+
}
|