@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,147 @@
1
+ import { AI_DRAFT_CONVERSATION_ID, type AIConversation } from "./types";
2
+
3
+ export type AIChatState = {
4
+ activeConversationId: string;
5
+ conversations: AIConversation[];
6
+ conversationListOpen: boolean;
7
+ drafts: Record<string, string>;
8
+ selectedEmployeeUsername: string;
9
+ selectedModel: string;
10
+ };
11
+
12
+ export type AIChatAction =
13
+ | { type: "reset"; state: AIChatState }
14
+ | { type: "set-active-conversation"; conversationId: string }
15
+ | { type: "set-conversation-list-open"; open: boolean }
16
+ | { type: "set-draft"; conversationId: string; value: string }
17
+ | { type: "select-employee"; username: string }
18
+ | { type: "select-model"; model: string }
19
+ | { type: "start-new-conversation" }
20
+ | { type: "add-conversation"; conversation: AIConversation }
21
+ | { type: "set-conversations"; conversations: AIConversation[] }
22
+ | { type: "remove-conversation"; conversationId: string }
23
+ | { type: "mark-conversation-read"; conversationId: string }
24
+ | { type: "rename-conversation"; conversationId: string; title: string }
25
+ | { type: "replace-conversation-id"; from: string; to: string };
26
+
27
+ export function createAIChatState({
28
+ conversations,
29
+ employeeUsername,
30
+ model,
31
+ }: {
32
+ conversations: AIConversation[];
33
+ employeeUsername: string;
34
+ model: string;
35
+ }): AIChatState {
36
+ return {
37
+ activeConversationId: conversations[0]?.id ?? AI_DRAFT_CONVERSATION_ID,
38
+ conversations,
39
+ conversationListOpen: false,
40
+ drafts: {},
41
+ selectedEmployeeUsername: employeeUsername,
42
+ selectedModel: model,
43
+ };
44
+ }
45
+
46
+ export function aiChatReducer(
47
+ state: AIChatState,
48
+ action: AIChatAction
49
+ ): AIChatState {
50
+ switch (action.type) {
51
+ case "reset":
52
+ return action.state;
53
+ case "set-active-conversation":
54
+ return {
55
+ ...state,
56
+ activeConversationId: action.conversationId,
57
+ conversationListOpen: false,
58
+ conversations: state.conversations.map((conversation) =>
59
+ conversation.id === action.conversationId
60
+ ? { ...conversation, unread: false }
61
+ : conversation
62
+ ),
63
+ };
64
+ case "set-conversation-list-open":
65
+ return { ...state, conversationListOpen: action.open };
66
+ case "set-draft":
67
+ return {
68
+ ...state,
69
+ drafts: { ...state.drafts, [action.conversationId]: action.value },
70
+ };
71
+ case "select-employee":
72
+ return { ...state, selectedEmployeeUsername: action.username };
73
+ case "select-model":
74
+ return { ...state, selectedModel: action.model };
75
+ case "start-new-conversation":
76
+ return {
77
+ ...state,
78
+ activeConversationId: AI_DRAFT_CONVERSATION_ID,
79
+ conversationListOpen: false,
80
+ drafts: { ...state.drafts, [AI_DRAFT_CONVERSATION_ID]: "" },
81
+ };
82
+ case "add-conversation":
83
+ return {
84
+ ...state,
85
+ activeConversationId: action.conversation.id,
86
+ conversations: [
87
+ action.conversation,
88
+ ...state.conversations.filter(
89
+ (conversation) => conversation.id !== action.conversation.id
90
+ ),
91
+ ],
92
+ drafts: {
93
+ ...state.drafts,
94
+ [action.conversation.id]:
95
+ state.drafts[AI_DRAFT_CONVERSATION_ID] ?? "",
96
+ },
97
+ };
98
+ case "set-conversations":
99
+ return {
100
+ ...state,
101
+ conversations: action.conversations,
102
+ };
103
+ case "mark-conversation-read":
104
+ return {
105
+ ...state,
106
+ conversations: state.conversations.map((conversation) =>
107
+ conversation.id === action.conversationId
108
+ ? { ...conversation, unread: false }
109
+ : conversation
110
+ ),
111
+ };
112
+ case "remove-conversation": {
113
+ const conversations = state.conversations.filter(
114
+ (conversation) => conversation.id !== action.conversationId
115
+ );
116
+ return { ...state, conversations };
117
+ }
118
+ case "rename-conversation":
119
+ return {
120
+ ...state,
121
+ conversations: state.conversations.map((conversation) =>
122
+ conversation.id === action.conversationId
123
+ ? { ...conversation, title: action.title }
124
+ : conversation
125
+ ),
126
+ };
127
+ case "replace-conversation-id":
128
+ return {
129
+ ...state,
130
+ activeConversationId:
131
+ state.activeConversationId === action.from
132
+ ? action.to
133
+ : state.activeConversationId,
134
+ conversations: state.conversations.map((conversation) =>
135
+ conversation.id === action.from
136
+ ? { ...conversation, id: action.to }
137
+ : conversation
138
+ ),
139
+ drafts: {
140
+ ...state.drafts,
141
+ [action.to]: state.drafts[action.from] ?? "",
142
+ },
143
+ };
144
+ default:
145
+ return state;
146
+ }
147
+ }
@@ -0,0 +1,105 @@
1
+ import { getAIWorkContextRequiredTools, mergeAIRequiredTools } from "./page-context";
2
+ import type {
3
+ AIChatTaskRuntime,
4
+ AIEmployee,
5
+ AIEmployeeTask,
6
+ AIEmployeeTasks,
7
+ AIEmployeeTaskTrigger,
8
+ AIModel,
9
+ AIWorkContextItem,
10
+ } from "./types";
11
+
12
+ const EMPTY_TASKS: AIEmployeeTask[] = [];
13
+
14
+ export type AIChatTaskSet = {
15
+ employeeUsername: string;
16
+ tasks: AIEmployeeTask[];
17
+ context?: AIWorkContextItem[];
18
+ };
19
+
20
+ export function getConfiguredAIChatTaskSet({
21
+ employeeUsername,
22
+ defaultEmployeeUsername,
23
+ defaultTasks,
24
+ employeeTasks,
25
+ inheritedContext,
26
+ }: {
27
+ employeeUsername: string;
28
+ defaultEmployeeUsername: string;
29
+ defaultTasks: AIEmployeeTask[];
30
+ employeeTasks: AIEmployeeTasks;
31
+ inheritedContext: AIWorkContextItem[];
32
+ }): AIChatTaskSet | undefined {
33
+ const tasks =
34
+ employeeTasks[employeeUsername] ??
35
+ (employeeUsername === defaultEmployeeUsername
36
+ ? defaultTasks
37
+ : EMPTY_TASKS);
38
+ return tasks.length
39
+ ? { employeeUsername, tasks, context: inheritedContext }
40
+ : undefined;
41
+ }
42
+
43
+ export function findTriggeredAIEmployee(
44
+ employees: AIEmployee[],
45
+ requested: AIEmployeeTaskTrigger["aiEmployee"]
46
+ ) {
47
+ return typeof requested === "string"
48
+ ? employees.find((item) => item.username === requested)
49
+ : employees.find((item) => item.username === requested.username) ??
50
+ requested;
51
+ }
52
+
53
+ export function getTriggeredAIEmployeeTask(options: AIEmployeeTaskTrigger) {
54
+ return (
55
+ options.task ??
56
+ (options.tasks?.length === 1 && options.auto !== false
57
+ ? options.tasks[0]
58
+ : undefined)
59
+ );
60
+ }
61
+
62
+ export function getTriggeredAIWorkContext(
63
+ options: AIEmployeeTaskTrigger,
64
+ task: AIEmployeeTask | undefined,
65
+ inheritedContext: AIWorkContextItem[]
66
+ ) {
67
+ const taskContext = task?.message?.workContext ?? [];
68
+ return taskContext.length
69
+ ? taskContext
70
+ : options.context?.length
71
+ ? options.context
72
+ : inheritedContext;
73
+ }
74
+
75
+ export function createAIChatTaskRuntime(
76
+ task: AIEmployeeTask | undefined,
77
+ workContext: AIWorkContextItem[]
78
+ ): AIChatTaskRuntime {
79
+ const requiredTools = getAIWorkContextRequiredTools(workContext);
80
+ return task
81
+ ? {
82
+ systemMessage: task.message?.system,
83
+ workContext: [],
84
+ skillSettings: mergeAIRequiredTools(task.skillSettings, requiredTools),
85
+ webSearch: task.webSearch,
86
+ }
87
+ : {
88
+ workContext: [],
89
+ skillSettings: mergeAIRequiredTools(undefined, requiredTools),
90
+ };
91
+ }
92
+
93
+ export function findAIChatTaskModel(
94
+ models: AIModel[],
95
+ task: AIEmployeeTask | undefined
96
+ ) {
97
+ return task?.model
98
+ ? models.find(
99
+ (item) =>
100
+ item.value === task.model?.model &&
101
+ (!task.model.llmService ||
102
+ item.llmService === task.model.llmService)
103
+ )
104
+ : undefined;
105
+ }
@@ -0,0 +1,184 @@
1
+ import type { ChatTransport } from "ai";
2
+ import type { AIService } from "../services";
3
+ import type { AIChatMessage, AIChatRequestContext } from "./types";
4
+ import { createNocoBaseUIMessageStream } from "./ui-message-stream";
5
+
6
+ const messageText = (message?: AIChatMessage) =>
7
+ message?.parts
8
+ .filter((part) => part.type === "text")
9
+ .map((part) => part.text)
10
+ .join("\n") ?? "";
11
+
12
+ export class NocoBaseChatTransport implements ChatTransport<AIChatMessage> {
13
+ private pendingResend?: { messageId: string };
14
+ private pendingConversationResume?: { message?: AIChatMessage };
15
+ private pendingToolResume?: {
16
+ messageId: string;
17
+ responseMessageId: string;
18
+ toolCallIds: string[];
19
+ toolCallResults: Array<{ id: string; result: unknown }>;
20
+ };
21
+
22
+ constructor(
23
+ private readonly options: {
24
+ service: AIService;
25
+ getContext: () => AIChatRequestContext;
26
+ onSessionCreated?: (sessionId: string) => void;
27
+ }
28
+ ) {}
29
+
30
+ prepareResend(messageId: string) {
31
+ this.pendingResend = { messageId };
32
+ }
33
+
34
+ cancelResend(messageId: string) {
35
+ if (this.pendingResend?.messageId === messageId) {
36
+ this.pendingResend = undefined;
37
+ }
38
+ }
39
+
40
+ prepareConversationResume(messages: AIChatMessage[]) {
41
+ this.pendingConversationResume = {
42
+ message: [...messages]
43
+ .reverse()
44
+ .find((message) => message.role === "assistant"),
45
+ };
46
+ }
47
+
48
+ prepareToolResume(
49
+ messageId: string,
50
+ responseMessageId: string,
51
+ toolCallIds: string[],
52
+ toolCallResults: Array<{ id: string; result: unknown }>
53
+ ) {
54
+ this.pendingToolResume = {
55
+ messageId,
56
+ responseMessageId,
57
+ toolCallIds,
58
+ toolCallResults,
59
+ };
60
+ }
61
+
62
+ cancelToolResume(messageId: string) {
63
+ if (this.pendingToolResume?.messageId === messageId) {
64
+ this.pendingToolResume = undefined;
65
+ }
66
+ }
67
+
68
+ async sendMessages({
69
+ messages,
70
+ abortSignal,
71
+ }: Parameters<ChatTransport<AIChatMessage>["sendMessages"]>[0]) {
72
+ const context = this.options.getContext();
73
+ if (context.model.configured === false) {
74
+ throw new Error(
75
+ "No enabled LLM model is configured in NocoBase. Configure and enable an LLM service before starting a conversation."
76
+ );
77
+ }
78
+ let sessionId = context.sessionId;
79
+
80
+ const pendingResend = this.pendingResend;
81
+ this.pendingResend = undefined;
82
+ if (pendingResend) {
83
+ if (!sessionId) {
84
+ throw new Error("A conversation is required to retry a message.");
85
+ }
86
+ const stream = await this.options.service.resendMessagesStream(
87
+ {
88
+ sessionId,
89
+ messageId: pendingResend.messageId,
90
+ model: {
91
+ llmService: context.model.llmService,
92
+ model: context.model.value,
93
+ },
94
+ webSearch: context.task?.webSearch,
95
+ },
96
+ abortSignal
97
+ );
98
+ return createNocoBaseUIMessageStream(stream);
99
+ }
100
+
101
+ if (!sessionId) {
102
+ sessionId = await this.options.service.createConversation({
103
+ employee: context.employee,
104
+ model: context.model,
105
+ systemMessage: context.task?.systemMessage,
106
+ skillSettings: context.task?.skillSettings,
107
+ });
108
+ this.options.onSessionCreated?.(sessionId);
109
+ }
110
+
111
+ const lastMessage = messages.at(-1);
112
+ const attachments = lastMessage?.metadata?.attachments?.filter(
113
+ (attachment) => attachment.status === "done"
114
+ );
115
+ const workContext = [
116
+ ...(context.task?.workContext ?? []),
117
+ ...(lastMessage?.metadata?.workContext ?? []),
118
+ ];
119
+ const stream = await this.options.service.sendMessagesStream(
120
+ {
121
+ sessionId,
122
+ aiEmployee: context.employee.username,
123
+ model: {
124
+ llmService: context.model.llmService,
125
+ model: context.model.value,
126
+ },
127
+ systemMessage: context.task?.systemMessage,
128
+ skillSettings: context.task?.skillSettings,
129
+ webSearch: context.task?.webSearch,
130
+ editingMessageId: lastMessage?.metadata?.editingMessageId,
131
+ messages: [
132
+ {
133
+ key: lastMessage?.id ?? crypto.randomUUID(),
134
+ role: "user",
135
+ content: { type: "text", content: messageText(lastMessage) },
136
+ attachments: attachments?.length ? attachments : undefined,
137
+ workContext: workContext.length ? workContext : undefined,
138
+ },
139
+ ],
140
+ },
141
+ abortSignal
142
+ );
143
+
144
+ return createNocoBaseUIMessageStream(stream);
145
+ }
146
+
147
+ async reconnectToStream() {
148
+ const pendingToolResume = this.pendingToolResume;
149
+ this.pendingToolResume = undefined;
150
+ const pendingConversationResume = this.pendingConversationResume;
151
+ this.pendingConversationResume = undefined;
152
+ const context = this.options.getContext();
153
+ if (!context.sessionId) {
154
+ throw new Error("A conversation is required to resume a stream.");
155
+ }
156
+
157
+ if (!pendingToolResume) {
158
+ const stream = await this.options.service.resumeConversationStream(
159
+ context.sessionId
160
+ );
161
+ return createNocoBaseUIMessageStream(stream, null, {
162
+ seedMessage: pendingConversationResume?.message,
163
+ });
164
+ }
165
+ const stream = await this.options.service.resumeToolCallStream({
166
+ sessionId: context.sessionId,
167
+ messageId: pendingToolResume.messageId,
168
+ toolCallIds: pendingToolResume.toolCallIds,
169
+ toolCallResults: pendingToolResume.toolCallResults,
170
+ model: {
171
+ llmService: context.model.llmService,
172
+ model: context.model.value,
173
+ },
174
+ webSearch: context.task?.webSearch,
175
+ });
176
+ return createNocoBaseUIMessageStream(
177
+ stream,
178
+ pendingToolResume.responseMessageId,
179
+ {
180
+ waitForNewMessage: true,
181
+ }
182
+ );
183
+ }
184
+ }
@@ -0,0 +1,271 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useMemo,
5
+ type PropsWithChildren,
6
+ } from "react";
7
+ import type { AIToolInvoker } from "./types";
8
+
9
+ export type AIFormField = {
10
+ name: string;
11
+ title?: string;
12
+ type?: string;
13
+ description?: string;
14
+ readonly?: boolean;
15
+ required?: boolean;
16
+ enum?: unknown;
17
+ [key: string]: unknown;
18
+ };
19
+
20
+ export type AIFormTarget = {
21
+ id: string;
22
+ title: string;
23
+ fields: AIFormField[];
24
+ getValues: () => unknown | Promise<unknown>;
25
+ setValues: (values: Record<string, unknown>) => void | Promise<void>;
26
+ };
27
+
28
+ export type AIFormFillSkippedField = {
29
+ name: string;
30
+ reason: "undeclared" | "readonly" | "invalid";
31
+ message: string;
32
+ };
33
+
34
+ export type AIFormFillResult = {
35
+ status: "success" | "error";
36
+ content: string;
37
+ appliedFields: string[];
38
+ skippedFields: AIFormFillSkippedField[];
39
+ };
40
+
41
+ export class AIFormRegistry {
42
+ private readonly targets = new Map<
43
+ string,
44
+ { token: symbol; target: AIFormTarget }
45
+ >();
46
+
47
+ register(target: AIFormTarget) {
48
+ if (!target.id.trim()) throw new Error("AI Form id is required");
49
+ if (!target.title.trim()) throw new Error("AI Form title is required");
50
+ if (!Array.isArray(target.fields)) {
51
+ throw new Error("AI Form fields must be an array");
52
+ }
53
+ if (typeof target.getValues !== "function") {
54
+ throw new Error("AI Form getValues must be a function");
55
+ }
56
+ if (typeof target.setValues !== "function") {
57
+ throw new Error("AI Form setValues must be a function");
58
+ }
59
+ const fieldNames = new Set<string>();
60
+ for (const field of target.fields) {
61
+ if (!field.name?.trim()) throw new Error("AI Form field name is required");
62
+ if (fieldNames.has(field.name)) {
63
+ throw new Error(
64
+ `AI Form field "${field.name}" is declared more than once`
65
+ );
66
+ }
67
+ fieldNames.add(field.name);
68
+ }
69
+ if (this.targets.has(target.id)) {
70
+ throw new Error(`AI Form "${target.id}" is already registered`);
71
+ }
72
+ const token = Symbol(target.id);
73
+ this.targets.set(target.id, { token, target });
74
+ return () => {
75
+ if (this.targets.get(target.id)?.token === token) {
76
+ this.targets.delete(target.id);
77
+ }
78
+ };
79
+ }
80
+
81
+ get(formId: string) {
82
+ return this.targets.get(formId)?.target;
83
+ }
84
+ }
85
+
86
+ const AIFormRegistryContext = createContext<AIFormRegistry | null>(null);
87
+
88
+ export function AIFormRegistryProvider({ children }: PropsWithChildren) {
89
+ const registry = useMemo(() => new AIFormRegistry(), []);
90
+ return (
91
+ <AIFormRegistryContext.Provider value={registry}>
92
+ {children}
93
+ </AIFormRegistryContext.Provider>
94
+ );
95
+ }
96
+
97
+ export function useAIFormRegistry() {
98
+ const registry = useContext(AIFormRegistryContext);
99
+ if (!registry) {
100
+ throw new Error(
101
+ "useAIFormRegistry must be used inside AIFormRegistryProvider"
102
+ );
103
+ }
104
+ return registry;
105
+ }
106
+
107
+ const getErrorMessage = (error: unknown) =>
108
+ error instanceof Error ? error.message : String(error);
109
+
110
+ const getEnumValues = (definition: unknown) => {
111
+ if (!Array.isArray(definition)) return undefined;
112
+ return definition.map((item) => {
113
+ if (item && typeof item === "object" && !Array.isArray(item)) {
114
+ return (item as { value?: unknown }).value;
115
+ }
116
+ return item;
117
+ });
118
+ };
119
+
120
+ const validateFieldValue = (field: AIFormField, value: unknown) => {
121
+ const enumValues = getEnumValues(field.enum);
122
+ if (enumValues?.length && !enumValues.some((item) => Object.is(item, value))) {
123
+ return "The value is not one of the declared options.";
124
+ }
125
+
126
+ switch (field.type?.toLowerCase()) {
127
+ case "string":
128
+ case "text":
129
+ case "textarea":
130
+ case "email":
131
+ case "url":
132
+ case "date":
133
+ case "datetime":
134
+ return typeof value === "string" ? undefined : "Expected a string.";
135
+ case "number":
136
+ case "percent":
137
+ return typeof value === "number" && Number.isFinite(value)
138
+ ? undefined
139
+ : "Expected a finite number.";
140
+ case "integer":
141
+ return typeof value === "number" && Number.isInteger(value)
142
+ ? undefined
143
+ : "Expected an integer.";
144
+ case "boolean":
145
+ case "checkbox":
146
+ return typeof value === "boolean" ? undefined : "Expected a boolean.";
147
+ case "array":
148
+ return Array.isArray(value) ? undefined : "Expected an array.";
149
+ case "object":
150
+ return value && typeof value === "object" && !Array.isArray(value)
151
+ ? undefined
152
+ : "Expected an object.";
153
+ default:
154
+ return undefined;
155
+ }
156
+ };
157
+
158
+ export function createFormFillerInvoker(
159
+ registry: AIFormRegistry
160
+ ): AIToolInvoker {
161
+ return async (input, context) => {
162
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
163
+ return {
164
+ status: "error",
165
+ content: "Form filler requires a form identifier and field data.",
166
+ appliedFields: [],
167
+ skippedFields: [],
168
+ };
169
+ }
170
+
171
+ const { form, data } = input as { form?: unknown; data?: unknown };
172
+ if (typeof form !== "string" || !form) {
173
+ return {
174
+ status: "error",
175
+ content: "The target form identifier is missing.",
176
+ appliedFields: [],
177
+ skippedFields: [],
178
+ };
179
+ }
180
+ if (context.allowedFormIds?.includes(form) !== true) {
181
+ return {
182
+ status: "error",
183
+ content: `The target form "${form}" is not available in this conversation context.`,
184
+ appliedFields: [],
185
+ skippedFields: [],
186
+ };
187
+ }
188
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
189
+ return {
190
+ status: "error",
191
+ content: "Form filler data must be an object.",
192
+ appliedFields: [],
193
+ skippedFields: [],
194
+ };
195
+ }
196
+
197
+ const target = registry.get(form);
198
+ if (!target) {
199
+ return {
200
+ status: "error",
201
+ content: `The target form "${form}" is not available on this page.`,
202
+ appliedFields: [],
203
+ skippedFields: [],
204
+ };
205
+ }
206
+
207
+ try {
208
+ const fields = new Map(target.fields.map((field) => [field.name, field]));
209
+ const accepted: Record<string, unknown> = {};
210
+ const skippedFields: AIFormFillSkippedField[] = [];
211
+ for (const [name, value] of Object.entries(
212
+ data as Record<string, unknown>
213
+ )) {
214
+ const field = fields.get(name);
215
+ if (!field) {
216
+ skippedFields.push({
217
+ name,
218
+ reason: "undeclared",
219
+ message: "This field is not declared by the target form.",
220
+ });
221
+ continue;
222
+ }
223
+ if (field.readonly) {
224
+ skippedFields.push({
225
+ name,
226
+ reason: "readonly",
227
+ message: "This field is read-only.",
228
+ });
229
+ continue;
230
+ }
231
+ const validationError = validateFieldValue(field, value);
232
+ if (validationError) {
233
+ skippedFields.push({
234
+ name,
235
+ reason: "invalid",
236
+ message: validationError,
237
+ });
238
+ continue;
239
+ }
240
+ accepted[name] = value;
241
+ }
242
+
243
+ const appliedFields = Object.keys(accepted);
244
+ if (!appliedFields.length) {
245
+ return {
246
+ status: "error",
247
+ content: `No valid editable fields were provided for "${target.title}".`,
248
+ appliedFields,
249
+ skippedFields,
250
+ } satisfies AIFormFillResult;
251
+ }
252
+
253
+ await target.setValues(accepted);
254
+ return {
255
+ status: "success",
256
+ content: skippedFields.length
257
+ ? `Filled ${appliedFields.length} field(s) in "${target.title}" and skipped ${skippedFields.length}. Please review the values and submit the form manually.`
258
+ : `Filled "${target.title}". Please review the values and submit the form manually.`,
259
+ appliedFields,
260
+ skippedFields,
261
+ } satisfies AIFormFillResult;
262
+ } catch (error) {
263
+ return {
264
+ status: "error",
265
+ content: `Unable to fill "${target.title}": ${getErrorMessage(error)}`,
266
+ appliedFields: [],
267
+ skippedFields: [],
268
+ } satisfies AIFormFillResult;
269
+ }
270
+ };
271
+ }