plugin-build-ui-template 1.0.1
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/client-v2.d.ts +2 -0
- package/client-v2.js +1 -0
- package/client.d.ts +2 -0
- package/client.js +1 -0
- package/dist/client/index.js +10 -0
- package/dist/client-v2/380.b4d1d20b1e27ac78.js +10 -0
- package/dist/client-v2/index.js +10 -0
- package/dist/externalVersion.js +22 -0
- package/dist/index.js +48 -0
- package/dist/server/actions/build.js +422 -0
- package/dist/server/collections/ai-build-ui-template-spaces.js +114 -0
- package/dist/server/index.js +48 -0
- package/dist/server/plugin.js +128 -0
- package/package.json +48 -0
- package/server.d.ts +2 -0
- package/server.js +1 -0
- package/src/client/BuildUITemplateManager.tsx +450 -0
- package/src/client/index.tsx +2 -0
- package/src/client/plugin.tsx +15 -0
- package/src/client-v2/index.tsx +1 -0
- package/src/client-v2/plugin.tsx +24 -0
- package/src/index.ts +2 -0
- package/src/server/actions/build.ts +454 -0
- package/src/server/collections/ai-build-ui-template-spaces.ts +83 -0
- package/src/server/index.ts +2 -0
- package/src/server/plugin.ts +125 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { Context, Next } from '@nocobase/actions';
|
|
2
|
+
import { Repository } from '@nocobase/database';
|
|
3
|
+
import type { Application } from '@nocobase/server';
|
|
4
|
+
// @ts-ignore
|
|
5
|
+
import { PluginAIServer } from '@nocobase/plugin-ai';
|
|
6
|
+
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
|
7
|
+
import { uid } from '@nocobase/utils';
|
|
8
|
+
|
|
9
|
+
export const WORKER_JOB_BUILD_UI_TEMPLATE_PROCESS = 'build-ui-template:process';
|
|
10
|
+
const BUILD_TEMPLATE_QUEUE_CHANNEL = 'plugin-build-ui-template.build';
|
|
11
|
+
const BUILD_TEMPLATE_QUEUE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
12
|
+
const BUILD_TEMPLATE_QUEUE_POLL_INTERVAL_MS = 5000;
|
|
13
|
+
const BUILD_TEMPLATE_QUEUE_WAKE_CHANNEL = 'plugin-build-ui-template.build.wake';
|
|
14
|
+
|
|
15
|
+
type BuildQueueMessage = {
|
|
16
|
+
spaceId: string;
|
|
17
|
+
runId: string;
|
|
18
|
+
queuedAt?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type BuildRunContext = {
|
|
22
|
+
spaceId: string;
|
|
23
|
+
runId: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let buildQueueTimer: NodeJS.Timeout | null = null;
|
|
27
|
+
let buildQueueKickTimer: NodeJS.Timeout | null = null;
|
|
28
|
+
let buildQueueProcessing = false;
|
|
29
|
+
let buildQueueWakeHandler: ((message?: any) => Promise<void>) | null = null;
|
|
30
|
+
|
|
31
|
+
class StaleBuildRunError extends Error {
|
|
32
|
+
constructor(spaceId: string, runId: string) {
|
|
33
|
+
super(`Build run ${runId} for space ${spaceId} is no longer active`);
|
|
34
|
+
this.name = 'StaleBuildRunError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 1. Triggers building
|
|
39
|
+
export async function build(ctx: Context, next: Next) {
|
|
40
|
+
const { filterByTk } = ctx.action.params;
|
|
41
|
+
if (!filterByTk) {
|
|
42
|
+
return ctx.throw(400, 'spaceId is required');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const spaceRepo = ctx.db.getRepository('aiBuildUiTemplateSpaces');
|
|
46
|
+
const space = await spaceRepo.findById(filterByTk);
|
|
47
|
+
if (!space) {
|
|
48
|
+
return ctx.throw(404, 'Space not found');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const runId = uid();
|
|
52
|
+
const now = new Date();
|
|
53
|
+
|
|
54
|
+
await spaceRepo.update({
|
|
55
|
+
filterByTk,
|
|
56
|
+
values: {
|
|
57
|
+
status: 'building',
|
|
58
|
+
buildPhase: 'queued',
|
|
59
|
+
buildRunId: runId,
|
|
60
|
+
buildQueuedAt: now,
|
|
61
|
+
buildLog: 'Build requested, queuing job...',
|
|
62
|
+
},
|
|
63
|
+
transaction: ctx.transaction,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
ctx.body = {
|
|
67
|
+
result: 'ok',
|
|
68
|
+
runId,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await next();
|
|
72
|
+
|
|
73
|
+
// Push to local event queue & wake worker
|
|
74
|
+
const app = ctx.app;
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
enqueueLocalBuild(app, { spaceId: String(filterByTk), runId });
|
|
77
|
+
}, 100);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Queue Mechanics
|
|
81
|
+
function enqueueLocalBuild(app: Application, message: BuildQueueMessage) {
|
|
82
|
+
app.log?.info(`[plugin-build-ui-template] Enqueued build ${message.runId} for space "${message.spaceId}"`);
|
|
83
|
+
publishBuildQueueWake(app, message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function publishBuildQueueWake(app: Application, message?: BuildQueueMessage) {
|
|
87
|
+
try {
|
|
88
|
+
await (app as any).pubSubManager?.publish?.(
|
|
89
|
+
BUILD_TEMPLATE_QUEUE_WAKE_CHANNEL,
|
|
90
|
+
{ spaceId: message?.spaceId, runId: message?.runId },
|
|
91
|
+
);
|
|
92
|
+
} catch (error: any) {
|
|
93
|
+
app.log?.debug(`[plugin-build-ui-template] Wake publish skipped: ${error?.message || error}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function registerBuildTemplateQueue(app: Application) {
|
|
98
|
+
if (buildQueueTimer) return;
|
|
99
|
+
|
|
100
|
+
buildQueueWakeHandler = async () => {
|
|
101
|
+
scheduleBuildQueueTick(app, 0);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const subscribe = (app as any).pubSubManager?.subscribe?.(BUILD_TEMPLATE_QUEUE_WAKE_CHANNEL, buildQueueWakeHandler);
|
|
105
|
+
if (subscribe?.catch) {
|
|
106
|
+
subscribe.catch((error: any) => {
|
|
107
|
+
app.log?.debug(`[plugin-build-ui-template] Wake subscribe skipped: ${error?.message || error}`);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
buildQueueTimer = setInterval(() => scheduleBuildQueueTick(app, 0), BUILD_TEMPLATE_QUEUE_POLL_INTERVAL_MS);
|
|
112
|
+
(buildQueueTimer as any).unref?.();
|
|
113
|
+
scheduleBuildQueueTick(app, 1000);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function unregisterBuildTemplateQueue(app: Application) {
|
|
117
|
+
if (buildQueueTimer) {
|
|
118
|
+
clearInterval(buildQueueTimer);
|
|
119
|
+
buildQueueTimer = null;
|
|
120
|
+
}
|
|
121
|
+
if (buildQueueKickTimer) {
|
|
122
|
+
clearTimeout(buildQueueKickTimer);
|
|
123
|
+
buildQueueKickTimer = null;
|
|
124
|
+
}
|
|
125
|
+
if (buildQueueWakeHandler) {
|
|
126
|
+
(app as any).pubSubManager?.unsubscribe?.(
|
|
127
|
+
BUILD_TEMPLATE_QUEUE_WAKE_CHANNEL,
|
|
128
|
+
buildQueueWakeHandler,
|
|
129
|
+
).catch(() => undefined);
|
|
130
|
+
buildQueueWakeHandler = null;
|
|
131
|
+
}
|
|
132
|
+
buildQueueProcessing = false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function scheduleBuildQueueTick(app: Application, delayMs: number) {
|
|
136
|
+
if (buildQueueKickTimer) return;
|
|
137
|
+
buildQueueKickTimer = setTimeout(() => {
|
|
138
|
+
buildQueueKickTimer = null;
|
|
139
|
+
runBuildQueueTick(app).catch((error) => {
|
|
140
|
+
app.log?.error('[plugin-build-ui-template] Queue tick failed', error);
|
|
141
|
+
});
|
|
142
|
+
}, delayMs);
|
|
143
|
+
(buildQueueKickTimer as any).unref?.();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function runBuildQueueTick(app: Application) {
|
|
147
|
+
if (buildQueueProcessing) return;
|
|
148
|
+
buildQueueProcessing = true;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const spaceRepo = app.db.getRepository('aiBuildUiTemplateSpaces');
|
|
152
|
+
const queuedSpaces = await spaceRepo.find({
|
|
153
|
+
filter: {
|
|
154
|
+
status: 'building',
|
|
155
|
+
buildPhase: 'queued',
|
|
156
|
+
},
|
|
157
|
+
sort: ['buildQueuedAt'],
|
|
158
|
+
limit: 1,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (!queuedSpaces || queuedSpaces.length === 0) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const space = queuedSpaces[0];
|
|
166
|
+
const run: BuildRunContext = {
|
|
167
|
+
spaceId: String(space.get('id')),
|
|
168
|
+
runId: String(space.get('buildRunId')),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
app.log?.info(`[plugin-build-ui-template] Starting async build for space ${run.spaceId}`);
|
|
172
|
+
|
|
173
|
+
// Claim run
|
|
174
|
+
const [affected] = await spaceRepo.update({
|
|
175
|
+
filter: {
|
|
176
|
+
id: run.spaceId,
|
|
177
|
+
status: 'building',
|
|
178
|
+
buildPhase: 'queued',
|
|
179
|
+
buildRunId: run.runId,
|
|
180
|
+
},
|
|
181
|
+
values: {
|
|
182
|
+
buildPhase: 'running',
|
|
183
|
+
buildStartedAt: new Date(),
|
|
184
|
+
buildHeartbeatAt: new Date(),
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (affected <= 0) {
|
|
189
|
+
app.log?.warn(`[plugin-build-ui-template] Failed to claim build run ${run.runId}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Keep updating heartbeat during the build
|
|
194
|
+
const heartbeatTimer = setInterval(() => {
|
|
195
|
+
spaceRepo.update({
|
|
196
|
+
filter: { id: run.spaceId, buildRunId: run.runId },
|
|
197
|
+
values: { buildHeartbeatAt: new Date() },
|
|
198
|
+
}).catch(() => undefined);
|
|
199
|
+
}, 10000);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await executeBuild(app, run);
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
app.log?.error(`[plugin-build-ui-template] Build ${run.runId} failed`, err);
|
|
205
|
+
await spaceRepo.update({
|
|
206
|
+
filter: { id: run.spaceId, buildRunId: run.runId },
|
|
207
|
+
values: {
|
|
208
|
+
status: 'error',
|
|
209
|
+
buildPhase: 'error',
|
|
210
|
+
buildLog: `Generation failed: ${err.message || String(err)}`,
|
|
211
|
+
},
|
|
212
|
+
}).catch(() => undefined);
|
|
213
|
+
} finally {
|
|
214
|
+
clearInterval(heartbeatTimer);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
} finally {
|
|
218
|
+
buildQueueProcessing = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function recoverInterruptedBuilds(app: Application) {
|
|
223
|
+
const spaceRepo = app.db.getRepository('aiBuildUiTemplateSpaces');
|
|
224
|
+
const runningSpaces = await spaceRepo.find({
|
|
225
|
+
filter: {
|
|
226
|
+
status: 'building',
|
|
227
|
+
buildPhase: 'running',
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
for (const space of runningSpaces) {
|
|
232
|
+
const spaceId = String(space.get('id'));
|
|
233
|
+
app.log?.info(`[plugin-build-ui-template] Re-queuing interrupted build for space ${spaceId}`);
|
|
234
|
+
await spaceRepo.update({
|
|
235
|
+
filterByTk: spaceId,
|
|
236
|
+
values: {
|
|
237
|
+
buildPhase: 'queued',
|
|
238
|
+
buildQueuedAt: new Date(),
|
|
239
|
+
buildLog: 'System restarted, re-queuing build job...',
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 3. Generation Logic
|
|
246
|
+
async function executeBuild(app: Application, run: BuildRunContext) {
|
|
247
|
+
const spaceRepo = app.db.getRepository('aiBuildUiTemplateSpaces');
|
|
248
|
+
const space = await spaceRepo.findById(run.spaceId);
|
|
249
|
+
if (!space) throw new Error('Space not found');
|
|
250
|
+
|
|
251
|
+
const { title, llmService, model, systemPrompt, promptRequirements, type, targetCollection } = space.get();
|
|
252
|
+
if (!llmService || !model) throw new Error('LLM Service or model is missing in space settings');
|
|
253
|
+
|
|
254
|
+
// Load collection metadata
|
|
255
|
+
await updateSpace(app, run, 'preparing', 'Loading target collection metadata...');
|
|
256
|
+
let fieldsMeta = '';
|
|
257
|
+
if (targetCollection) {
|
|
258
|
+
const collection = app.db.getCollection(targetCollection);
|
|
259
|
+
if (collection) {
|
|
260
|
+
const fields = collection.fields;
|
|
261
|
+
fieldsMeta = Array.from(fields.values())
|
|
262
|
+
.map((f: any) => `- Name: ${f.name}, Type: ${f.type}, Title: ${f.options?.title || f.name}`)
|
|
263
|
+
.join('\n');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await updateSpace(app, run, 'generating', 'AI is generating UI flow models...');
|
|
268
|
+
|
|
269
|
+
const aiPlugin = app.pm.get('ai') as PluginAIServer;
|
|
270
|
+
if (!aiPlugin) throw new Error('Plugin AI is not available');
|
|
271
|
+
|
|
272
|
+
const serviceData = await aiPlugin.aiManager.getLLMService({ llmService, model });
|
|
273
|
+
const provider = serviceData.provider;
|
|
274
|
+
|
|
275
|
+
// Prompts LLM for layout
|
|
276
|
+
const messages = [];
|
|
277
|
+
if (systemPrompt) {
|
|
278
|
+
messages.push(new SystemMessage(systemPrompt));
|
|
279
|
+
} else {
|
|
280
|
+
messages.push(
|
|
281
|
+
new SystemMessage(
|
|
282
|
+
`You are a senior UI UX developer specializing in NocoBase V2.
|
|
283
|
+
You construct professional block layouts using nested JSON FlowModels.
|
|
284
|
+
|
|
285
|
+
Rules:
|
|
286
|
+
- Return ONLY a valid JSON structure representing the root FlowModel. No markdown code fences, no explanations.
|
|
287
|
+
- The root object must contain "use" representing the block type. Common types: "EditFormModel", "DetailsBlockModel", "TableBlockModel", "GridCardBlockModel".
|
|
288
|
+
- The layout is recursive. Sub-models should be defined in a "subModels" object, mapped by subKey.
|
|
289
|
+
- Every subModel must have a unique "uid" placeholder (you can output temporary strings like "node_1", "node_2").
|
|
290
|
+
- Always include standard grid layouts: a Form or Details block should have a subModel with key "grid" using "ReferenceFormGridModel" or "FormGridModel" containing a grid of fields.
|
|
291
|
+
- Ensure correct collection binding by using target fields provided in the prompt context.
|
|
292
|
+
`
|
|
293
|
+
)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let prompt = `Create a beautiful UI ${type === 'popup' ? 'Popup' : 'Block'} template for collection "${targetCollection || 'unknown'}".
|
|
298
|
+
Requirements: ${promptRequirements || 'Create a clean, functional dashboard/form layout'}
|
|
299
|
+
`;
|
|
300
|
+
|
|
301
|
+
if (fieldsMeta) {
|
|
302
|
+
prompt += `\nAvailable Database Fields for collection "${targetCollection}":\n${fieldsMeta}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
messages.push(new HumanMessage(prompt));
|
|
306
|
+
|
|
307
|
+
const response = await provider.chatModel.invoke(messages);
|
|
308
|
+
const rawText = stripThink(toPlainText(response.content));
|
|
309
|
+
|
|
310
|
+
await updateSpace(app, run, 'saving', 'Parsing and storing the new FlowModel...');
|
|
311
|
+
|
|
312
|
+
// Parse layout tree - Highly resilient JSON boundary parsing
|
|
313
|
+
const cleanJsonText = stripFence(rawText);
|
|
314
|
+
const jsonStart = cleanJsonText.indexOf('{');
|
|
315
|
+
const jsonEnd = cleanJsonText.lastIndexOf('}');
|
|
316
|
+
const jsonText = jsonStart >= 0 && jsonEnd > jsonStart ? cleanJsonText.slice(jsonStart, jsonEnd + 1) : cleanJsonText;
|
|
317
|
+
|
|
318
|
+
let parsedModel: any;
|
|
319
|
+
try {
|
|
320
|
+
parsedModel = JSON.parse(jsonText);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
throw new Error(`Failed to parse AI output as JSON: ${rawText.slice(0, 300)}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Set randomized UIDs to make them uniquely saveable
|
|
326
|
+
const uidMap: Record<string, string> = {};
|
|
327
|
+
const assignUids = (node: any) => {
|
|
328
|
+
if (!node || typeof node !== 'object') return;
|
|
329
|
+
const oldUid = node.uid || node['x-uid'] || uid();
|
|
330
|
+
const newUid = uid();
|
|
331
|
+
uidMap[oldUid] = newUid;
|
|
332
|
+
node.uid = newUid;
|
|
333
|
+
node['x-uid'] = newUid;
|
|
334
|
+
|
|
335
|
+
if (node.subModels && typeof node.subModels === 'object') {
|
|
336
|
+
for (const val of Object.values(node.subModels)) {
|
|
337
|
+
const items = Array.isArray(val) ? val : [val];
|
|
338
|
+
for (const item of items) {
|
|
339
|
+
assignUids(item);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
assignUids(parsedModel);
|
|
345
|
+
|
|
346
|
+
// Highly robust recursive replacement of temporary placeholder UIDs inside nested objects
|
|
347
|
+
const replacePlaceholderUids = (val: any): any => {
|
|
348
|
+
if (typeof val === 'string') {
|
|
349
|
+
return uidMap[val] || val;
|
|
350
|
+
}
|
|
351
|
+
if (Array.isArray(val)) {
|
|
352
|
+
return val.map(replacePlaceholderUids);
|
|
353
|
+
}
|
|
354
|
+
if (val && typeof val === 'object') {
|
|
355
|
+
const next: any = {};
|
|
356
|
+
for (const [k, v] of Object.entries(val)) {
|
|
357
|
+
next[k] = replacePlaceholderUids(v);
|
|
358
|
+
}
|
|
359
|
+
return next;
|
|
360
|
+
}
|
|
361
|
+
return val;
|
|
362
|
+
};
|
|
363
|
+
parsedModel = replacePlaceholderUids(parsedModel);
|
|
364
|
+
|
|
365
|
+
// Set parent relation options
|
|
366
|
+
const configureRelations = (node: any, parentUid?: string, subKey?: string, subType?: string) => {
|
|
367
|
+
if (!node || typeof node !== 'object') return;
|
|
368
|
+
if (parentUid && subKey) {
|
|
369
|
+
node.parentId = parentUid;
|
|
370
|
+
node.subKey = subKey;
|
|
371
|
+
node.subType = subType || 'object';
|
|
372
|
+
}
|
|
373
|
+
if (node.subModels && typeof node.subModels === 'object') {
|
|
374
|
+
for (const [key, val] of Object.entries(node.subModels)) {
|
|
375
|
+
const isArray = Array.isArray(val);
|
|
376
|
+
const items = isArray ? val : [val];
|
|
377
|
+
items.forEach((item: any, idx: number) => {
|
|
378
|
+
configureRelations(item, node.uid, key, isArray ? 'array' : 'object');
|
|
379
|
+
if (isArray) {
|
|
380
|
+
item.sortIndex = idx + 1;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
configureRelations(parsedModel);
|
|
387
|
+
|
|
388
|
+
// Save tree to database
|
|
389
|
+
const flowRepo = app.db.getRepository('flowModels') as any;
|
|
390
|
+
if (!flowRepo || typeof flowRepo.insertModel !== 'function') {
|
|
391
|
+
throw new Error('FlowModelRepository or insertModel is not available. Ensure plugin-flow-engine is enabled.');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const savedModel = await flowRepo.insertModel(parsedModel);
|
|
395
|
+
const targetUid = savedModel?.uid || parsedModel.uid;
|
|
396
|
+
|
|
397
|
+
// Create UI template record
|
|
398
|
+
const templateRepo = app.db.getRepository('flowModelTemplates');
|
|
399
|
+
const tplUid = uid();
|
|
400
|
+
await templateRepo.create({
|
|
401
|
+
values: {
|
|
402
|
+
uid: tplUid,
|
|
403
|
+
name: `${title || 'AI'} Template (${type})`,
|
|
404
|
+
description: `AI-generated template: ${promptRequirements?.slice(0, 100) || ''}`,
|
|
405
|
+
targetUid,
|
|
406
|
+
useModel: parsedModel.use || 'BlockModel',
|
|
407
|
+
type: type || 'block',
|
|
408
|
+
collectionName: targetCollection || undefined,
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await spaceRepo.update({
|
|
413
|
+
filterByTk: run.spaceId,
|
|
414
|
+
values: {
|
|
415
|
+
status: 'completed',
|
|
416
|
+
buildPhase: 'completed',
|
|
417
|
+
templateUid: tplUid,
|
|
418
|
+
buildLog: `Template generated successfully! Target root FlowModel UID: ${targetUid}`,
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function updateSpace(app: Application, run: BuildRunContext, phase: string, log: string) {
|
|
424
|
+
const spaceRepo = app.db.getRepository('aiBuildUiTemplateSpaces');
|
|
425
|
+
await spaceRepo.update({
|
|
426
|
+
filter: { id: run.spaceId, buildRunId: run.runId },
|
|
427
|
+
values: {
|
|
428
|
+
buildPhase: phase,
|
|
429
|
+
buildLog: log,
|
|
430
|
+
},
|
|
431
|
+
}).catch(() => undefined);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function toPlainText(value: unknown) {
|
|
435
|
+
if (typeof value === 'string') return value;
|
|
436
|
+
if (Array.isArray(value)) {
|
|
437
|
+
return value.map((item: any) => item?.text || item?.content || '').filter(Boolean).join('\n');
|
|
438
|
+
}
|
|
439
|
+
if (value && typeof value === 'object') {
|
|
440
|
+
return (value as any).text || (value as any).content || JSON.stringify(value);
|
|
441
|
+
}
|
|
442
|
+
return String(value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function stripThink(text: string) {
|
|
446
|
+
return text.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, '').trim();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function stripFence(text: string) {
|
|
450
|
+
return text
|
|
451
|
+
.replace(/^```(?:json|markdown|md)?\s*/i, '')
|
|
452
|
+
.replace(/```\s*$/i, '')
|
|
453
|
+
.trim();
|
|
454
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { defineCollection } from '@nocobase/database';
|
|
2
|
+
|
|
3
|
+
export default defineCollection({
|
|
4
|
+
name: 'aiBuildUiTemplateSpaces',
|
|
5
|
+
shared: true,
|
|
6
|
+
dumpRules: 'required',
|
|
7
|
+
migrationRules: ['overwrite', 'schema-only'],
|
|
8
|
+
timestamps: true,
|
|
9
|
+
fields: [
|
|
10
|
+
{
|
|
11
|
+
type: 'uid',
|
|
12
|
+
name: 'id',
|
|
13
|
+
primaryKey: true,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
type: 'string',
|
|
17
|
+
name: 'title',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
type: 'string',
|
|
21
|
+
name: 'llmService',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: 'string',
|
|
25
|
+
name: 'model',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
type: 'text',
|
|
29
|
+
name: 'systemPrompt',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
type: 'text',
|
|
33
|
+
name: 'promptRequirements',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: 'string',
|
|
37
|
+
name: 'type',
|
|
38
|
+
defaultValue: 'block', // 'block' or 'popup'
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
type: 'string',
|
|
42
|
+
name: 'targetCollection',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: 'string',
|
|
46
|
+
name: 'buildPhase',
|
|
47
|
+
defaultValue: 'idle',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: 'string',
|
|
51
|
+
name: 'buildRunId',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: 'date',
|
|
55
|
+
name: 'buildQueuedAt',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: 'date',
|
|
59
|
+
name: 'buildStartedAt',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: 'date',
|
|
63
|
+
name: 'buildHeartbeatAt',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: 'string',
|
|
67
|
+
name: 'buildWorkerId',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: 'string',
|
|
71
|
+
name: 'templateUid',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
type: 'string',
|
|
75
|
+
name: 'status',
|
|
76
|
+
defaultValue: 'draft',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
type: 'text',
|
|
80
|
+
name: 'buildLog',
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { InstallOptions, Plugin } from '@nocobase/server';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
build,
|
|
5
|
+
recoverInterruptedBuilds,
|
|
6
|
+
registerBuildTemplateQueue,
|
|
7
|
+
unregisterBuildTemplateQueue,
|
|
8
|
+
} from './actions/build';
|
|
9
|
+
|
|
10
|
+
export class PluginBuildUITemplateServer extends Plugin {
|
|
11
|
+
private readonly schemaCollections = ['aiBuildUiTemplateSpaces'];
|
|
12
|
+
|
|
13
|
+
async load() {
|
|
14
|
+
// Import collection definitions
|
|
15
|
+
await this.db.import({
|
|
16
|
+
directory: resolve(__dirname, 'collections'),
|
|
17
|
+
});
|
|
18
|
+
await this.ensureSchema();
|
|
19
|
+
|
|
20
|
+
// Register resource actions
|
|
21
|
+
this.app.resourceManager.registerActionHandlers({
|
|
22
|
+
'aiBuildUiTemplateSpaces:build': build,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ACL permissions
|
|
26
|
+
this.app.acl.registerSnippet({
|
|
27
|
+
name: 'pm.ai-build-ui-template',
|
|
28
|
+
actions: ['aiBuildUiTemplateSpaces:*'],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Initialize background queue
|
|
32
|
+
registerBuildTemplateQueue(this.app);
|
|
33
|
+
|
|
34
|
+
// Resume interrupted worker runs on startup
|
|
35
|
+
this.app.on('afterStart', async () => {
|
|
36
|
+
try {
|
|
37
|
+
await recoverInterruptedBuilds(this.app);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
this.app.logger.warn('[plugin-build-ui-template] Failed to recover interrupted builds', err);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Event hooks for cleanup
|
|
44
|
+
this.app.on('beforeStop', () => {
|
|
45
|
+
unregisterBuildTemplateQueue(this.app);
|
|
46
|
+
});
|
|
47
|
+
this.app.on('beforeDestroy', () => {
|
|
48
|
+
unregisterBuildTemplateQueue(this.app);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async ensureCollectionSchema(collectionName: string) {
|
|
53
|
+
const collection = this.db.getCollection(collectionName);
|
|
54
|
+
if (!collection) {
|
|
55
|
+
this.app.logger.warn(`[plugin-build-ui-template] Collection "${collectionName}" is not registered`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const queryInterface = this.db.sequelize.getQueryInterface();
|
|
60
|
+
const tableName = collection.getTableNameWithSchema();
|
|
61
|
+
let columns: Record<string, any> | null = null;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
columns = await queryInterface.describeTable(tableName);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await collection.model.sync();
|
|
67
|
+
columns = await queryInterface.describeTable(tableName);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const attributes = collection.model.rawAttributes as Record<string, any>;
|
|
71
|
+
for (const [attributeName, attribute] of Object.entries(attributes)) {
|
|
72
|
+
const columnName = attribute.field || attributeName;
|
|
73
|
+
if (columns[columnName]) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const columnDefinition = { ...attribute };
|
|
78
|
+
delete columnDefinition.Model;
|
|
79
|
+
delete columnDefinition.fieldName;
|
|
80
|
+
|
|
81
|
+
await queryInterface.addColumn(tableName, columnName, columnDefinition);
|
|
82
|
+
columns[columnName] = columnDefinition;
|
|
83
|
+
this.app.logger.info(`[plugin-build-ui-template] Added missing column "${columnName}" to "${collectionName}"`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async ensureSchema() {
|
|
88
|
+
for (const collectionName of this.schemaCollections) {
|
|
89
|
+
await this.ensureCollectionSchema(collectionName);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const repo = this.db.getRepository<any>('collections');
|
|
93
|
+
if (repo) {
|
|
94
|
+
for (const collectionName of this.schemaCollections) {
|
|
95
|
+
await repo.db2cm(collectionName);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async install(options?: InstallOptions) {
|
|
101
|
+
await this.ensureSchema();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async upgrade() {
|
|
105
|
+
await this.ensureSchema();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async beforeDisable() {
|
|
109
|
+
unregisterBuildTemplateQueue(this.app);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async afterDisable() {
|
|
113
|
+
unregisterBuildTemplateQueue(this.app);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async beforeUnload() {
|
|
117
|
+
unregisterBuildTemplateQueue(this.app);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async remove() {
|
|
121
|
+
unregisterBuildTemplateQueue(this.app);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default PluginBuildUITemplateServer;
|