plugin-build-guide-block 1.0.12 → 1.1.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.
@@ -1,189 +1,497 @@
1
- import { Context, Next } from '@nocobase/actions';
2
- import { Repository } from '@nocobase/database';
3
- import sanitizeHtml from 'sanitize-html';
4
- // @ts-ignore
5
- import { PluginAIServer } from '@nocobase/plugin-ai';
6
- // @ts-ignore
7
- import { PluginFileManagerServer } from '@nocobase/plugin-file-manager';
8
- import { HumanMessage, SystemMessage } from '@langchain/core/messages';
9
- import axios from 'axios';
10
- import fs from 'fs';
11
- import path from 'path';
12
- import { marked } from 'marked';
13
-
14
- const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
15
- allowedTags: [
16
- 'div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
17
- 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
18
- 'a', 'img', 'span', 'strong', 'em', 'code', 'pre', 'blockquote', 'br', 'hr',
19
- ],
20
- allowedAttributes: {
21
- a: ['href', 'target'],
22
- img: ['src', 'alt', 'width', 'height'],
23
- '*': ['style', 'class'],
24
- },
25
- allowedStyles: {
26
- '*': {
27
- color: [/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, /^rgb/, /^rgba/],
28
- 'background-color': [/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, /^rgb/, /^rgba/],
29
- 'text-align': [/^left$/, /^right$/, /^center$/, /^justify$/],
30
- 'font-size': [/^\d+(?:px|em|%)$/],
31
- },
32
- },
33
- };
34
-
35
- async function fetchFileContent(app: any, file: any): Promise<string> {
36
- const fileManager = app.pm.get('file-manager') as PluginFileManagerServer;
37
- if (!fileManager) return '';
38
- const url = await fileManager.getFileURL(file);
39
-
40
- try {
41
- if (url.startsWith('http')) {
42
- const response = await axios.get(url, { responseType: 'text', timeout: 15000 });
43
- return response.data;
44
- } else {
45
- let localPath = url;
46
- if (process.env.APP_PUBLIC_PATH && localPath.startsWith(process.env.APP_PUBLIC_PATH)) {
47
- localPath = localPath.slice(process.env.APP_PUBLIC_PATH.length);
48
- }
49
- localPath = path.join(process.cwd(), localPath);
50
- const data = await fs.promises.readFile(localPath, 'utf8');
51
- return data;
52
- }
53
- } catch (err) {
54
- app.log.error(`Failed to read file content for document ${file.id}`, err);
55
- return `[Failed to read document: ${file.filename}]`;
56
- }
57
- }
58
-
59
- export async function build(ctx: Context, next: Next) {
60
- const { filterByTk } = ctx.action.params;
61
- const repository = ctx.db.getRepository('aiBuildGuideSpaces') as Repository;
62
-
63
- const space = await repository.findById(filterByTk);
64
-
65
- if (!space) {
66
- ctx.throw(404, 'Space not found');
67
- }
68
-
69
- // Concurrency guard — reject if already building
70
- if (space.get('status') === 'building') {
71
- ctx.throw(409, 'A build is already in progress for this space');
72
- }
73
-
74
- // Capture the long-lived Application reference — NOT the per-request ctx
75
- const app = ctx.app;
76
- const db = ctx.db;
77
-
78
- try {
79
- // 1. Set status to building
80
- await repository.update({
81
- filterByTk,
82
- values: {
83
- status: 'building',
84
- buildLog: null,
85
- },
86
- });
87
-
88
- // 2. Run the LLM pipeline asynchronously (fire-and-forget)
89
- const bgPromise = (async () => {
90
- const bgRepo = db.getRepository('aiBuildGuideSpaces') as Repository;
91
-
92
- // 2a. Extract Document contexts
93
- const documents = await space.getDocuments();
94
- let documentsText = '';
95
-
96
- if (documents && documents.length > 0) {
97
- const texts = await Promise.all(
98
- documents.map(async (doc) => {
99
- const content = await fetchFileContent(app, doc);
100
- return `--- Document: ${doc.filename} ---\n${content}\n`;
101
- })
102
- );
103
- documentsText = texts.join('\n');
104
- }
105
-
106
- // 2b. Connect to LLM via plugin-ai
107
- const aiPlugin = app.pm.get('ai') as PluginAIServer;
108
- if (!aiPlugin) {
109
- throw new Error('Plugin AI is not available');
110
- }
111
-
112
- const { llmService, model, systemPrompt, outputFormat } = space.get();
113
- const format = outputFormat === 'markdown' ? 'markdown' : 'html';
114
-
115
- if (!llmService || !model) {
116
- throw new Error('LLM Service or model is missing in space configuration');
117
- }
118
-
119
- const serviceData = await aiPlugin.aiManager.getLLMService({ llmService, model });
120
- const provider = serviceData.provider;
121
-
122
- const messages = [];
123
- if (systemPrompt) {
124
- messages.push(new SystemMessage(systemPrompt));
125
- }
126
-
127
- const instruction =
128
- format === 'markdown'
129
- ? `Please generate a comprehensive user guide in pure Markdown based on the following documents. Output ONLY Markdown content (no HTML wrappers, no code-fence around the whole document).\n\nDocuments:\n${documentsText}`
130
- : `Please generate an HTML user guide based on the following documents. Output ONLY valid HTML without Markdown blocks.\n\nDocuments:\n${documentsText}`;
131
- messages.push(new HumanMessage(instruction));
132
-
133
- const response = await provider.chatModel.invoke(messages);
134
- const rawText =
135
- typeof response.content === 'string' ? response.content : JSON.stringify(response.content);
136
-
137
- const updateValues: Record<string, unknown> = { status: 'completed' };
138
-
139
- if (format === 'markdown') {
140
- const rawMarkdown = rawText.replace(/^```(?:markdown|md)?\s*/, '').replace(/```\s*$/, '');
141
- const renderedHtml = await marked.parse(rawMarkdown, { async: true });
142
- const cleanHtml = sanitizeHtml(renderedHtml, SANITIZE_OPTIONS);
143
- updateValues.generatedMarkdown = rawMarkdown;
144
- updateValues.generatedHtml = cleanHtml;
145
- } else {
146
- const rawHtml = rawText.replace(/^```html\s*/, '').replace(/```\s*$/, '');
147
- const cleanHtml = sanitizeHtml(rawHtml, SANITIZE_OPTIONS);
148
- updateValues.generatedHtml = cleanHtml;
149
- updateValues.generatedMarkdown = null;
150
- }
151
-
152
- await bgRepo.update({
153
- filterByTk,
154
- values: updateValues,
155
- });
156
- })();
157
-
158
- // Ensure background errors are caught and persisted
159
- bgPromise.catch(async (error) => {
160
- app.log.error('Build Guide Background Error', error);
161
- try {
162
- const bgRepo = db.getRepository('aiBuildGuideSpaces') as Repository;
163
- await bgRepo.update({
164
- filterByTk,
165
- values: {
166
- status: 'error',
167
- buildLog: error.message || String(error),
168
- },
169
- });
170
- } catch (updateErr) {
171
- app.log.error('Failed to persist build error status', updateErr);
172
- }
173
- });
174
-
175
- ctx.body = { status: 'building' };
176
- } catch (error) {
177
- app.log.error('Build Guide Error', error);
178
- await repository.update({
179
- filterByTk,
180
- values: {
181
- status: 'error',
182
- buildLog: error.message || String(error),
183
- },
184
- });
185
- ctx.throw(500, error.message || 'Error occurred during build');
186
- }
187
-
188
- await next();
189
- }
1
+ import { Context, Next } from '@nocobase/actions';
2
+ import { Repository } from '@nocobase/database';
3
+ import sanitizeHtml from 'sanitize-html';
4
+ // @ts-ignore
5
+ import { PluginAIServer } from '@nocobase/plugin-ai';
6
+ // @ts-ignore
7
+ import { PluginFileManagerServer } from '@nocobase/plugin-file-manager';
8
+ import { HumanMessage, SystemMessage } from '@langchain/core/messages';
9
+ import axios from 'axios';
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import crypto from 'crypto';
13
+ import { marked } from 'marked';
14
+
15
+ const MAX_SOURCE_CHARS = 90000;
16
+ const MIN_CHAPTERS = 3;
17
+ const MAX_CHAPTERS = 12;
18
+ const DEFAULT_TARGET_CHAPTERS = 5;
19
+
20
+ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
21
+ allowedTags: [
22
+ 'div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
23
+ 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
24
+ 'a', 'img', 'span', 'strong', 'em', 'code', 'pre', 'blockquote', 'br', 'hr',
25
+ ],
26
+ allowedAttributes: {
27
+ a: ['href', 'target'],
28
+ img: ['src', 'alt', 'width', 'height'],
29
+ '*': ['style', 'class', 'id'],
30
+ },
31
+ allowedStyles: {
32
+ '*': {
33
+ color: [/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, /^rgb/, /^rgba/],
34
+ 'background-color': [/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, /^rgb/, /^rgba/],
35
+ 'text-align': [/^left$/, /^right$/, /^center$/, /^justify$/],
36
+ 'font-size': [/^\d+(?:px|em|%)$/],
37
+ },
38
+ },
39
+ };
40
+
41
+ type GuidePlanItem = {
42
+ title: string;
43
+ goal?: string;
44
+ sourceHints?: string[];
45
+ };
46
+
47
+ type GuidePlan = {
48
+ title?: string;
49
+ chapters: GuidePlanItem[];
50
+ };
51
+
52
+ function clampChapterCount(value: unknown) {
53
+ const count = Number(value);
54
+ if (!Number.isFinite(count)) return DEFAULT_TARGET_CHAPTERS;
55
+ return Math.max(MIN_CHAPTERS, Math.min(MAX_CHAPTERS, Math.round(count)));
56
+ }
57
+
58
+ async function fetchFileContent(app: any, file: any): Promise<string> {
59
+ const fileManager = app.pm.get('file-manager') as PluginFileManagerServer;
60
+ if (!fileManager) return '';
61
+ const url = await fileManager.getFileURL(file);
62
+
63
+ try {
64
+ if (url.startsWith('http')) {
65
+ const response = await axios.get(url, { responseType: 'text', timeout: 15000 });
66
+ return response.data;
67
+ }
68
+
69
+ let localPath = url;
70
+ if (process.env.APP_PUBLIC_PATH && localPath.startsWith(process.env.APP_PUBLIC_PATH)) {
71
+ localPath = localPath.slice(process.env.APP_PUBLIC_PATH.length);
72
+ }
73
+ localPath = path.join(process.cwd(), localPath);
74
+ return await fs.promises.readFile(localPath, 'utf8');
75
+ } catch (err) {
76
+ app.log.error(`Failed to read file content for document ${file.id}`, err);
77
+ return `[Failed to read document: ${file.filename}]`;
78
+ }
79
+ }
80
+
81
+ function toPlainText(value: unknown) {
82
+ if (typeof value === 'string') return value;
83
+ if (Array.isArray(value)) {
84
+ return value
85
+ .map((item: any) => {
86
+ if (typeof item === 'string') return item;
87
+ if (typeof item?.text === 'string') return item.text;
88
+ if (typeof item?.content === 'string') return item.content;
89
+ return '';
90
+ })
91
+ .filter(Boolean)
92
+ .join('\n');
93
+ }
94
+ if (value && typeof value === 'object') {
95
+ const text = (value as any).text || (value as any).content;
96
+ if (typeof text === 'string') return text;
97
+ }
98
+ return JSON.stringify(value);
99
+ }
100
+
101
+ function stripFence(text: string) {
102
+ return text
103
+ .replace(/^```(?:json|markdown|md|html)?\s*/i, '')
104
+ .replace(/```\s*$/i, '')
105
+ .trim();
106
+ }
107
+
108
+ function slugify(text: string, fallback: string) {
109
+ const slug = text
110
+ .toLowerCase()
111
+ .normalize('NFKD')
112
+ .replace(/[\u0300-\u036f]/g, '')
113
+ .replace(/[^a-z0-9]+/g, '-')
114
+ .replace(/^-+|-+$/g, '');
115
+ return slug || fallback;
116
+ }
117
+
118
+ function createFallbackPlan(guideTitle: string, targetChapterCount: number): GuidePlan {
119
+ const chapterTemplates = [
120
+ {
121
+ title: 'Overview and scope',
122
+ goal: 'Explain what the guide covers, who it is for, and the expected outcome.',
123
+ },
124
+ {
125
+ title: 'Prerequisites and preparation',
126
+ goal: 'List required access, tools, input data, setup steps, and assumptions before starting.',
127
+ },
128
+ {
129
+ title: 'Initial setup',
130
+ goal: 'Guide the user through the first required configuration or installation flow.',
131
+ },
132
+ {
133
+ title: 'Core workflow',
134
+ goal: 'Describe the main task flow users need to complete successfully.',
135
+ },
136
+ {
137
+ title: 'Advanced usage and configuration',
138
+ goal: 'Cover optional settings, variations, and deeper operational procedures.',
139
+ },
140
+ {
141
+ title: 'Verification and validation',
142
+ goal: 'Show how to confirm that the setup or workflow is working correctly.',
143
+ },
144
+ {
145
+ title: 'Troubleshooting',
146
+ goal: 'Document common problems, likely causes, and recovery steps.',
147
+ },
148
+ {
149
+ title: 'Reference and next steps',
150
+ goal: 'Summarize related tasks, reference information, and recommended follow-up actions.',
151
+ },
152
+ ];
153
+
154
+ return {
155
+ title: guideTitle,
156
+ chapters: Array.from({ length: targetChapterCount }, (_, index) => {
157
+ const template = chapterTemplates[index] || {
158
+ title: `Additional topic ${index + 1}`,
159
+ goal: 'Expand an important topic from the source documents into a focused guide section.',
160
+ };
161
+ return {
162
+ ...template,
163
+ sourceHints: [],
164
+ };
165
+ }),
166
+ };
167
+ }
168
+
169
+ function normalizePlan(rawText: string, guideTitle: string, targetChapterCount: number): GuidePlan {
170
+ const cleanText = stripFence(rawText);
171
+ const jsonStart = cleanText.indexOf('{');
172
+ const jsonEnd = cleanText.lastIndexOf('}');
173
+ const jsonText = jsonStart >= 0 && jsonEnd > jsonStart ? cleanText.slice(jsonStart, jsonEnd + 1) : cleanText;
174
+ let parsed: any;
175
+ try {
176
+ parsed = JSON.parse(jsonText);
177
+ } catch {
178
+ return createFallbackPlan(guideTitle, targetChapterCount);
179
+ }
180
+ const rawChapters = Array.isArray(parsed?.chapters) ? parsed.chapters : [];
181
+ const chapters = rawChapters
182
+ .slice(0, MAX_CHAPTERS)
183
+ .map((item: any, index: number) => ({
184
+ title: String(item?.title || `Section ${index + 1}`),
185
+ goal: item?.goal ? String(item.goal) : '',
186
+ sourceHints: Array.isArray(item?.sourceHints) ? item.sourceHints.map((hint: any) => String(hint)) : [],
187
+ }))
188
+ .filter((item: GuidePlanItem) => item.title);
189
+
190
+ if (chapters.length < MIN_CHAPTERS) {
191
+ return createFallbackPlan(parsed?.title ? String(parsed.title) : guideTitle, targetChapterCount);
192
+ }
193
+
194
+ return {
195
+ title: parsed?.title ? String(parsed.title) : guideTitle,
196
+ chapters,
197
+ };
198
+ }
199
+
200
+ async function getLLMProvider(app: any, llmService: string, model: string) {
201
+ const aiPlugin = app.pm.get('ai') as PluginAIServer;
202
+ if (!aiPlugin) {
203
+ throw new Error('Plugin AI is not available');
204
+ }
205
+ const serviceData = await aiPlugin.aiManager.getLLMService({ llmService, model });
206
+ return serviceData.provider;
207
+ }
208
+
209
+ async function buildPlan(provider: any, space: any, documentsText: string): Promise<GuidePlan> {
210
+ const { title, systemPrompt, targetChapterCount, chapterGuidance } = space.get();
211
+ const targetCount = clampChapterCount(targetChapterCount);
212
+ const messages = [];
213
+ if (systemPrompt) {
214
+ messages.push(new SystemMessage(systemPrompt));
215
+ }
216
+ messages.push(
217
+ new HumanMessage(`Create a concise breakdown plan for a multi-page user guide.
218
+
219
+ Return ONLY valid JSON with this shape:
220
+ {
221
+ "title": "Guide title",
222
+ "chapters": [
223
+ {
224
+ "title": "Chapter title",
225
+ "goal": "What this chapter should teach",
226
+ "sourceHints": ["Relevant topics, file names, or keywords"]
227
+ }
228
+ ]
229
+ }
230
+
231
+ Rules:
232
+ - Create exactly ${targetCount} chapters.
233
+ - Each chapter must cover a distinct user goal. Do not collapse the guide into a single chapter.
234
+ - Keep chapter titles user-facing and action-oriented.
235
+ - Do not include markdown fences or explanations.
236
+
237
+ Guide title: ${title || 'User guide'}
238
+
239
+ Chapter guidance:
240
+ ${chapterGuidance || 'Use the source document structure and split the guide into logical user-facing tasks.'}
241
+
242
+ Source documents:
243
+ ${documentsText.slice(0, MAX_SOURCE_CHARS)}`),
244
+ );
245
+ const response = await provider.chatModel.invoke(messages);
246
+ return normalizePlan(toPlainText(response.content), title || 'User guide', targetCount);
247
+ }
248
+
249
+ async function buildPageMarkdown(provider: any, space: any, plan: GuidePlan, chapter: GuidePlanItem, documentsText: string) {
250
+ const { title, systemPrompt } = space.get();
251
+ const messages = [];
252
+ if (systemPrompt) {
253
+ messages.push(new SystemMessage(systemPrompt));
254
+ }
255
+ messages.push(
256
+ new HumanMessage(`Write one chapter for a user guide in pure Markdown.
257
+
258
+ Output rules:
259
+ - Output ONLY Markdown content for this chapter.
260
+ - Start with a level-2 heading using the chapter title.
261
+ - Use clear steps, tables, and callouts when useful.
262
+ - Do not wrap the whole response in a code fence.
263
+ - Do not mention that this was generated by AI.
264
+
265
+ Guide title: ${plan.title || title || 'User guide'}
266
+
267
+ Full chapter plan:
268
+ ${JSON.stringify(plan, null, 2)}
269
+
270
+ Chapter to write:
271
+ ${JSON.stringify(chapter, null, 2)}
272
+
273
+ Source documents:
274
+ ${documentsText.slice(0, MAX_SOURCE_CHARS)}`),
275
+ );
276
+ const response = await provider.chatModel.invoke(messages);
277
+ return stripFence(toPlainText(response.content));
278
+ }
279
+
280
+ async function markdownToCleanHtml(markdown: string) {
281
+ const renderedHtml = await marked.parse(markdown, { async: true });
282
+ return sanitizeHtml(renderedHtml, SANITIZE_OPTIONS);
283
+ }
284
+
285
+ async function readDocuments(app: any, space: any) {
286
+ const documents = await space.getDocuments();
287
+ if (!documents || documents.length === 0) {
288
+ return '';
289
+ }
290
+
291
+ const texts = await Promise.all(
292
+ documents.map(async (doc: any) => {
293
+ const content = await fetchFileContent(app, doc);
294
+ return `--- Document: ${doc.filename || doc.id} ---\n${content}\n`;
295
+ }),
296
+ );
297
+ return texts.join('\n');
298
+ }
299
+
300
+ async function runBuild(app: any, db: any, filterByTk: string) {
301
+ const spaceRepo = db.getRepository('aiBuildGuideSpaces') as Repository;
302
+ const pageRepo = db.getRepository('aiBuildGuidePages') as Repository;
303
+ const space = await spaceRepo.findById(filterByTk);
304
+
305
+ if (!space) {
306
+ throw new Error('Space not found');
307
+ }
308
+
309
+ const { llmService, model } = space.get();
310
+ if (!llmService || !model) {
311
+ throw new Error('LLM Service or model is missing in space configuration');
312
+ }
313
+
314
+ await pageRepo.destroy({
315
+ filter: {
316
+ spaceId: filterByTk,
317
+ },
318
+ });
319
+
320
+ await spaceRepo.update({
321
+ filterByTk,
322
+ values: {
323
+ buildPhase: 'reading',
324
+ buildLog: 'Reading source documents',
325
+ generatedHtml: null,
326
+ generatedMarkdown: null,
327
+ planJson: null,
328
+ pageCount: 0,
329
+ },
330
+ });
331
+
332
+ const documentsText = await readDocuments(app, space);
333
+ const sourceHash = crypto.createHash('sha256').update(documentsText).digest('hex');
334
+ const provider = await getLLMProvider(app, llmService, model);
335
+
336
+ await spaceRepo.update({
337
+ filterByTk,
338
+ values: {
339
+ buildPhase: 'planning',
340
+ buildLog: 'Creating guide breakdown plan',
341
+ sourceHash,
342
+ },
343
+ });
344
+
345
+ const plan = await buildPlan(provider, space, documentsText);
346
+ await spaceRepo.update({
347
+ filterByTk,
348
+ values: {
349
+ planJson: plan,
350
+ pageCount: plan.chapters.length,
351
+ buildPhase: 'building_pages',
352
+ buildLog: `Plan created with ${plan.chapters.length} chapters`,
353
+ },
354
+ });
355
+
356
+ const pageRecords = [];
357
+ for (const [index, chapter] of plan.chapters.entries()) {
358
+ const page = await pageRepo.create({
359
+ values: {
360
+ spaceId: filterByTk,
361
+ sort: index + 1,
362
+ title: chapter.title,
363
+ slug: slugify(chapter.title, `chapter-${index + 1}`),
364
+ goal: chapter.goal,
365
+ planItem: chapter,
366
+ status: 'pending',
367
+ },
368
+ });
369
+ pageRecords.push(page);
370
+ }
371
+
372
+ for (const [index, page] of pageRecords.entries()) {
373
+ const chapter = plan.chapters[index];
374
+ const pageId = page.get('id');
375
+ await pageRepo.update({
376
+ filterByTk: pageId,
377
+ values: {
378
+ status: 'building',
379
+ buildLog: 'Building chapter with LLM',
380
+ },
381
+ });
382
+ await spaceRepo.update({
383
+ filterByTk,
384
+ values: {
385
+ buildPhase: 'building_pages',
386
+ buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`,
387
+ },
388
+ });
389
+
390
+ try {
391
+ const markdown = await buildPageMarkdown(provider, space, plan, chapter, documentsText);
392
+ const html = await markdownToCleanHtml(markdown);
393
+ await pageRepo.update({
394
+ filterByTk: pageId,
395
+ values: {
396
+ status: 'completed',
397
+ generatedMarkdown: markdown,
398
+ generatedHtml: html,
399
+ buildLog: null,
400
+ },
401
+ });
402
+ } catch (error: any) {
403
+ await pageRepo.update({
404
+ filterByTk: pageId,
405
+ values: {
406
+ status: 'error',
407
+ buildLog: error.message || String(error),
408
+ },
409
+ });
410
+ throw error;
411
+ }
412
+ }
413
+
414
+ const completedPages = await pageRepo.find({
415
+ filter: {
416
+ spaceId: filterByTk,
417
+ status: 'completed',
418
+ },
419
+ sort: ['sort'],
420
+ });
421
+ const combinedMarkdown = completedPages
422
+ .map((page: any) => page.get('generatedMarkdown'))
423
+ .filter(Boolean)
424
+ .join('\n\n---\n\n');
425
+ const combinedHtml = await markdownToCleanHtml(combinedMarkdown);
426
+
427
+ await spaceRepo.update({
428
+ filterByTk,
429
+ values: {
430
+ status: 'completed',
431
+ buildPhase: 'completed',
432
+ buildLog: `Built ${completedPages.length} chapters successfully`,
433
+ generatedMarkdown: combinedMarkdown,
434
+ generatedHtml: combinedHtml,
435
+ },
436
+ });
437
+ }
438
+
439
+ export async function build(ctx: Context, next: Next) {
440
+ const { filterByTk } = ctx.action.params;
441
+ const repository = ctx.db.getRepository('aiBuildGuideSpaces') as Repository;
442
+
443
+ const space = await repository.findById(filterByTk);
444
+
445
+ if (!space) {
446
+ ctx.throw(404, 'Space not found');
447
+ }
448
+
449
+ if (space.get('status') === 'building') {
450
+ ctx.throw(409, 'A build is already in progress for this space');
451
+ }
452
+
453
+ const app = ctx.app;
454
+ const db = ctx.db;
455
+
456
+ try {
457
+ await repository.update({
458
+ filterByTk,
459
+ values: {
460
+ status: 'building',
461
+ buildPhase: 'queued',
462
+ buildLog: 'Build queued',
463
+ },
464
+ });
465
+
466
+ runBuild(app, db, filterByTk).catch(async (error) => {
467
+ app.log.error('Build Guide Background Error', error);
468
+ try {
469
+ await repository.update({
470
+ filterByTk,
471
+ values: {
472
+ status: 'error',
473
+ buildPhase: 'error',
474
+ buildLog: error.message || String(error),
475
+ },
476
+ });
477
+ } catch (updateErr) {
478
+ app.log.error('Failed to persist build error status', updateErr);
479
+ }
480
+ });
481
+
482
+ ctx.body = { status: 'building' };
483
+ } catch (error: any) {
484
+ app.log.error('Build Guide Error', error);
485
+ await repository.update({
486
+ filterByTk,
487
+ values: {
488
+ status: 'error',
489
+ buildPhase: 'error',
490
+ buildLog: error.message || String(error),
491
+ },
492
+ });
493
+ ctx.throw(500, error.message || 'Error occurred during build');
494
+ }
495
+
496
+ await next();
497
+ }