@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.
- package/README.MD +90 -1
- package/components.json +3 -1
- package/package.json +21 -5
- package/registry/nocobase-acl/README.md +19 -0
- package/registry/nocobase-acl/components/acl-boundary.tsx +106 -0
- package/registry/nocobase-acl/components/index.ts +3 -0
- package/registry/nocobase-acl/components/role-options.ts +36 -0
- package/registry/nocobase-acl/components/role-switcher.tsx +135 -0
- package/registry/nocobase-acl/demo/boundary-api.tsx +186 -0
- package/registry/nocobase-acl/demo/components.tsx +231 -0
- package/registry/nocobase-acl/demo/index.tsx +505 -0
- package/registry/nocobase-acl/demo/lazy-route.tsx +10 -0
- package/registry/nocobase-acl/demo/prompt-generator.tsx +134 -0
- package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +198 -0
- package/registry/nocobase-acl/demo/scenario-section.tsx +37 -0
- package/registry/nocobase-acl/extension.tsx +71 -0
- package/registry/nocobase-acl/index.ts +1 -0
- package/registry/nocobase-acl/tests/acl-regression.mjs +314 -0
- package/registry/nocobase-ai/README.md +70 -0
- package/registry/nocobase-ai/adapters/react-hook-form.ts +20 -0
- package/registry/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
- package/registry/nocobase-ai/components/ai-root-provider.tsx +32 -0
- package/registry/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
- package/registry/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
- package/registry/nocobase-ai/components/chat/chat-compact.tsx +90 -0
- package/registry/nocobase-ai/components/chat/chat-composer.tsx +429 -0
- package/registry/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
- package/registry/nocobase-ai/components/chat/chat-header.tsx +88 -0
- package/registry/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
- package/registry/nocobase-ai/components/chat/chat-message.tsx +252 -0
- package/registry/nocobase-ai/components/chat/chat-messages.tsx +173 -0
- package/registry/nocobase-ai/components/chat/chat-window.tsx +118 -0
- package/registry/nocobase-ai/components/chat/conversation-list.tsx +343 -0
- package/registry/nocobase-ai/components/chat/markdown-message.tsx +165 -0
- package/registry/nocobase-ai/components/chat/model-select-options.tsx +30 -0
- package/registry/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
- package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
- package/registry/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
- package/registry/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
- package/registry/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
- package/registry/nocobase-ai/components/index.ts +59 -0
- package/registry/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
- package/registry/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
- package/registry/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
- package/registry/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
- package/registry/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
- package/registry/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
- package/registry/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
- package/registry/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
- package/registry/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
- package/registry/nocobase-ai/components/tools/business-report-dialog.tsx +364 -0
- package/registry/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
- package/registry/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
- package/registry/nocobase-ai/components/tools/chart-renderer.tsx +99 -0
- package/registry/nocobase-ai/components/tools/echarts-preview.tsx +59 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
- package/registry/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
- package/registry/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
- package/registry/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
- package/registry/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
- package/registry/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
- package/registry/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
- package/registry/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
- package/registry/nocobase-ai/demo/configuration-gate.tsx +49 -0
- package/registry/nocobase-ai/demo/container-showcase.tsx +210 -0
- package/registry/nocobase-ai/demo/floating.tsx +134 -0
- package/registry/nocobase-ai/demo/index.tsx +346 -0
- package/registry/nocobase-ai/demo/interaction-showcase.tsx +151 -0
- package/registry/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
- package/registry/nocobase-ai/demo/page-context.tsx +884 -0
- package/registry/nocobase-ai/demo/page-element-showcase.tsx +197 -0
- package/registry/nocobase-ai/demo/prompt-card.tsx +41 -0
- package/registry/nocobase-ai/demo/prompt-generator.tsx +452 -0
- package/registry/nocobase-ai/demo/shortcut.tsx +1207 -0
- package/registry/nocobase-ai/demo/tool-cards.tsx +530 -0
- package/registry/nocobase-ai/extension.tsx +158 -0
- package/registry/nocobase-ai/global-ai-chat.tsx +213 -0
- package/registry/nocobase-ai/index.ts +3 -0
- package/registry/nocobase-ai/providers/ai-provider.tsx +323 -0
- package/registry/nocobase-ai/providers/avatars.ts +817 -0
- package/registry/nocobase-ai/providers/chat-context.tsx +109 -0
- package/registry/nocobase-ai/providers/chat-controller.ts +84 -0
- package/registry/nocobase-ai/providers/chat-message-utils.ts +198 -0
- package/registry/nocobase-ai/providers/chat-provider.tsx +871 -0
- package/registry/nocobase-ai/providers/chat-reducer.ts +147 -0
- package/registry/nocobase-ai/providers/chat-task-utils.ts +105 -0
- package/registry/nocobase-ai/providers/chat-transport.ts +184 -0
- package/registry/nocobase-ai/providers/form-registry.tsx +271 -0
- package/registry/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
- package/registry/nocobase-ai/providers/index.ts +61 -0
- package/registry/nocobase-ai/providers/model.ts +32 -0
- package/registry/nocobase-ai/providers/page-context.tsx +136 -0
- package/registry/nocobase-ai/providers/stream-coalescer.ts +60 -0
- package/registry/nocobase-ai/providers/stream-event-utils.ts +82 -0
- package/registry/nocobase-ai/providers/stream-parser.ts +61 -0
- package/registry/nocobase-ai/providers/sub-agent-stream.ts +316 -0
- package/registry/nocobase-ai/providers/types.ts +183 -0
- package/registry/nocobase-ai/providers/ui-message-stream.ts +463 -0
- package/registry/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
- package/registry/nocobase-ai/providers/use-chat-attachments.ts +206 -0
- package/registry/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
- package/registry/nocobase-ai/providers/use-chat-runtime.ts +210 -0
- package/registry/nocobase-ai/providers/use-chat-work-context.ts +93 -0
- package/registry/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
- package/registry/nocobase-ai/providers/use-conversation-history.ts +178 -0
- package/registry/nocobase-ai/services/index.ts +7 -0
- package/registry/nocobase-ai/services/nocobase-ai-service.ts +454 -0
- package/registry/nocobase-ai/services/types.ts +82 -0
- package/registry/nocobase-ai/tests/business-report-regression.mjs +70 -0
- package/registry/nocobase-ai/tests/form-filler-regression.mjs +129 -0
- package/registry/nocobase-ai/tests/frontend-tool-regression.mjs +128 -0
- package/registry/nocobase-ai/tests/page-context-regression.mjs +48 -0
- package/registry/nocobase-ai/tests/stream-regression.mjs +207 -0
- package/registry/nocobase-i18n/README.md +53 -0
- package/registry/nocobase-i18n/components/index.ts +1 -0
- package/registry/nocobase-i18n/components/language-switcher.tsx +138 -0
- package/registry/nocobase-i18n/demo/index.tsx +209 -0
- package/registry/nocobase-i18n/demo/prompt-generator.tsx +155 -0
- package/registry/nocobase-i18n/extension.tsx +46 -0
- package/registry/nocobase-i18n/i18n-provider.ts +9 -0
- package/registry/nocobase-i18n/index.ts +6 -0
- package/registry/nocobase-i18n/locale-store.ts +134 -0
- package/registry/nocobase-i18n/locales/en-US.ts +89 -0
- package/registry/nocobase-i18n/locales/zh-CN.ts +85 -0
- package/registry/nocobase-i18n/provider.tsx +32 -0
- package/registry/nocobase-i18n/runtime.ts +118 -0
- package/registry/nocobase-i18n/server-resources.ts +184 -0
- package/registry/nocobase-i18n/tests/i18n-regression.mjs +42 -0
- package/registry.config.json +163 -0
- package/scripts/registry.mjs +164 -0
- package/src/App.css +95 -0
- package/src/App.tsx +87 -69
- package/src/app/extension.ts +11 -0
- package/src/app/extensions.tsx +49 -0
- package/src/components/access-control/access-denied.tsx +30 -0
- package/src/components/access-control/acl-bootstrap.tsx +9 -0
- package/src/components/access-control/acl-gate.tsx +36 -0
- package/src/components/access-control/navigate-to-accessible-resource.tsx +17 -0
- package/src/components/access-control/resource-access-guard.tsx +21 -0
- package/src/components/access-control/use-acl-runtime.ts +28 -0
- package/src/components/app-shell/breadcrumb.tsx +39 -3
- package/src/components/app-shell/document-title-handler.tsx +11 -20
- package/src/components/app-shell/header.tsx +22 -4
- package/src/components/app-shell/loading-overlay.tsx +2 -4
- package/src/components/app-shell/loading-state.tsx +15 -0
- package/src/components/app-shell/sidebar.tsx +103 -45
- package/src/components/data-table/data-table-pagination.tsx +27 -7
- package/src/components/data-table/data-table-styles.ts +57 -0
- package/src/components/data-table/data-table.tsx +14 -59
- package/src/components/resources/resource-label.ts +101 -0
- package/src/components/resources/views/create-view.tsx +17 -14
- package/src/components/resources/views/edit-view.tsx +17 -15
- package/src/components/resources/views/list-view.tsx +20 -10
- package/src/components/resources/views/show-view.tsx +20 -18
- package/src/index.tsx +2 -3
- package/src/lib/i18n.ts +161 -0
- package/src/lib/nocobase/acl/action.ts +147 -0
- package/src/lib/nocobase/acl/data-source.ts +24 -0
- package/src/lib/nocobase/acl/index.ts +6 -0
- package/src/lib/nocobase/acl/menu.ts +41 -0
- package/src/lib/nocobase/acl/record-permissions.ts +111 -0
- package/src/lib/nocobase/acl/store.ts +182 -0
- package/src/lib/nocobase/acl/types.ts +96 -0
- package/src/lib/nocobase/client.ts +279 -0
- package/src/lib/nocobase/error.ts +32 -0
- package/src/locales/en-US.ts +82 -0
- package/src/locales/index.ts +13 -0
- package/src/locales/zh-CN.ts +79 -0
- package/src/pages/users/create.tsx +98 -0
- package/src/pages/users/edit.tsx +92 -0
- package/src/pages/users/form-context.ts +57 -0
- package/src/pages/users/form-fields.tsx +211 -0
- package/src/pages/users/list.tsx +205 -0
- package/src/pages/users/show.tsx +187 -0
- package/src/pages/users/types.ts +20 -0
- package/src/providers/access-control.ts +34 -0
- package/src/providers/auth.ts +74 -60
- package/src/providers/constants.ts +3 -37
- package/src/providers/data.ts +242 -85
- package/vite.config.ts +17 -0
- package/src/pages/blog-posts/create.tsx +0 -177
- package/src/pages/blog-posts/edit.tsx +0 -197
- package/src/pages/blog-posts/list.tsx +0 -131
- package/src/pages/blog-posts/show.tsx +0 -81
- package/src/pages/categories/create.tsx +0 -73
- package/src/pages/categories/edit.tsx +0 -73
- package/src/pages/categories/index.ts +0 -4
- package/src/pages/categories/list.tsx +0 -86
- package/src/pages/categories/show.tsx +0 -37
- /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
|
+
}
|