@nocobase/portal-template-default 1.0.0 → 1.0.2

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 (194) hide show
  1. package/README.MD +90 -1
  2. package/components.json +3 -1
  3. package/package.json +21 -5
  4. package/registry/nocobase-acl/README.md +19 -0
  5. package/registry/nocobase-acl/components/acl-boundary.tsx +106 -0
  6. package/registry/nocobase-acl/components/index.ts +3 -0
  7. package/registry/nocobase-acl/components/role-options.ts +36 -0
  8. package/registry/nocobase-acl/components/role-switcher.tsx +135 -0
  9. package/registry/nocobase-acl/demo/boundary-api.tsx +186 -0
  10. package/registry/nocobase-acl/demo/components.tsx +231 -0
  11. package/registry/nocobase-acl/demo/index.tsx +505 -0
  12. package/registry/nocobase-acl/demo/lazy-route.tsx +10 -0
  13. package/registry/nocobase-acl/demo/prompt-generator.tsx +134 -0
  14. package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +198 -0
  15. package/registry/nocobase-acl/demo/scenario-section.tsx +37 -0
  16. package/registry/nocobase-acl/extension.tsx +71 -0
  17. package/registry/nocobase-acl/index.ts +1 -0
  18. package/registry/nocobase-acl/tests/acl-regression.mjs +314 -0
  19. package/registry/nocobase-ai/README.md +70 -0
  20. package/registry/nocobase-ai/adapters/react-hook-form.ts +20 -0
  21. package/registry/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
  22. package/registry/nocobase-ai/components/ai-root-provider.tsx +32 -0
  23. package/registry/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
  24. package/registry/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
  25. package/registry/nocobase-ai/components/chat/chat-compact.tsx +90 -0
  26. package/registry/nocobase-ai/components/chat/chat-composer.tsx +429 -0
  27. package/registry/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
  28. package/registry/nocobase-ai/components/chat/chat-header.tsx +88 -0
  29. package/registry/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
  30. package/registry/nocobase-ai/components/chat/chat-message.tsx +252 -0
  31. package/registry/nocobase-ai/components/chat/chat-messages.tsx +173 -0
  32. package/registry/nocobase-ai/components/chat/chat-window.tsx +118 -0
  33. package/registry/nocobase-ai/components/chat/conversation-list.tsx +343 -0
  34. package/registry/nocobase-ai/components/chat/markdown-message.tsx +165 -0
  35. package/registry/nocobase-ai/components/chat/model-select-options.tsx +30 -0
  36. package/registry/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
  37. package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
  38. package/registry/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
  39. package/registry/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
  40. package/registry/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
  41. package/registry/nocobase-ai/components/index.ts +59 -0
  42. package/registry/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
  43. package/registry/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
  44. package/registry/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
  45. package/registry/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
  46. package/registry/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
  47. package/registry/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
  48. package/registry/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
  49. package/registry/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
  50. package/registry/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
  51. package/registry/nocobase-ai/components/tools/business-report-dialog.tsx +364 -0
  52. package/registry/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
  53. package/registry/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
  54. package/registry/nocobase-ai/components/tools/chart-renderer.tsx +99 -0
  55. package/registry/nocobase-ai/components/tools/echarts-preview.tsx +59 -0
  56. package/registry/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
  57. package/registry/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
  58. package/registry/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
  59. package/registry/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
  60. package/registry/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
  61. package/registry/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
  62. package/registry/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
  63. package/registry/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
  64. package/registry/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
  65. package/registry/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
  66. package/registry/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
  67. package/registry/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
  68. package/registry/nocobase-ai/demo/configuration-gate.tsx +49 -0
  69. package/registry/nocobase-ai/demo/container-showcase.tsx +210 -0
  70. package/registry/nocobase-ai/demo/floating.tsx +134 -0
  71. package/registry/nocobase-ai/demo/index.tsx +346 -0
  72. package/registry/nocobase-ai/demo/interaction-showcase.tsx +151 -0
  73. package/registry/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
  74. package/registry/nocobase-ai/demo/page-context.tsx +884 -0
  75. package/registry/nocobase-ai/demo/page-element-showcase.tsx +197 -0
  76. package/registry/nocobase-ai/demo/prompt-card.tsx +41 -0
  77. package/registry/nocobase-ai/demo/prompt-generator.tsx +452 -0
  78. package/registry/nocobase-ai/demo/shortcut.tsx +1207 -0
  79. package/registry/nocobase-ai/demo/tool-cards.tsx +530 -0
  80. package/registry/nocobase-ai/extension.tsx +158 -0
  81. package/registry/nocobase-ai/global-ai-chat.tsx +213 -0
  82. package/registry/nocobase-ai/index.ts +3 -0
  83. package/registry/nocobase-ai/providers/ai-provider.tsx +323 -0
  84. package/registry/nocobase-ai/providers/avatars.ts +817 -0
  85. package/registry/nocobase-ai/providers/chat-context.tsx +109 -0
  86. package/registry/nocobase-ai/providers/chat-controller.ts +84 -0
  87. package/registry/nocobase-ai/providers/chat-message-utils.ts +198 -0
  88. package/registry/nocobase-ai/providers/chat-provider.tsx +871 -0
  89. package/registry/nocobase-ai/providers/chat-reducer.ts +147 -0
  90. package/registry/nocobase-ai/providers/chat-task-utils.ts +105 -0
  91. package/registry/nocobase-ai/providers/chat-transport.ts +184 -0
  92. package/registry/nocobase-ai/providers/form-registry.tsx +271 -0
  93. package/registry/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
  94. package/registry/nocobase-ai/providers/index.ts +61 -0
  95. package/registry/nocobase-ai/providers/model.ts +32 -0
  96. package/registry/nocobase-ai/providers/page-context.tsx +136 -0
  97. package/registry/nocobase-ai/providers/stream-coalescer.ts +60 -0
  98. package/registry/nocobase-ai/providers/stream-event-utils.ts +82 -0
  99. package/registry/nocobase-ai/providers/stream-parser.ts +61 -0
  100. package/registry/nocobase-ai/providers/sub-agent-stream.ts +316 -0
  101. package/registry/nocobase-ai/providers/types.ts +183 -0
  102. package/registry/nocobase-ai/providers/ui-message-stream.ts +463 -0
  103. package/registry/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
  104. package/registry/nocobase-ai/providers/use-chat-attachments.ts +206 -0
  105. package/registry/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
  106. package/registry/nocobase-ai/providers/use-chat-runtime.ts +210 -0
  107. package/registry/nocobase-ai/providers/use-chat-work-context.ts +93 -0
  108. package/registry/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
  109. package/registry/nocobase-ai/providers/use-conversation-history.ts +178 -0
  110. package/registry/nocobase-ai/services/index.ts +7 -0
  111. package/registry/nocobase-ai/services/nocobase-ai-service.ts +454 -0
  112. package/registry/nocobase-ai/services/types.ts +82 -0
  113. package/registry/nocobase-ai/tests/business-report-regression.mjs +70 -0
  114. package/registry/nocobase-ai/tests/form-filler-regression.mjs +129 -0
  115. package/registry/nocobase-ai/tests/frontend-tool-regression.mjs +128 -0
  116. package/registry/nocobase-ai/tests/page-context-regression.mjs +48 -0
  117. package/registry/nocobase-ai/tests/stream-regression.mjs +207 -0
  118. package/registry/nocobase-i18n/README.md +53 -0
  119. package/registry/nocobase-i18n/components/index.ts +1 -0
  120. package/registry/nocobase-i18n/components/language-switcher.tsx +138 -0
  121. package/registry/nocobase-i18n/demo/index.tsx +209 -0
  122. package/registry/nocobase-i18n/demo/prompt-generator.tsx +155 -0
  123. package/registry/nocobase-i18n/extension.tsx +46 -0
  124. package/registry/nocobase-i18n/i18n-provider.ts +9 -0
  125. package/registry/nocobase-i18n/index.ts +6 -0
  126. package/registry/nocobase-i18n/locale-store.ts +134 -0
  127. package/registry/nocobase-i18n/locales/en-US.ts +89 -0
  128. package/registry/nocobase-i18n/locales/zh-CN.ts +85 -0
  129. package/registry/nocobase-i18n/provider.tsx +32 -0
  130. package/registry/nocobase-i18n/runtime.ts +118 -0
  131. package/registry/nocobase-i18n/server-resources.ts +184 -0
  132. package/registry/nocobase-i18n/tests/i18n-regression.mjs +42 -0
  133. package/registry.config.json +163 -0
  134. package/scripts/registry.mjs +164 -0
  135. package/src/App.css +95 -0
  136. package/src/App.tsx +87 -69
  137. package/src/app/extension.ts +11 -0
  138. package/src/app/extensions.tsx +49 -0
  139. package/src/components/access-control/access-denied.tsx +30 -0
  140. package/src/components/access-control/acl-bootstrap.tsx +9 -0
  141. package/src/components/access-control/acl-gate.tsx +36 -0
  142. package/src/components/access-control/navigate-to-accessible-resource.tsx +17 -0
  143. package/src/components/access-control/resource-access-guard.tsx +21 -0
  144. package/src/components/access-control/use-acl-runtime.ts +28 -0
  145. package/src/components/app-shell/breadcrumb.tsx +39 -3
  146. package/src/components/app-shell/document-title-handler.tsx +11 -20
  147. package/src/components/app-shell/header.tsx +22 -4
  148. package/src/components/app-shell/loading-overlay.tsx +2 -4
  149. package/src/components/app-shell/loading-state.tsx +15 -0
  150. package/src/components/app-shell/sidebar.tsx +103 -45
  151. package/src/components/data-table/data-table-pagination.tsx +27 -7
  152. package/src/components/data-table/data-table-styles.ts +57 -0
  153. package/src/components/data-table/data-table.tsx +14 -59
  154. package/src/components/resources/resource-label.ts +101 -0
  155. package/src/components/resources/views/create-view.tsx +17 -14
  156. package/src/components/resources/views/edit-view.tsx +17 -15
  157. package/src/components/resources/views/list-view.tsx +20 -10
  158. package/src/components/resources/views/show-view.tsx +20 -18
  159. package/src/index.tsx +2 -3
  160. package/src/lib/i18n.ts +161 -0
  161. package/src/lib/nocobase/acl/action.ts +147 -0
  162. package/src/lib/nocobase/acl/data-source.ts +24 -0
  163. package/src/lib/nocobase/acl/index.ts +6 -0
  164. package/src/lib/nocobase/acl/menu.ts +41 -0
  165. package/src/lib/nocobase/acl/record-permissions.ts +111 -0
  166. package/src/lib/nocobase/acl/store.ts +182 -0
  167. package/src/lib/nocobase/acl/types.ts +96 -0
  168. package/src/lib/nocobase/client.ts +279 -0
  169. package/src/lib/nocobase/error.ts +32 -0
  170. package/src/locales/en-US.ts +82 -0
  171. package/src/locales/index.ts +13 -0
  172. package/src/locales/zh-CN.ts +79 -0
  173. package/src/pages/users/create.tsx +98 -0
  174. package/src/pages/users/edit.tsx +92 -0
  175. package/src/pages/users/form-context.ts +57 -0
  176. package/src/pages/users/form-fields.tsx +211 -0
  177. package/src/pages/users/list.tsx +205 -0
  178. package/src/pages/users/show.tsx +187 -0
  179. package/src/pages/users/types.ts +20 -0
  180. package/src/providers/access-control.ts +34 -0
  181. package/src/providers/auth.ts +74 -60
  182. package/src/providers/constants.ts +3 -37
  183. package/src/providers/data.ts +242 -85
  184. package/vite.config.ts +17 -0
  185. package/src/pages/blog-posts/create.tsx +0 -177
  186. package/src/pages/blog-posts/edit.tsx +0 -197
  187. package/src/pages/blog-posts/list.tsx +0 -131
  188. package/src/pages/blog-posts/show.tsx +0 -81
  189. package/src/pages/categories/create.tsx +0 -73
  190. package/src/pages/categories/edit.tsx +0 -73
  191. package/src/pages/categories/index.ts +0 -4
  192. package/src/pages/categories/list.tsx +0 -86
  193. package/src/pages/categories/show.tsx +0 -37
  194. /package/src/pages/{blog-posts → users}/index.ts +0 -0
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "vite";
3
+
4
+ const server = await createServer({
5
+ appType: "custom",
6
+ logLevel: "silent",
7
+ server: { middlewareMode: true },
8
+ });
9
+
10
+ try {
11
+ const { AIFormRegistry, createFormFillerInvoker } =
12
+ await server.ssrLoadModule(
13
+ "/registry/nocobase-ai/providers/form-registry.tsx"
14
+ );
15
+
16
+ const registry = new AIFormRegistry();
17
+ const applied = [];
18
+ const unregister = registry.register({
19
+ id: "lead-form",
20
+ title: "Lead form",
21
+ fields: [
22
+ { name: "company", type: "string" },
23
+ {
24
+ name: "priority",
25
+ type: "string",
26
+ enum: ["normal", "high"],
27
+ },
28
+ { name: "owner", type: "string", readonly: true },
29
+ ],
30
+ getValues: () => ({}),
31
+ setValues: (values) => applied.push(values),
32
+ });
33
+ const invoke = createFormFillerInvoker(registry);
34
+
35
+ const allowedContext = { allowedFormIds: ["lead-form"] };
36
+ assert.deepEqual(
37
+ await invoke(
38
+ {
39
+ form: "lead-form",
40
+ data: { company: "Acme", priority: "high" },
41
+ },
42
+ allowedContext
43
+ ),
44
+ {
45
+ status: "success",
46
+ content:
47
+ 'Filled "Lead form". Please review the values and submit the form manually.',
48
+ appliedFields: ["company", "priority"],
49
+ skippedFields: [],
50
+ }
51
+ );
52
+ assert.deepEqual(applied, [{ company: "Acme", priority: "high" }]);
53
+
54
+ assert.deepEqual(
55
+ await invoke(
56
+ {
57
+ form: "lead-form",
58
+ data: { company: 42, owner: "Ada", unknown: true },
59
+ },
60
+ allowedContext
61
+ ),
62
+ {
63
+ status: "error",
64
+ content: 'No valid editable fields were provided for "Lead form".',
65
+ appliedFields: [],
66
+ skippedFields: [
67
+ {
68
+ name: "company",
69
+ reason: "invalid",
70
+ message: "Expected a string.",
71
+ },
72
+ {
73
+ name: "owner",
74
+ reason: "readonly",
75
+ message: "This field is read-only.",
76
+ },
77
+ {
78
+ name: "unknown",
79
+ reason: "undeclared",
80
+ message: "This field is not declared by the target form.",
81
+ },
82
+ ],
83
+ }
84
+ );
85
+
86
+ assert.throws(
87
+ () =>
88
+ registry.register({
89
+ id: "lead-form",
90
+ title: "Duplicate lead form",
91
+ fields: [],
92
+ getValues: () => ({}),
93
+ setValues: () => undefined,
94
+ }),
95
+ /already registered/
96
+ );
97
+
98
+ unregister();
99
+ assert.deepEqual(
100
+ await invoke(
101
+ { form: "lead-form", data: { company: "Acme" } },
102
+ allowedContext
103
+ ),
104
+ {
105
+ status: "error",
106
+ content: 'The target form "lead-form" is not available on this page.',
107
+ appliedFields: [],
108
+ skippedFields: [],
109
+ }
110
+ );
111
+
112
+ assert.deepEqual(
113
+ await invoke(
114
+ { form: "lead-form", data: { company: "Acme" } },
115
+ { allowedFormIds: [] }
116
+ ),
117
+ {
118
+ status: "error",
119
+ content:
120
+ 'The target form "lead-form" is not available in this conversation context.',
121
+ appliedFields: [],
122
+ skippedFields: [],
123
+ }
124
+ );
125
+
126
+ console.log("AI Form filler regression tests passed");
127
+ } finally {
128
+ await server.close();
129
+ }
@@ -0,0 +1,128 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "vite";
3
+
4
+ const server = await createServer({
5
+ appType: "custom",
6
+ logLevel: "silent",
7
+ server: { middlewareMode: true },
8
+ });
9
+
10
+ try {
11
+ const { AIFrontendToolRegistry, createFrontendToolInvokers } =
12
+ await server.ssrLoadModule(
13
+ "/registry/nocobase-ai/providers/frontend-tool-registry.tsx"
14
+ );
15
+
16
+ const registry = new AIFrontendToolRegistry();
17
+ const calls = [];
18
+ const unregister = registry.register("quote-card", {
19
+ name: "update_quote_discount",
20
+ description: "Update the quote discount preview.",
21
+ permission: "ASK",
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: { discountPercent: { type: "number" } },
25
+ },
26
+ execute: (args) => {
27
+ calls.push(args);
28
+ return { updated: true };
29
+ },
30
+ });
31
+ const [manifest] = registry.list("quote-card");
32
+ assert.deepEqual(manifest, {
33
+ id: "quote-card:update_quote_discount",
34
+ blockUid: "quote-card",
35
+ name: "update_quote_discount",
36
+ title: undefined,
37
+ description: "Update the quote discount preview.",
38
+ permission: "ASK",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: { discountPercent: { type: "number" } },
42
+ },
43
+ });
44
+
45
+ const invokers = createFrontendToolInvokers(registry);
46
+ const allowedContext = { allowedFrontendToolIds: [manifest.id] };
47
+ assert.deepEqual(
48
+ await invokers.loadFrontendTool({ toolId: manifest.id }, allowedContext),
49
+ manifest
50
+ );
51
+ assert.deepEqual(
52
+ await invokers.executeFrontendTool(
53
+ { toolId: manifest.id, args: { discountPercent: 12 } },
54
+ allowedContext
55
+ ),
56
+ { updated: true }
57
+ );
58
+ assert.deepEqual(calls, [{ discountPercent: 12 }]);
59
+
60
+ assert.throws(
61
+ () =>
62
+ registry.register("quote-card", {
63
+ name: "update_quote_discount",
64
+ description: "Duplicate Tool",
65
+ execute: () => undefined,
66
+ }),
67
+ /already registered/
68
+ );
69
+ assert.throws(
70
+ () =>
71
+ registry.register("quote-card", {
72
+ name: "invalid_schema",
73
+ description: "Invalid schema",
74
+ inputSchema: [],
75
+ execute: () => undefined,
76
+ }),
77
+ /inputSchema must be an object/
78
+ );
79
+
80
+ const unregisterInvalidResult = registry.register("quote-card", {
81
+ name: "invalid_result",
82
+ description: "Return an invalid result.",
83
+ execute: () => ({ value: 1n }),
84
+ });
85
+ assert.deepEqual(
86
+ await invokers.executeFrontendTool(
87
+ { toolId: "quote-card:invalid_result", args: {} },
88
+ {
89
+ allowedFrontendToolIds: ["quote-card:invalid_result"],
90
+ }
91
+ ),
92
+ {
93
+ status: "error",
94
+ content:
95
+ 'Frontend Tool "quote-card:invalid_result" returned a non-serializable result',
96
+ }
97
+ );
98
+ unregisterInvalidResult();
99
+
100
+ unregister();
101
+ assert.deepEqual(
102
+ await invokers.executeFrontendTool(
103
+ { toolId: manifest.id, args: {} },
104
+ allowedContext
105
+ ),
106
+ {
107
+ status: "error",
108
+ content:
109
+ 'Frontend Tool "quote-card:update_quote_discount" is unavailable',
110
+ }
111
+ );
112
+
113
+ assert.deepEqual(
114
+ await invokers.executeFrontendTool(
115
+ { toolId: manifest.id, args: {} },
116
+ { allowedFrontendToolIds: [] }
117
+ ),
118
+ {
119
+ status: "error",
120
+ content:
121
+ 'Frontend Tool "quote-card:update_quote_discount" is not available in this conversation context',
122
+ }
123
+ );
124
+
125
+ console.log("AI frontend Tool regression tests passed");
126
+ } finally {
127
+ await server.close();
128
+ }
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "vite";
3
+
4
+ const server = await createServer({
5
+ appType: "custom",
6
+ logLevel: "silent",
7
+ server: { middlewareMode: true },
8
+ });
9
+
10
+ try {
11
+ const {
12
+ createAIPageContextReference,
13
+ getAIWorkContextRequiredTools,
14
+ mergeAIRequiredTools,
15
+ } = await server.ssrLoadModule(
16
+ "/registry/nocobase-ai/providers/page-context.tsx"
17
+ );
18
+
19
+ const formContext = createAIPageContextReference({
20
+ id: "lead-form",
21
+ title: "Lead form",
22
+ kind: "form",
23
+ });
24
+ assert.deepEqual(formContext, {
25
+ type: "page-element",
26
+ id: "lead-form",
27
+ title: "Lead form",
28
+ kind: "form",
29
+ });
30
+ assert.deepEqual(getAIWorkContextRequiredTools([formContext]), [
31
+ "formFiller",
32
+ ]);
33
+ assert.deepEqual(
34
+ mergeAIRequiredTools(
35
+ { skills: ["lead-review"], tools: ["inspect-record"] },
36
+ ["formFiller", "inspect-record"]
37
+ ),
38
+ {
39
+ skills: ["lead-review"],
40
+ tools: ["inspect-record", "formFiller"],
41
+ }
42
+ );
43
+ assert.equal(mergeAIRequiredTools(undefined, []), undefined);
44
+
45
+ console.log("AI page context regression tests passed");
46
+ } finally {
47
+ await server.close();
48
+ }
@@ -0,0 +1,207 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "vite";
3
+
4
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+
6
+ const encodeSSE = (events) => {
7
+ const encoder = new TextEncoder();
8
+ return new ReadableStream({
9
+ start(controller) {
10
+ controller.enqueue(
11
+ encoder.encode(
12
+ events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")
13
+ )
14
+ );
15
+ controller.close();
16
+ },
17
+ });
18
+ };
19
+
20
+ const collectStream = async (stream) => {
21
+ const result = [];
22
+ for await (const chunk of stream) result.push(chunk);
23
+ return result;
24
+ };
25
+
26
+ const server = await createServer({
27
+ appType: "custom",
28
+ logLevel: "silent",
29
+ server: { middlewareMode: true },
30
+ });
31
+
32
+ try {
33
+ const { StreamCoalescer } = await server.ssrLoadModule(
34
+ "/registry/nocobase-ai/providers/stream-coalescer.ts"
35
+ );
36
+ const { NocoBaseChatTransport } = await server.ssrLoadModule(
37
+ "/registry/nocobase-ai/providers/chat-transport.ts"
38
+ );
39
+ const { SubAgentStreamAccumulator } = await server.ssrLoadModule(
40
+ "/registry/nocobase-ai/providers/sub-agent-stream.ts"
41
+ );
42
+ const { NocoBaseClient } = await server.ssrLoadModule(
43
+ "/src/lib/nocobase/client.ts"
44
+ );
45
+
46
+ {
47
+ const client = new NocoBaseClient("http://localhost:13001/api");
48
+ assert.equal(
49
+ client.resolveUrl("/files/main/main/aiFiles/5.png?preview=1"),
50
+ "http://localhost:13001/files/main/main/aiFiles/5.png?preview=1"
51
+ );
52
+ assert.equal(
53
+ client.resolveUrl("https://cdn.example.com/file.png"),
54
+ "https://cdn.example.com/file.png"
55
+ );
56
+ }
57
+
58
+ {
59
+ const flushed = [];
60
+ const coalescer = new StreamCoalescer({
61
+ interval: 20,
62
+ maxSize: 100,
63
+ getSize: (value) => value.length,
64
+ merge: (current, incoming) => current + incoming,
65
+ onFlush: (key, value) => flushed.push([key, value]),
66
+ });
67
+ coalescer.push("message", "hello ");
68
+ coalescer.push("message", "world");
69
+ await wait(35);
70
+ assert.deepEqual(flushed, [["message", "hello world"]]);
71
+ }
72
+
73
+ {
74
+ const seedMessage = {
75
+ id: "assistant-existing",
76
+ role: "assistant",
77
+ parts: [
78
+ {
79
+ type: "data-subAgent",
80
+ id: "sub-1",
81
+ data: {
82
+ sessionId: "sub-1",
83
+ username: "viz",
84
+ status: "pending",
85
+ messages: [
86
+ {
87
+ id: "sub-message",
88
+ role: "assistant",
89
+ parts: [{ type: "text", text: "saved ", state: "done" }],
90
+ },
91
+ ],
92
+ },
93
+ },
94
+ ],
95
+ };
96
+ const accumulator = new SubAgentStreamAccumulator(seedMessage);
97
+ const [chunk] = accumulator.process({
98
+ type: "content",
99
+ body: "tail",
100
+ from: "sub-agent",
101
+ sessionId: "sub-1",
102
+ username: "viz",
103
+ });
104
+ assert.equal(chunk.data.messages.length, 1);
105
+ assert.equal(chunk.data.messages[0].parts[0].text, "saved tail");
106
+ }
107
+
108
+ {
109
+ const toolCallId = "report-tool";
110
+ const events = [
111
+ ...Array.from({ length: 100 }, (_, index) => ({
112
+ type: "tool_call_chunks",
113
+ from: "main-agent",
114
+ sessionId: "conversation-1",
115
+ body: [
116
+ index === 0
117
+ ? {
118
+ id: toolCallId,
119
+ name: "businessReportGenerator",
120
+ args: "{",
121
+ }
122
+ : { id: toolCallId, args: "x" },
123
+ ],
124
+ })),
125
+ { type: "stream_end", from: "main-agent", sessionId: "conversation-1" },
126
+ ];
127
+ const service = {
128
+ resumeConversationStream: async () => encodeSSE(events),
129
+ };
130
+ const transport = new NocoBaseChatTransport({
131
+ service,
132
+ getContext: () => ({
133
+ sessionId: "conversation-1",
134
+ employee: { username: "atlas", nickname: "Atlas" },
135
+ model: { value: "test", label: "Test" },
136
+ }),
137
+ });
138
+ transport.prepareConversationResume([
139
+ { id: "assistant-existing", role: "assistant", parts: [] },
140
+ ]);
141
+ const chunks = await collectStream(await transport.reconnectToStream());
142
+ const start = chunks.find((chunk) => chunk.type === "start");
143
+ const toolDeltas = chunks.filter(
144
+ (chunk) => chunk.type === "tool-input-delta"
145
+ );
146
+ assert.equal(start.messageId, undefined);
147
+ assert.ok(toolDeltas.length < 10, `received ${toolDeltas.length} deltas`);
148
+ assert.equal(
149
+ toolDeltas.reduce((size, chunk) => size + chunk.inputTextDelta.length, 0),
150
+ 100
151
+ );
152
+ }
153
+
154
+ {
155
+ let request;
156
+ const service = {
157
+ sendMessagesStream: async (input) => {
158
+ request = input;
159
+ return encodeSSE([
160
+ { type: "content", body: "ok", from: "main-agent" },
161
+ { type: "stream_end", from: "main-agent" },
162
+ ]);
163
+ },
164
+ };
165
+ const transport = new NocoBaseChatTransport({
166
+ service,
167
+ getContext: () => ({
168
+ sessionId: "conversation-context",
169
+ employee: { username: "mira", nickname: "Mira" },
170
+ model: { value: "test", label: "Test" },
171
+ }),
172
+ });
173
+ await collectStream(
174
+ await transport.sendMessages({
175
+ messages: [
176
+ {
177
+ id: "user-context",
178
+ role: "user",
179
+ metadata: {
180
+ workContext: [
181
+ {
182
+ type: "page-element",
183
+ id: "customer-form",
184
+ title: "Customer form",
185
+ content: { values: { name: "Northwind" } },
186
+ },
187
+ ],
188
+ },
189
+ parts: [{ type: "text", text: "Review this form" }],
190
+ },
191
+ ],
192
+ })
193
+ );
194
+ assert.deepEqual(request.messages[0].workContext, [
195
+ {
196
+ type: "page-element",
197
+ id: "customer-form",
198
+ title: "Customer form",
199
+ content: { values: { name: "Northwind" } },
200
+ },
201
+ ]);
202
+ }
203
+
204
+ console.log("AI stream regression tests passed");
205
+ } finally {
206
+ await server.close();
207
+ }
@@ -0,0 +1,53 @@
1
+ # NocoBase i18n
2
+
3
+ Optional frontend-owned internationalization for the NocoBase Admin Starter.
4
+
5
+ The Registry connects i18next to the application translation provider, persists
6
+ the selected locale through the Starter's shared NocoBase client, sends it as
7
+ `X-Locale`, and adds a reusable language switcher to the signed-in user menu.
8
+ Its integrated Demo route shows the complete configuration model without
9
+ requiring a second Registry item.
10
+
11
+ Starter and Registry UI resources remain frontend-owned. At startup the runtime
12
+ reads `enabledLanguages` from `systemSettings:get` and merges only registered
13
+ dynamic NocoBase namespaces from `app:getLang`; `lm-collections` is registered
14
+ by default so collection and field translations remain compatible.
15
+
16
+ Application-owned React translations belong in `src/locales`, outside the
17
+ installed Registry directory. Register them through the Starter-level
18
+ `registerTranslationResources` helper so the application remains buildable when
19
+ the optional i18n Registry is not installed. When this Registry is present, it
20
+ consumes both resources registered before startup and resources added later by
21
+ lazy features.
22
+
23
+ On NocoBase versions that support filtered language resources, the request uses
24
+ `app:getLang?ns=lm-collections,...`. Older servers ignore the query parameter;
25
+ the client still merges only registered namespaces, so the integration remains
26
+ backward compatible.
27
+
28
+ Other installed components can register their own namespace without changing
29
+ the runtime:
30
+
31
+ ```ts
32
+ import { registerLocaleResources } from "@/extensions/nocobase-i18n";
33
+
34
+ registerLocaleResources("my-extension", {
35
+ "en-US": { title: "Orders" },
36
+ "zh-CN": { title: "订单" },
37
+ });
38
+ ```
39
+
40
+ If a component relies on another server-generated namespace, opt in explicitly:
41
+
42
+ ```ts
43
+ import { registerServerResourceNamespace } from "@/extensions/nocobase-i18n";
44
+
45
+ registerServerResourceNamespace("my-dynamic-namespace");
46
+ ```
47
+
48
+ Namespaces registered after application startup are loaded incrementally, so
49
+ lazy Registry components do not need to participate in the initial request.
50
+
51
+ Use the application's `useTranslate` hook in React components. Existing exact
52
+ NocoBase-style expressions such as `{{t("Orders")}}` and expressions with a
53
+ string namespace are resolved through the Starter compatibility helper.
@@ -0,0 +1 @@
1
+ export * from "./language-switcher";
@@ -0,0 +1,138 @@
1
+ import { useGetLocale, useSetLocale, useTranslate } from "@refinedev/core";
2
+ import { Languages, Loader2 } from "lucide-react";
3
+ import { useState, type ReactNode } from "react";
4
+
5
+ import {
6
+ DropdownMenuRadioGroup,
7
+ DropdownMenuRadioItem,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuSub,
10
+ DropdownMenuSubContent,
11
+ DropdownMenuSubTrigger,
12
+ } from "@/components/ui/dropdown-menu";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "@/components/ui/select";
20
+ import { cn } from "@/lib/utils";
21
+ import { useEnabledLocales } from "../locale-store";
22
+
23
+ export type LanguageSwitcherProps = {
24
+ className?: string;
25
+ label?: ReactNode | false;
26
+ triggerClassName?: string;
27
+ };
28
+
29
+ export function LanguageSwitcher({
30
+ className,
31
+ label,
32
+ triggerClassName,
33
+ }: LanguageSwitcherProps) {
34
+ const translate = useTranslate();
35
+ const getLocale = useGetLocale();
36
+ const setLocale = useSetLocale();
37
+ const locales = useEnabledLocales();
38
+ const currentLocale = getLocale();
39
+ const [switching, setSwitching] = useState(false);
40
+ const [error, setError] = useState<string>();
41
+ const resolvedLabel =
42
+ typeof label === "undefined"
43
+ ? translate("language.label", { ns: "nocobase-i18n" }, "Language")
44
+ : label;
45
+
46
+ if (locales.length < 2) return null;
47
+
48
+ const currentDefinition =
49
+ locales.find(({ locale }) => locale === currentLocale) ?? locales[0];
50
+
51
+ return (
52
+ <div className={cn("space-y-2", className)}>
53
+ {resolvedLabel === false ? null : (
54
+ <p className="text-xs font-medium text-muted-foreground">
55
+ {resolvedLabel}
56
+ </p>
57
+ )}
58
+ <Select
59
+ value={currentLocale}
60
+ disabled={switching}
61
+ onValueChange={(value) => {
62
+ if (!value || value === currentLocale) return;
63
+ setSwitching(true);
64
+ setError(undefined);
65
+ Promise.resolve(setLocale(value)).catch((reason) => {
66
+ setError(
67
+ reason instanceof Error
68
+ ? reason.message
69
+ : translate(
70
+ "language.switchError",
71
+ { ns: "nocobase-i18n" },
72
+ "Unable to switch language."
73
+ )
74
+ );
75
+ setSwitching(false);
76
+ });
77
+ }}
78
+ >
79
+ <SelectTrigger
80
+ className={cn("w-full min-w-52", triggerClassName)}
81
+ aria-label={String(resolvedLabel || "Language")}
82
+ >
83
+ {switching ? <Loader2 className="animate-spin" /> : <Languages />}
84
+ <SelectValue>{currentDefinition?.label ?? currentLocale}</SelectValue>
85
+ </SelectTrigger>
86
+ <SelectContent>
87
+ {locales.map((definition) => (
88
+ <SelectItem key={definition.locale} value={definition.locale}>
89
+ {definition.label}
90
+ </SelectItem>
91
+ ))}
92
+ </SelectContent>
93
+ </Select>
94
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function LanguageUserMenuItems() {
100
+ const translate = useTranslate();
101
+ const getLocale = useGetLocale();
102
+ const setLocale = useSetLocale();
103
+ const locales = useEnabledLocales();
104
+ const currentLocale = getLocale();
105
+
106
+ if (locales.length < 2) return null;
107
+
108
+ return (
109
+ <>
110
+ <DropdownMenuSeparator />
111
+ <DropdownMenuSub>
112
+ <DropdownMenuSubTrigger className="min-h-9 gap-2 px-2 text-muted-foreground focus:text-foreground">
113
+ <Languages />
114
+ <span>
115
+ {translate("language.label", { ns: "nocobase-i18n" }, "Language")}
116
+ </span>
117
+ </DropdownMenuSubTrigger>
118
+ <DropdownMenuSubContent className="min-w-44">
119
+ <DropdownMenuRadioGroup
120
+ value={currentLocale}
121
+ onValueChange={(value) => {
122
+ if (value && value !== currentLocale) void setLocale(value);
123
+ }}
124
+ >
125
+ {locales.map((definition) => (
126
+ <DropdownMenuRadioItem
127
+ key={definition.locale}
128
+ value={definition.locale}
129
+ >
130
+ {definition.label}
131
+ </DropdownMenuRadioItem>
132
+ ))}
133
+ </DropdownMenuRadioGroup>
134
+ </DropdownMenuSubContent>
135
+ </DropdownMenuSub>
136
+ </>
137
+ );
138
+ }