plugin-build-guide-block 1.0.12 → 1.1.3
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 +16 -74
- package/dist/client/components/SpaceSelect.d.ts +2 -0
- package/dist/client/index.js +9 -10
- package/dist/client/schemas/spacesSchema.d.ts +81 -0
- package/dist/externalVersion.js +7 -16
- package/dist/index.js +0 -9
- package/dist/locale/en-US.json +12 -4
- package/dist/locale/namespace.js +0 -9
- package/dist/locale/vi-VN.json +12 -4
- package/dist/locale/zh-CN.json +12 -4
- package/dist/server/actions/build.js +346 -80
- package/dist/server/actions/getHtml.js +0 -9
- package/dist/server/actions/getMarkdown.js +0 -9
- package/dist/server/collections/ai-build-guide-pages.d.ts +2 -0
- package/dist/server/collections/ai-build-guide-pages.js +81 -0
- package/dist/server/collections/ai-build-guide-spaces.js +33 -9
- package/dist/server/index.js +0 -9
- package/dist/server/plugin.d.ts +3 -0
- package/dist/server/plugin.js +50 -21
- package/package.json +1 -1
- package/src/client/UserGuideBlock.tsx +368 -53
- package/src/client/UserGuideBlockProvider.tsx +9 -8
- package/src/client/UserGuideManager.tsx +52 -23
- package/src/client/components/SpaceSelect.tsx +37 -0
- package/src/client/models/UserGuideBlockModel.ts +19 -29
- package/src/client/plugin.tsx +3 -2
- package/src/client/schemaSettings.ts +2 -12
- package/src/client/schemas/spacesSchema.ts +439 -357
- package/src/locale/en-US.json +12 -4
- package/src/locale/vi-VN.json +12 -4
- package/src/locale/zh-CN.json +12 -4
- package/src/server/actions/build.ts +501 -189
- package/src/server/collections/ai-build-guide-pages.ts +60 -0
- package/src/server/collections/ai-build-guide-spaces.ts +57 -24
- package/src/server/plugin.ts +58 -11
|
@@ -1,189 +1,501 @@
|
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
'
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 = 1;
|
|
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 targetCount = clampChapterCount(targetChapterCount);
|
|
171
|
+
const cleanText = stripFence(rawText);
|
|
172
|
+
const jsonStart = cleanText.indexOf('{');
|
|
173
|
+
const jsonEnd = cleanText.lastIndexOf('}');
|
|
174
|
+
const jsonText = jsonStart >= 0 && jsonEnd > jsonStart ? cleanText.slice(jsonStart, jsonEnd + 1) : cleanText;
|
|
175
|
+
let parsed: any;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(jsonText);
|
|
178
|
+
} catch {
|
|
179
|
+
return createFallbackPlan(guideTitle, targetCount);
|
|
180
|
+
}
|
|
181
|
+
const rawChapters = Array.isArray(parsed?.chapters) ? parsed.chapters : [];
|
|
182
|
+
const planTitle = parsed?.title ? String(parsed.title) : guideTitle;
|
|
183
|
+
const fallbackPlan = createFallbackPlan(planTitle, targetCount);
|
|
184
|
+
const chapters = fallbackPlan.chapters.map((fallback, index) => {
|
|
185
|
+
const item = rawChapters[index];
|
|
186
|
+
if (!item) return fallback;
|
|
187
|
+
const title = typeof item === 'string' ? item : item?.title;
|
|
188
|
+
const goal = typeof item === 'object' ? item?.goal : undefined;
|
|
189
|
+
const sourceHints = typeof item === 'object' && Array.isArray(item?.sourceHints) ? item.sourceHints : undefined;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
title: String(title || fallback.title),
|
|
193
|
+
goal: goal ? String(goal) : fallback.goal,
|
|
194
|
+
sourceHints: sourceHints ? sourceHints.map((hint: any) => String(hint)) : fallback.sourceHints,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
title: planTitle,
|
|
200
|
+
chapters,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function getLLMProvider(app: any, llmService: string, model: string) {
|
|
205
|
+
const aiPlugin = app.pm.get('ai') as PluginAIServer;
|
|
206
|
+
if (!aiPlugin) {
|
|
207
|
+
throw new Error('Plugin AI is not available');
|
|
208
|
+
}
|
|
209
|
+
const serviceData = await aiPlugin.aiManager.getLLMService({ llmService, model });
|
|
210
|
+
return serviceData.provider;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function buildPlan(provider: any, space: any, documentsText: string): Promise<GuidePlan> {
|
|
214
|
+
const { title, systemPrompt, targetChapterCount, chapterGuidance } = space.get();
|
|
215
|
+
const targetCount = clampChapterCount(targetChapterCount);
|
|
216
|
+
const messages = [];
|
|
217
|
+
if (systemPrompt) {
|
|
218
|
+
messages.push(new SystemMessage(systemPrompt));
|
|
219
|
+
}
|
|
220
|
+
messages.push(
|
|
221
|
+
new HumanMessage(`Create a concise breakdown plan for a multi-page user guide.
|
|
222
|
+
|
|
223
|
+
Return ONLY valid JSON with this shape:
|
|
224
|
+
{
|
|
225
|
+
"title": "Guide title",
|
|
226
|
+
"chapters": [
|
|
227
|
+
{
|
|
228
|
+
"title": "Chapter title",
|
|
229
|
+
"goal": "What this chapter should teach",
|
|
230
|
+
"sourceHints": ["Relevant topics, file names, or keywords"]
|
|
231
|
+
}
|
|
232
|
+
]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
Rules:
|
|
236
|
+
- Create exactly ${targetCount} chapter${targetCount === 1 ? '' : 's'}.
|
|
237
|
+
- If more than one chapter is requested, each chapter must cover a distinct user goal.
|
|
238
|
+
- Keep chapter titles user-facing and action-oriented.
|
|
239
|
+
- Do not include markdown fences or explanations.
|
|
240
|
+
|
|
241
|
+
Guide title: ${title || 'User guide'}
|
|
242
|
+
|
|
243
|
+
Chapter guidance:
|
|
244
|
+
${chapterGuidance || 'Use the source document structure and split the guide into logical user-facing tasks.'}
|
|
245
|
+
|
|
246
|
+
Source documents:
|
|
247
|
+
${documentsText.slice(0, MAX_SOURCE_CHARS)}`),
|
|
248
|
+
);
|
|
249
|
+
const response = await provider.chatModel.invoke(messages);
|
|
250
|
+
return normalizePlan(toPlainText(response.content), title || 'User guide', targetCount);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function buildPageMarkdown(provider: any, space: any, plan: GuidePlan, chapter: GuidePlanItem, documentsText: string) {
|
|
254
|
+
const { title, systemPrompt } = space.get();
|
|
255
|
+
const messages = [];
|
|
256
|
+
if (systemPrompt) {
|
|
257
|
+
messages.push(new SystemMessage(systemPrompt));
|
|
258
|
+
}
|
|
259
|
+
messages.push(
|
|
260
|
+
new HumanMessage(`Write one chapter for a user guide in pure Markdown.
|
|
261
|
+
|
|
262
|
+
Output rules:
|
|
263
|
+
- Output ONLY Markdown content for this chapter.
|
|
264
|
+
- Start with a level-2 heading using the chapter title.
|
|
265
|
+
- Use clear steps, tables, and callouts when useful.
|
|
266
|
+
- Do not wrap the whole response in a code fence.
|
|
267
|
+
- Do not mention that this was generated by AI.
|
|
268
|
+
|
|
269
|
+
Guide title: ${plan.title || title || 'User guide'}
|
|
270
|
+
|
|
271
|
+
Full chapter plan:
|
|
272
|
+
${JSON.stringify(plan, null, 2)}
|
|
273
|
+
|
|
274
|
+
Chapter to write:
|
|
275
|
+
${JSON.stringify(chapter, null, 2)}
|
|
276
|
+
|
|
277
|
+
Source documents:
|
|
278
|
+
${documentsText.slice(0, MAX_SOURCE_CHARS)}`),
|
|
279
|
+
);
|
|
280
|
+
const response = await provider.chatModel.invoke(messages);
|
|
281
|
+
return stripFence(toPlainText(response.content));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function markdownToCleanHtml(markdown: string) {
|
|
285
|
+
const renderedHtml = await marked.parse(markdown, { async: true });
|
|
286
|
+
return sanitizeHtml(renderedHtml, SANITIZE_OPTIONS);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function readDocuments(app: any, space: any) {
|
|
290
|
+
const documents = await space.getDocuments();
|
|
291
|
+
if (!documents || documents.length === 0) {
|
|
292
|
+
return '';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const texts = await Promise.all(
|
|
296
|
+
documents.map(async (doc: any) => {
|
|
297
|
+
const content = await fetchFileContent(app, doc);
|
|
298
|
+
return `--- Document: ${doc.filename || doc.id} ---\n${content}\n`;
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
return texts.join('\n');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function runBuild(app: any, db: any, filterByTk: string) {
|
|
305
|
+
const spaceRepo = db.getRepository('aiBuildGuideSpaces') as Repository;
|
|
306
|
+
const pageRepo = db.getRepository('aiBuildGuidePages') as Repository;
|
|
307
|
+
const space = await spaceRepo.findById(filterByTk);
|
|
308
|
+
|
|
309
|
+
if (!space) {
|
|
310
|
+
throw new Error('Space not found');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const { llmService, model } = space.get();
|
|
314
|
+
if (!llmService || !model) {
|
|
315
|
+
throw new Error('LLM Service or model is missing in space configuration');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await pageRepo.destroy({
|
|
319
|
+
filter: {
|
|
320
|
+
spaceId: filterByTk,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await spaceRepo.update({
|
|
325
|
+
filterByTk,
|
|
326
|
+
values: {
|
|
327
|
+
buildPhase: 'reading',
|
|
328
|
+
buildLog: 'Reading source documents',
|
|
329
|
+
generatedHtml: null,
|
|
330
|
+
generatedMarkdown: null,
|
|
331
|
+
planJson: null,
|
|
332
|
+
pageCount: 0,
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const documentsText = await readDocuments(app, space);
|
|
337
|
+
const sourceHash = crypto.createHash('sha256').update(documentsText).digest('hex');
|
|
338
|
+
const provider = await getLLMProvider(app, llmService, model);
|
|
339
|
+
|
|
340
|
+
await spaceRepo.update({
|
|
341
|
+
filterByTk,
|
|
342
|
+
values: {
|
|
343
|
+
buildPhase: 'planning',
|
|
344
|
+
buildLog: 'Creating guide breakdown plan',
|
|
345
|
+
sourceHash,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const plan = await buildPlan(provider, space, documentsText);
|
|
350
|
+
await spaceRepo.update({
|
|
351
|
+
filterByTk,
|
|
352
|
+
values: {
|
|
353
|
+
planJson: plan,
|
|
354
|
+
pageCount: plan.chapters.length,
|
|
355
|
+
buildPhase: 'building_pages',
|
|
356
|
+
buildLog: `Plan created with ${plan.chapters.length} chapters`,
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const pageRecords = [];
|
|
361
|
+
for (const [index, chapter] of plan.chapters.entries()) {
|
|
362
|
+
const page = await pageRepo.create({
|
|
363
|
+
values: {
|
|
364
|
+
spaceId: filterByTk,
|
|
365
|
+
sort: index + 1,
|
|
366
|
+
title: chapter.title,
|
|
367
|
+
slug: slugify(chapter.title, `chapter-${index + 1}`),
|
|
368
|
+
goal: chapter.goal,
|
|
369
|
+
planItem: chapter,
|
|
370
|
+
status: 'pending',
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
pageRecords.push(page);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const [index, page] of pageRecords.entries()) {
|
|
377
|
+
const chapter = plan.chapters[index];
|
|
378
|
+
const pageId = page.get('id');
|
|
379
|
+
await pageRepo.update({
|
|
380
|
+
filterByTk: pageId,
|
|
381
|
+
values: {
|
|
382
|
+
status: 'building',
|
|
383
|
+
buildLog: 'Building chapter with LLM',
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
await spaceRepo.update({
|
|
387
|
+
filterByTk,
|
|
388
|
+
values: {
|
|
389
|
+
buildPhase: 'building_pages',
|
|
390
|
+
buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`,
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const markdown = await buildPageMarkdown(provider, space, plan, chapter, documentsText);
|
|
396
|
+
const html = await markdownToCleanHtml(markdown);
|
|
397
|
+
await pageRepo.update({
|
|
398
|
+
filterByTk: pageId,
|
|
399
|
+
values: {
|
|
400
|
+
status: 'completed',
|
|
401
|
+
generatedMarkdown: markdown,
|
|
402
|
+
generatedHtml: html,
|
|
403
|
+
buildLog: null,
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
} catch (error: any) {
|
|
407
|
+
await pageRepo.update({
|
|
408
|
+
filterByTk: pageId,
|
|
409
|
+
values: {
|
|
410
|
+
status: 'error',
|
|
411
|
+
buildLog: error.message || String(error),
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const completedPages = await pageRepo.find({
|
|
419
|
+
filter: {
|
|
420
|
+
spaceId: filterByTk,
|
|
421
|
+
status: 'completed',
|
|
422
|
+
},
|
|
423
|
+
sort: ['sort'],
|
|
424
|
+
});
|
|
425
|
+
const combinedMarkdown = completedPages
|
|
426
|
+
.map((page: any) => page.get('generatedMarkdown'))
|
|
427
|
+
.filter(Boolean)
|
|
428
|
+
.join('\n\n---\n\n');
|
|
429
|
+
const combinedHtml = await markdownToCleanHtml(combinedMarkdown);
|
|
430
|
+
|
|
431
|
+
await spaceRepo.update({
|
|
432
|
+
filterByTk,
|
|
433
|
+
values: {
|
|
434
|
+
status: 'completed',
|
|
435
|
+
buildPhase: 'completed',
|
|
436
|
+
buildLog: `Built ${completedPages.length} chapters successfully`,
|
|
437
|
+
generatedMarkdown: combinedMarkdown,
|
|
438
|
+
generatedHtml: combinedHtml,
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export async function build(ctx: Context, next: Next) {
|
|
444
|
+
const { filterByTk } = ctx.action.params;
|
|
445
|
+
const repository = ctx.db.getRepository('aiBuildGuideSpaces') as Repository;
|
|
446
|
+
|
|
447
|
+
const space = await repository.findById(filterByTk);
|
|
448
|
+
|
|
449
|
+
if (!space) {
|
|
450
|
+
ctx.throw(404, 'Space not found');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (space.get('status') === 'building') {
|
|
454
|
+
ctx.throw(409, 'A build is already in progress for this space');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const app = ctx.app;
|
|
458
|
+
const db = ctx.db;
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
await repository.update({
|
|
462
|
+
filterByTk,
|
|
463
|
+
values: {
|
|
464
|
+
status: 'building',
|
|
465
|
+
buildPhase: 'queued',
|
|
466
|
+
buildLog: 'Build queued',
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
runBuild(app, db, filterByTk).catch(async (error) => {
|
|
471
|
+
app.log.error('Build Guide Background Error', error);
|
|
472
|
+
try {
|
|
473
|
+
await repository.update({
|
|
474
|
+
filterByTk,
|
|
475
|
+
values: {
|
|
476
|
+
status: 'error',
|
|
477
|
+
buildPhase: 'error',
|
|
478
|
+
buildLog: error.message || String(error),
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
} catch (updateErr) {
|
|
482
|
+
app.log.error('Failed to persist build error status', updateErr);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
ctx.body = { status: 'building' };
|
|
487
|
+
} catch (error: any) {
|
|
488
|
+
app.log.error('Build Guide Error', error);
|
|
489
|
+
await repository.update({
|
|
490
|
+
filterByTk,
|
|
491
|
+
values: {
|
|
492
|
+
status: 'error',
|
|
493
|
+
buildPhase: 'error',
|
|
494
|
+
buildLog: error.message || String(error),
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
ctx.throw(500, error.message || 'Error occurred during build');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
await next();
|
|
501
|
+
}
|