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.
Files changed (91) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  3. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  4. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  5. package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
  6. package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
  7. package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  10. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  11. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  12. package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
  13. package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
  14. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  15. package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
  16. package/dist/client-v2/index.js +1 -1
  17. package/dist/constants.js +39 -0
  18. package/dist/externalVersion.js +9 -10
  19. package/dist/locale/en-US.json +39 -9
  20. package/dist/locale/vi-VN.json +31 -1
  21. package/dist/locale/zh-CN.json +31 -1
  22. package/dist/server/collections/ai-api-config.js +6 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  24. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  25. package/dist/server/plugin.js +45 -1
  26. package/dist/server/resource/ai-api-config.js +17 -0
  27. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  28. package/dist/server/routes/agent-completions.js +67 -51
  29. package/dist/server/routes/auth.js +11 -1
  30. package/dist/server/routes/chat-completions.js +174 -20
  31. package/dist/server/routes/completions.js +41 -21
  32. package/dist/server/routes/embeddings.js +6 -14
  33. package/dist/server/routes/models.js +102 -20
  34. package/dist/server/routes/router.js +94 -22
  35. package/dist/server/usage.js +2 -0
  36. package/dist/server/utils/app-observability.js +110 -0
  37. package/dist/server/utils/openai-format.js +17 -3
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/utils/user-permissions.js +160 -0
  40. package/dist/server/validation.js +18 -0
  41. package/dist/swagger.js +36 -4
  42. package/package.json +2 -2
  43. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  44. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  45. package/src/client/locale.ts +11 -21
  46. package/src/client/plugin.tsx +28 -8
  47. package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
  48. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  49. package/src/client-v2/locale.ts +21 -1
  50. package/src/client-v2/pages/GeneralPage.tsx +13 -0
  51. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  52. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  53. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  54. package/src/client-v2/plugin.tsx +50 -1
  55. package/src/constants.ts +28 -0
  56. package/src/locale/en-US.json +39 -9
  57. package/src/locale/vi-VN.json +31 -1
  58. package/src/locale/zh-CN.json +31 -1
  59. package/src/server/__tests__/app-observability.test.ts +98 -0
  60. package/src/server/__tests__/models.test.ts +116 -0
  61. package/src/server/__tests__/openai-format.test.ts +52 -1
  62. package/src/server/__tests__/permission-sync.test.ts +109 -0
  63. package/src/server/__tests__/request-body.test.ts +310 -0
  64. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  65. package/src/server/__tests__/usage-route.test.ts +213 -0
  66. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  67. package/src/server/__tests__/user-permissions.test.ts +284 -0
  68. package/src/server/collections/ai-api-config.ts +6 -0
  69. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  70. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  71. package/src/server/plugin.ts +65 -4
  72. package/src/server/resource/ai-api-config.ts +23 -0
  73. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  74. package/src/server/routes/agent-completions.ts +84 -62
  75. package/src/server/routes/auth.ts +14 -1
  76. package/src/server/routes/chat-completions.ts +294 -20
  77. package/src/server/routes/completions.ts +54 -20
  78. package/src/server/routes/embeddings.ts +10 -15
  79. package/src/server/routes/models.ts +318 -195
  80. package/src/server/routes/router.ts +136 -26
  81. package/src/server/usage.ts +2 -0
  82. package/src/server/utils/app-observability.ts +105 -0
  83. package/src/server/utils/openai-format.ts +26 -0
  84. package/src/server/utils/streaming.ts +13 -1
  85. package/src/server/utils/user-permissions.ts +218 -0
  86. package/src/server/validation.ts +27 -0
  87. package/src/swagger.ts +47 -4
  88. package/dist/client/302.25edd5d75460acbf.js +0 -10
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
  91. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -43,7 +43,8 @@ __export(openai_format_exports, {
43
43
  toOpenAIEmbeddingsResponse: () => toOpenAIEmbeddingsResponse,
44
44
  toOpenAIError: () => toOpenAIError,
45
45
  toOpenAIResponse: () => toOpenAIResponse,
46
- toOpenAIStreamChunk: () => toOpenAIStreamChunk
46
+ toOpenAIStreamChunk: () => toOpenAIStreamChunk,
47
+ toOpenAIUsageChunk: () => toOpenAIUsageChunk
47
48
  });
48
49
  module.exports = __toCommonJS(openai_format_exports);
49
50
  var import_crypto = __toESM(require("crypto"));
@@ -116,7 +117,19 @@ function toOpenAIStreamChunk(options) {
116
117
  logprobs: null,
117
118
  finish_reason: finishReason
118
119
  }
119
- ]
120
+ ],
121
+ usage: null
122
+ };
123
+ }
124
+ function toOpenAIUsageChunk(options) {
125
+ const { id, model, usage, object = "chat.completion.chunk" } = options;
126
+ return {
127
+ id,
128
+ object,
129
+ created: Math.floor(Date.now() / 1e3),
130
+ model,
131
+ choices: [],
132
+ usage
120
133
  };
121
134
  }
122
135
  function toOpenAIEmbeddingsResponse(options) {
@@ -154,5 +167,6 @@ function formatSSEDone() {
154
167
  toOpenAIEmbeddingsResponse,
155
168
  toOpenAIError,
156
169
  toOpenAIResponse,
157
- toOpenAIStreamChunk
170
+ toOpenAIStreamChunk,
171
+ toOpenAIUsageChunk
158
172
  });
@@ -26,18 +26,30 @@ var __copyProps = (to, from, except, desc) => {
26
26
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
27
  var streaming_exports = {};
28
28
  __export(streaming_exports, {
29
+ AiApiClientDisconnectedError: () => AiApiClientDisconnectedError,
29
30
  createRequestAbortController: () => createRequestAbortController,
31
+ isClientDisconnected: () => isClientDisconnected,
30
32
  isStreamingRequested: () => isStreamingRequested,
31
33
  writeResponse: () => writeResponse
32
34
  });
33
35
  module.exports = __toCommonJS(streaming_exports);
36
+ class AiApiClientDisconnectedError extends Error {
37
+ code = "client_disconnected";
38
+ constructor() {
39
+ super("Client disconnected");
40
+ this.name = "AiApiClientDisconnectedError";
41
+ }
42
+ }
43
+ function isClientDisconnected(ctx, error) {
44
+ return ctx.req.aborted === true || error instanceof AiApiClientDisconnectedError;
45
+ }
34
46
  function isStreamingRequested(value) {
35
47
  return value !== false;
36
48
  }
37
49
  function createRequestAbortController(ctx) {
38
50
  const controller = new AbortController();
39
51
  const abort = () => {
40
- if (!ctx.res.writableEnded) controller.abort(new Error("Client disconnected"));
52
+ if (!ctx.res.writableEnded) controller.abort(new AiApiClientDisconnectedError());
41
53
  };
42
54
  ctx.req.once("aborted", abort);
43
55
  ctx.res.once("close", abort);
@@ -74,7 +86,9 @@ function waitForDrain(ctx) {
74
86
  }
75
87
  // Annotate the CommonJS export names for ESM import in node:
76
88
  0 && (module.exports = {
89
+ AiApiClientDisconnectedError,
77
90
  createRequestAbortController,
91
+ isClientDisconnected,
78
92
  isStreamingRequested,
79
93
  writeResponse
80
94
  });
@@ -0,0 +1,160 @@
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
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var user_permissions_exports = {};
28
+ __export(user_permissions_exports, {
29
+ buildAccessScope: () => buildAccessScope,
30
+ enforceModelAccess: () => enforceModelAccess,
31
+ invalidateUserPermissionCache: () => invalidateUserPermissionCache,
32
+ isModelAllowed: () => isModelAllowed,
33
+ isServiceAllowed: () => isServiceAllowed,
34
+ resolveUserAccessScope: () => resolveUserAccessScope
35
+ });
36
+ module.exports = __toCommonJS(user_permissions_exports);
37
+ var import_openai_format = require("./openai-format");
38
+ const SCOPE_TTL_MS = 15e3;
39
+ const scopeCache = /* @__PURE__ */ new Map();
40
+ const NO_RECORD_SCOPE = {
41
+ hasUserRecord: false,
42
+ denyAll: false,
43
+ allowedServices: null,
44
+ allowAllModels: true,
45
+ allowedModels: /* @__PURE__ */ new Set(),
46
+ lookupFailed: false
47
+ };
48
+ const LOOKUP_FAILED_SCOPE = { ...NO_RECORD_SCOPE, denyAll: true, lookupFailed: true };
49
+ function invalidateUserPermissionCache(userId) {
50
+ if (userId === void 0 || userId === null) {
51
+ scopeCache.clear();
52
+ return;
53
+ }
54
+ const suffix = `:${userId}`;
55
+ for (const key of scopeCache.keys()) {
56
+ if (key.endsWith(suffix)) scopeCache.delete(key);
57
+ }
58
+ }
59
+ function valueOf(row, name) {
60
+ if (!row) return void 0;
61
+ const candidate = row;
62
+ if (typeof candidate.get === "function") return candidate.get(name);
63
+ return row[name];
64
+ }
65
+ function toStringArray(value) {
66
+ if (!Array.isArray(value)) return [];
67
+ return value.filter((item) => typeof item === "string" && item.length > 0);
68
+ }
69
+ function buildAccessScope(row) {
70
+ if (!row) return NO_RECORD_SCOPE;
71
+ if (valueOf(row, "enabled") === false) {
72
+ return { ...NO_RECORD_SCOPE, hasUserRecord: true, denyAll: true, allowedServices: [] };
73
+ }
74
+ return {
75
+ hasUserRecord: true,
76
+ denyAll: false,
77
+ allowedServices: toStringArray(valueOf(row, "allowedLlmServices")),
78
+ allowAllModels: valueOf(row, "allowAllModels") !== false,
79
+ allowedModels: new Set(toStringArray(valueOf(row, "allowedModels"))),
80
+ lookupFailed: false
81
+ };
82
+ }
83
+ async function resolveUserAccessScope(ctx) {
84
+ var _a, _b, _c, _d;
85
+ const userId = (_a = ctx.state.currentUser) == null ? void 0 : _a.id;
86
+ if (userId === void 0 || userId === null) return NO_RECORD_SCOPE;
87
+ const key = `${((_b = ctx.app) == null ? void 0 : _b.name) ?? "main"}:${userId}`;
88
+ const cached = scopeCache.get(key);
89
+ if (cached && cached.expiresAt > Date.now()) return cached.scope;
90
+ let scope;
91
+ try {
92
+ const row = await ctx.db.getRepository("aiApiUserPermissions").findOne({ filter: { userId } });
93
+ scope = buildAccessScope(row);
94
+ } catch (err) {
95
+ (_d = (_c = ctx.log) == null ? void 0 : _c.error) == null ? void 0 : _d.call(_c, "AI API user permissions lookup failed, denying access:", err);
96
+ return LOOKUP_FAILED_SCOPE;
97
+ }
98
+ scopeCache.set(key, { scope, expiresAt: Date.now() + SCOPE_TTL_MS });
99
+ return scope;
100
+ }
101
+ function matchesService(list, serviceName, serviceTitle) {
102
+ return list.some((entry) => entry === serviceName || entry === serviceTitle);
103
+ }
104
+ function isServiceAllowed(scope, globalEnabledServices, service) {
105
+ if (scope.denyAll) return false;
106
+ const globalList = toStringArray(globalEnabledServices);
107
+ if (globalList.length && !matchesService(globalList, service.name, service.title)) return false;
108
+ if (!scope.hasUserRecord) return true;
109
+ return matchesService(scope.allowedServices ?? [], service.name, service.title);
110
+ }
111
+ function isModelAllowed(scope, fullModelId) {
112
+ if (scope.denyAll) return false;
113
+ if (!scope.hasUserRecord) return true;
114
+ if (scope.allowAllModels) return true;
115
+ return scope.allowedModels.has(fullModelId);
116
+ }
117
+ async function enforceModelAccess(ctx, globalEnabledServices, service, modelId) {
118
+ const scope = await resolveUserAccessScope(ctx);
119
+ const serviceLabel = service.title || service.name;
120
+ if (scope.lookupFailed) {
121
+ ctx.status = 503;
122
+ ctx.body = (0, import_openai_format.toOpenAIError)(
123
+ 503,
124
+ "Unable to verify LLM permissions for this user. Please retry shortly.",
125
+ "service_unavailable",
126
+ "permission_check_failed"
127
+ );
128
+ return false;
129
+ }
130
+ if (!isServiceAllowed(scope, globalEnabledServices, service)) {
131
+ ctx.status = 403;
132
+ ctx.body = (0, import_openai_format.toOpenAIError)(
133
+ 403,
134
+ `LLM service '${serviceLabel}' is not enabled for API access`,
135
+ "permission_denied",
136
+ "model_not_available"
137
+ );
138
+ return false;
139
+ }
140
+ if (!isModelAllowed(scope, `${service.name}/${modelId}`)) {
141
+ ctx.status = 403;
142
+ ctx.body = (0, import_openai_format.toOpenAIError)(
143
+ 403,
144
+ `Model '${service.name}/${modelId}' is not permitted for this user. Use GET /v1/models to see available models.`,
145
+ "permission_denied",
146
+ "model_not_available"
147
+ );
148
+ return false;
149
+ }
150
+ return true;
151
+ }
152
+ // Annotate the CommonJS export names for ESM import in node:
153
+ 0 && (module.exports = {
154
+ buildAccessScope,
155
+ enforceModelAccess,
156
+ invalidateUserPermissionCache,
157
+ isModelAllowed,
158
+ isServiceAllowed,
159
+ resolveUserAccessScope
160
+ });
@@ -36,6 +36,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
36
36
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
37
37
  var validation_exports = {};
38
38
  __export(validation_exports, {
39
+ validateModelMetadata: () => validateModelMetadata,
39
40
  validateModelPrice: () => validateModelPrice,
40
41
  validateQuotaPolicy: () => validateQuotaPolicy
41
42
  });
@@ -77,6 +78,22 @@ async function validateModelPrice(db, model) {
77
78
  });
78
79
  if (overlap) throw new Error("An enabled price already overlaps this effective period.");
79
80
  }
81
+ function requirePositiveIntegerOrNull(value, field) {
82
+ if (value === null || value === void 0 || value === "") return;
83
+ const parsed = Number(value);
84
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${field} must be a positive integer.`);
85
+ }
86
+ function validateModelMetadata(model) {
87
+ if (!String(model.get("llmService") ?? "").trim()) throw new Error("llmService is required.");
88
+ if (!String(model.get("model") ?? "").trim()) throw new Error("model is required.");
89
+ requirePositiveIntegerOrNull(model.get("contextWindow"), "contextWindow");
90
+ requirePositiveIntegerOrNull(model.get("maxCompletionTokens"), "maxCompletionTokens");
91
+ const contextWindow = model.get("contextWindow");
92
+ const maxCompletionTokens = model.get("maxCompletionTokens");
93
+ if (contextWindow !== null && contextWindow !== void 0 && contextWindow !== "" && maxCompletionTokens !== null && maxCompletionTokens !== void 0 && maxCompletionTokens !== "" && Number(maxCompletionTokens) > Number(contextWindow)) {
94
+ throw new Error("maxCompletionTokens cannot exceed contextWindow.");
95
+ }
96
+ }
80
97
  function validateQuotaPolicy(model) {
81
98
  if (!["daily", "monthly"].includes(String(model.get("periodType")))) {
82
99
  throw new Error("periodType must be daily or monthly.");
@@ -97,6 +114,7 @@ function validateQuotaPolicy(model) {
97
114
  }
98
115
  // Annotate the CommonJS export names for ESM import in node:
99
116
  0 && (module.exports = {
117
+ validateModelMetadata,
100
118
  validateModelPrice,
101
119
  validateQuotaPolicy
102
120
  });
package/dist/swagger.js CHANGED
@@ -78,7 +78,7 @@ var swagger_default = {
78
78
  get: {
79
79
  tags: ["ai-llm"],
80
80
  summary: "List available models",
81
- description: "Returns all LLM models available across registered services. Model IDs are formatted as `serviceName/modelId`.",
81
+ description: "Returns the LLM models available to the authenticated caller across registered services. Model IDs are formatted as `serviceName/modelId`.\n\nThe catalog is user-scoped: it starts from `enabledLlmServices` in the AI API configuration, then narrows to the caller's `aiApiUserPermissions` record when one exists. A user grant can only narrow the global whitelist, never widen it, so two users may receive different lists from the same request.",
82
82
  security: [{ BearerAuth: [] }],
83
83
  responses: {
84
84
  200: {
@@ -105,6 +105,7 @@ var swagger_default = {
105
105
  get: {
106
106
  tags: ["ai-llm"],
107
107
  summary: "Get model details",
108
+ description: "User-scoped in the same way as `GET /v1/models`: a model the caller is not granted is reported as not found rather than disclosed.",
108
109
  security: [{ BearerAuth: [] }],
109
110
  parameters: [
110
111
  {
@@ -124,7 +125,7 @@ var swagger_default = {
124
125
  }
125
126
  }
126
127
  },
127
- 404: { description: "Model not found" }
128
+ 404: { description: "Model not found, or not available to this user" }
128
129
  }
129
130
  }
130
131
  },
@@ -272,11 +273,18 @@ var swagger_default = {
272
273
  enabledLlmServices: {
273
274
  type: "array",
274
275
  items: { type: "string" },
275
- description: "List of enabled LLM service names"
276
+ description: "List of enabled LLM service names. This is the outer bound for every caller; per-user `aiApiUserPermissions` records can only narrow it further."
276
277
  },
277
278
  rateLimitPerMinute: {
278
279
  type: "integer",
279
280
  description: "Max requests per minute per user (0 = unlimited)"
281
+ },
282
+ maxRequestBodyMb: {
283
+ type: "integer",
284
+ minimum: 1,
285
+ maximum: 100,
286
+ default: 10,
287
+ description: "Max request body size in megabytes. Requests above this return 413. The gateway buffers each body in memory, so values above 100 are rejected."
280
288
  }
281
289
  }
282
290
  },
@@ -289,11 +297,35 @@ var swagger_default = {
289
297
  owned_by: { type: "string" }
290
298
  }
291
299
  },
300
+ ContentBlock: {
301
+ type: "object",
302
+ description: "A multimodal content block. Only text and image_url blocks are forwarded to the provider; any other type is rejected with 400 unsupported_content_block.",
303
+ properties: {
304
+ type: { type: "string", enum: ["text", "image_url"] },
305
+ text: { type: "string" },
306
+ image_url: {
307
+ type: "object",
308
+ properties: {
309
+ url: {
310
+ type: "string",
311
+ description: "An https URL or a base64 data URL, e.g. data:image/png;base64,iVBORw0KGgo...",
312
+ example: "data:image/png;base64,iVBORw0KGgo..."
313
+ },
314
+ detail: { type: "string", enum: ["auto", "low", "high"] }
315
+ },
316
+ required: ["url"]
317
+ }
318
+ },
319
+ required: ["type"]
320
+ },
292
321
  ChatMessage: {
293
322
  type: "object",
294
323
  properties: {
295
324
  role: { type: "string", enum: ["system", "user", "assistant", "tool"] },
296
- content: { type: "string" },
325
+ content: {
326
+ description: 'Plain text, or an array of content blocks for multimodal requests. Inline base64 images inflate the payload by about 33%; see "Max request body size" in the gateway settings.',
327
+ oneOf: [{ type: "string" }, { type: "array", items: { $ref: "#/components/schemas/ContentBlock" } }]
328
+ },
297
329
  name: { type: "string" },
298
330
  tool_call_id: { type: "string" },
299
331
  tool_calls: { type: "array", items: { $ref: "#/components/schemas/ToolCall" } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-ai-api",
3
- "version": "1.0.20",
3
+ "version": "1.0.23",
4
4
  "main": "dist/server/index.js",
5
5
  "dependencies": {},
6
6
  "peerDependencies": {
@@ -30,4 +30,4 @@
30
30
  "server.d.ts"
31
31
  ],
32
32
  "license": "Apache-2.0"
33
- }
33
+ }
@@ -0,0 +1,69 @@
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 { Application } from '@nocobase/client';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../../constants';
13
+ import { PluginAiApiClient } from '../plugin';
14
+
15
+ describe('AI API v1 settings registration', () => {
16
+ async function loadPlugin() {
17
+ const app = new Application({});
18
+ await new PluginAiApiClient({}, app).load();
19
+ return app;
20
+ }
21
+
22
+ it('registers the same ordered settings tabs and ACL boundaries as v2', async () => {
23
+ const app = await loadPlugin();
24
+ app.pluginSettingsManager.setAclSnippets([]);
25
+
26
+ const menu = app.pluginSettingsManager.get('ai-api');
27
+
28
+ expect(menu?.children?.map((item) => item.name)).toEqual([
29
+ 'ai-api.config',
30
+ 'ai-api.model-pricing',
31
+ 'ai-api.model-metadata',
32
+ 'ai-api.user-permissions',
33
+ 'ai-api.user-quotas',
34
+ 'ai-api.usage',
35
+ ]);
36
+ expect(app.pluginSettingsManager.getAclSnippet('ai-api.user-permissions')).toBe(AI_API_USER_PERMISSIONS_SNIPPET);
37
+ expect(app.pluginSettingsManager.getRoutePath('ai-api.user-permissions')).toBe(
38
+ '/admin/settings/ai-api/user-permissions',
39
+ );
40
+ expect(app.pluginSettingsManager.getList().map((item) => item.name)).not.toContain('ai-api-user-permissions');
41
+ });
42
+
43
+ it('requires access to the legacy parent before exposing the user permissions tab', async () => {
44
+ const app = await loadPlugin();
45
+ app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`]);
46
+
47
+ expect(app.pluginSettingsManager.getList()).toEqual([]);
48
+ });
49
+
50
+ it('hides only the user permissions tab when its own snippet is denied', async () => {
51
+ const app = await loadPlugin();
52
+ app.pluginSettingsManager.setAclSnippets([`!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
53
+
54
+ expect(app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name)).toEqual([
55
+ 'ai-api.config',
56
+ 'ai-api.model-pricing',
57
+ 'ai-api.model-metadata',
58
+ 'ai-api.user-quotas',
59
+ 'ai-api.usage',
60
+ ]);
61
+ });
62
+
63
+ it('hides both settings surfaces when both snippets are denied', async () => {
64
+ const app = await loadPlugin();
65
+ app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`, `!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
66
+
67
+ expect(app.pluginSettingsManager.getList()).toEqual([]);
68
+ });
69
+ });
@@ -1,169 +1,11 @@
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, { useEffect, useState } from 'react';
11
- import { useApp } from '@nocobase/client-v2';
12
- import { Card, Switch, Select, Space, Typography, Spin, Divider } from 'antd';
13
-
14
- const { Text } = Typography;
15
-
16
- interface AiEmployee {
17
- username: string;
18
- nickname: string;
19
- }
20
-
21
- interface RolePermRecord {
22
- id?: number;
23
- roleName: string;
24
- enabled: boolean;
25
- allowAllEmployees: boolean;
26
- allowedEmployees: string[];
27
- }
28
-
29
- /**
30
- * Permission tab shown in Settings → Users & Permissions → [Role] → "AI API" tab.
31
- * Lets admins control whether a role can use the AI API and which AI Employees it may access.
32
- */
33
- export function AiApiRolePermissions({ role }: { role: any }) {
34
- const api = useApp().apiClient;
35
- const [loading, setLoading] = useState(true);
36
- const [saving, setSaving] = useState(false);
37
- const [employees, setEmployees] = useState<AiEmployee[]>([]);
38
- const [record, setRecord] = useState<RolePermRecord | null>(null);
39
-
40
- const roleName = role?.name;
41
-
42
- useEffect(() => {
43
- if (!roleName) return;
44
- loadData();
45
- // eslint-disable-next-line react-hooks/exhaustive-deps
46
- }, [roleName]);
47
-
48
- const loadData = async () => {
49
- setLoading(true);
50
- try {
51
- const [permRes, empRes] = await Promise.all([
52
- api.request({
53
- url: 'aiApiRolePermissions',
54
- params: { filter: { roleName }, paginate: false },
55
- }),
56
- api.request({ url: 'aiEmployees:list', params: { paginate: false } }),
57
- ]);
58
-
59
- const existing = permRes?.data?.data?.[0];
60
- setRecord(
61
- existing
62
- ? {
63
- id: existing.id,
64
- roleName: existing.roleName,
65
- enabled: !!existing.enabled,
66
- allowAllEmployees: existing.allowAllEmployees !== false,
67
- allowedEmployees: existing.allowedEmployees || [],
68
- }
69
- : {
70
- roleName,
71
- enabled: false,
72
- allowAllEmployees: true,
73
- allowedEmployees: [],
74
- },
75
- );
76
-
77
- setEmployees(
78
- (empRes?.data?.data || []).map((e: any) => ({
79
- username: e.username,
80
- nickname: e.nickname || e.username,
81
- })),
82
- );
83
- } catch (err) {
84
- console.error('Failed to load AI API role permissions:', err);
85
- } finally {
86
- setLoading(false);
87
- }
88
- };
89
-
90
- const save = async (patch: Partial<RolePermRecord>) => {
91
- if (!record) return;
92
- const next = { ...record, ...patch };
93
- setRecord(next);
94
- setSaving(true);
95
- try {
96
- if (next.id) {
97
- await api.request({
98
- url: `aiApiRolePermissions/${next.id}`,
99
- method: 'PUT',
100
- data: next,
101
- });
102
- } else {
103
- const res = await api.request({
104
- url: 'aiApiRolePermissions',
105
- method: 'POST',
106
- data: next,
107
- });
108
- const created = res?.data?.data;
109
- if (created?.id) {
110
- setRecord({ ...next, id: created.id });
111
- }
112
- }
113
- } catch (err) {
114
- console.error('Failed to save AI API role permissions:', err);
115
- // Revert on error
116
- setRecord(record);
117
- } finally {
118
- setSaving(false);
119
- }
120
- };
121
-
122
- if (loading) return <Spin />;
123
-
124
- return (
125
- <Card bordered={false}>
126
- <Space direction="vertical" style={{ width: '100%' }} size="middle">
127
- <Space>
128
- <Switch checked={!!record?.enabled} loading={saving} onChange={(checked) => save({ enabled: checked })} />
129
- <Text strong>Allow this role to use the AI API</Text>
130
- </Space>
131
-
132
- {record?.enabled && (
133
- <>
134
- <Divider style={{ margin: '8px 0' }} />
135
- <Space>
136
- <Switch
137
- checked={!!record?.allowAllEmployees}
138
- loading={saving}
139
- onChange={(checked) => save({ allowAllEmployees: checked })}
140
- />
141
- <Text>Allow all AI Employees</Text>
142
- </Space>
143
-
144
- {!record?.allowAllEmployees && (
145
- <div>
146
- <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
147
- Select which AI Employees this role may use:
148
- </Text>
149
- <Select
150
- mode="multiple"
151
- allowClear
152
- style={{ width: '100%', maxWidth: 480 }}
153
- placeholder="Select allowed AI Employees"
154
- value={record?.allowedEmployees || []}
155
- options={employees.map((e) => ({
156
- label: `${e.nickname} (${e.username})`,
157
- value: e.username,
158
- }))}
159
- onChange={(vals) => save({ allowedEmployees: vals })}
160
- disabled={saving}
161
- />
162
- </div>
163
- )}
164
- </>
165
- )}
166
- </Space>
167
- </Card>
168
- );
169
- }
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
+ // Implementation lives in client-v2; imports may only flow v1 -> v2, never back.
11
+ export { AiApiRolePermissions } from '../../client-v2/components/AiApiRolePermissions';
@@ -1,21 +1,11 @@
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
- }
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
+ // Implementation lives in client-v2; imports may only flow v1 -> v2, never back.
11
+ export { tExpr, useT } from '../client-v2/locale';