plugin-build-guide-block 1.1.5 → 1.1.7
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/dist/client/index.js +2 -2
- package/dist/client-v2/73.6b8b2eda7d969c69.js +10 -0
- package/dist/client-v2/index.js +10 -0
- package/dist/externalVersion.js +9 -8
- package/dist/node_modules/sanitize-html/index.js +2 -2
- package/dist/node_modules/sanitize-html/package.json +1 -1
- package/dist/server/actions/build.js +687 -85
- package/dist/server/collections/ai-build-guide-spaces.js +20 -0
- package/dist/server/plugin.js +21 -19
- package/dist/server/tools/search-guides.js +41 -30
- package/package.json +7 -3
- package/src/client/components/BuildButton.tsx +20 -9
- package/src/client-v2/plugin.tsx +24 -0
- package/src/server/actions/build.ts +774 -88
- package/src/server/collections/ai-build-guide-spaces.ts +77 -57
- package/src/server/plugin.ts +170 -163
- package/src/server/tools/search-guides.ts +113 -95
- package/dist/client/UserGuideBlock.d.ts +0 -2
- package/dist/client/UserGuideBlockInitializer.d.ts +0 -2
- package/dist/client/UserGuideBlockProvider.d.ts +0 -2
- package/dist/client/UserGuideManager.d.ts +0 -2
- package/dist/client/components/BuildButton.d.ts +0 -2
- package/dist/client/components/LLMServiceSelect.d.ts +0 -2
- package/dist/client/components/ModelSelect.d.ts +0 -2
- package/dist/client/components/SpaceSelect.d.ts +0 -2
- package/dist/client/components/StatusTag.d.ts +0 -2
- package/dist/client/locale.d.ts +0 -3
- package/dist/client/models/UserGuideBlockModel.d.ts +0 -9
- package/dist/client/models/index.d.ts +0 -9
- package/dist/client/plugin.d.ts +0 -5
- package/dist/client/schemaSettings.d.ts +0 -2
- package/dist/client/schemas/spacesSchema.d.ts +0 -437
- package/dist/index.d.ts +0 -2
- package/dist/locale/namespace.d.ts +0 -6
- package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.browser.cjs +0 -34
- package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.browser.js +0 -34
- package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.cjs +0 -35
- package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.d.ts +0 -56
- package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.js +0 -35
- package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.native.js +0 -26
- package/dist/node_modules/sanitize-html/node_modules/nanoid/async/package.json +0 -12
- package/dist/node_modules/sanitize-html/node_modules/nanoid/bin/nanoid.cjs +0 -55
- package/dist/node_modules/sanitize-html/node_modules/nanoid/index.browser.cjs +0 -34
- package/dist/node_modules/sanitize-html/node_modules/nanoid/index.browser.js +0 -34
- package/dist/node_modules/sanitize-html/node_modules/nanoid/index.cjs +0 -45
- package/dist/node_modules/sanitize-html/node_modules/nanoid/index.d.cts +0 -91
- package/dist/node_modules/sanitize-html/node_modules/nanoid/index.d.ts +0 -91
- package/dist/node_modules/sanitize-html/node_modules/nanoid/index.js +0 -45
- package/dist/node_modules/sanitize-html/node_modules/nanoid/nanoid.js +0 -1
- package/dist/node_modules/sanitize-html/node_modules/nanoid/non-secure/index.cjs +0 -21
- package/dist/node_modules/sanitize-html/node_modules/nanoid/non-secure/index.d.ts +0 -33
- package/dist/node_modules/sanitize-html/node_modules/nanoid/non-secure/index.js +0 -21
- package/dist/node_modules/sanitize-html/node_modules/nanoid/non-secure/package.json +0 -6
- package/dist/node_modules/sanitize-html/node_modules/nanoid/package.json +0 -88
- package/dist/node_modules/sanitize-html/node_modules/nanoid/url-alphabet/index.cjs +0 -3
- package/dist/node_modules/sanitize-html/node_modules/nanoid/url-alphabet/index.js +0 -3
- package/dist/node_modules/sanitize-html/node_modules/nanoid/url-alphabet/package.json +0 -6
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/at-rule.d.ts +0 -115
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/at-rule.js +0 -25
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/comment.d.ts +0 -67
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/comment.js +0 -13
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/container.d.ts +0 -452
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/container.js +0 -439
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/css-syntax-error.d.ts +0 -248
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/css-syntax-error.js +0 -100
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/declaration.d.ts +0 -148
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/declaration.js +0 -24
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/document.d.ts +0 -68
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/document.js +0 -33
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/fromJSON.d.ts +0 -9
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/fromJSON.js +0 -54
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/input.d.ts +0 -194
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/input.js +0 -248
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/lazy-result.d.ts +0 -190
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/lazy-result.js +0 -550
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/list.d.ts +0 -57
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/list.js +0 -58
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/map-generator.js +0 -359
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/no-work-result.d.ts +0 -46
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/no-work-result.js +0 -135
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/node.d.ts +0 -536
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/node.js +0 -381
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/parse.d.ts +0 -9
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/parse.js +0 -42
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/parser.js +0 -610
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/postcss.d.mts +0 -72
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/postcss.d.ts +0 -441
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/postcss.js +0 -101
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/previous-map.d.ts +0 -81
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/previous-map.js +0 -142
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/processor.d.ts +0 -115
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/processor.js +0 -67
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/result.d.ts +0 -206
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/result.js +0 -42
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/root.d.ts +0 -86
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/root.js +0 -61
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/rule.d.ts +0 -113
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/rule.js +0 -27
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/stringifier.d.ts +0 -46
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/stringifier.js +0 -353
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/stringify.d.ts +0 -9
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/stringify.js +0 -11
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/symbols.js +0 -5
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/terminal-highlight.js +0 -70
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/tokenize.js +0 -266
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/warn-once.js +0 -13
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/warning.d.ts +0 -147
- package/dist/node_modules/sanitize-html/node_modules/postcss/lib/warning.js +0 -37
- package/dist/node_modules/sanitize-html/node_modules/postcss/node_modules/.bin/nanoid +0 -15
- package/dist/node_modules/sanitize-html/node_modules/postcss/node_modules/.bin/nanoid.cmd +0 -7
- package/dist/node_modules/sanitize-html/node_modules/postcss/package.json +0 -88
- package/dist/server/actions/build.d.ts +0 -2
- package/dist/server/actions/getHtml.d.ts +0 -2
- package/dist/server/actions/getMarkdown.d.ts +0 -2
- package/dist/server/collections/ai-build-guide-pages.d.ts +0 -2
- package/dist/server/collections/ai-build-guide-spaces.d.ts +0 -2
- package/dist/server/index.d.ts +0 -2
- package/dist/server/plugin.d.ts +0 -16
- package/dist/server/tools/index.d.ts +0 -1
- package/dist/server/tools/search-guides.d.ts +0 -28
- /package/{dist/client/index.d.ts → src/client-v2/index.tsx} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Context, Next } from '@nocobase/actions';
|
|
2
2
|
import { Repository } from '@nocobase/database';
|
|
3
|
+
import type { Application } from '@nocobase/server';
|
|
3
4
|
import sanitizeHtml from 'sanitize-html';
|
|
4
5
|
// @ts-ignore
|
|
5
6
|
import { PluginAIServer } from '@nocobase/plugin-ai';
|
|
@@ -16,12 +17,98 @@ const MAX_SOURCE_CHARS = 90000;
|
|
|
16
17
|
const MIN_CHAPTERS = 1;
|
|
17
18
|
const MAX_CHAPTERS = 12;
|
|
18
19
|
const DEFAULT_TARGET_CHAPTERS = 5;
|
|
20
|
+
export const WORKER_JOB_BUILD_GUIDE_PROCESS = 'build-guide:process';
|
|
21
|
+
|
|
22
|
+
const BUILD_GUIDE_QUEUE_CHANNEL = 'plugin-build-guide-block.build';
|
|
23
|
+
const BUILD_GUIDE_QUEUE_CONCURRENCY = Math.max(
|
|
24
|
+
1,
|
|
25
|
+
Number.parseInt(process.env.BUILD_GUIDE_QUEUE_CONCURRENCY || process.env.BUILD_GUIDE_MAX_CONCURRENCY || '1', 10) || 1,
|
|
26
|
+
);
|
|
27
|
+
const BUILD_GUIDE_QUEUE_TIMEOUT_MS = Math.max(
|
|
28
|
+
60_000,
|
|
29
|
+
Number.parseInt(process.env.BUILD_GUIDE_QUEUE_TIMEOUT_MS || '', 10) || 30 * 60 * 1000,
|
|
30
|
+
);
|
|
31
|
+
const BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS = Math.max(
|
|
32
|
+
1000,
|
|
33
|
+
Number.parseInt(process.env.BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS || '', 10) || 5000,
|
|
34
|
+
);
|
|
35
|
+
const BUILD_GUIDE_QUEUE_WAKE_CHANNEL = 'plugin-build-guide-block.build.wake';
|
|
36
|
+
const BUILD_GUIDE_QUEUE_REDIS_CONNECTION = 'plugin-build-guide-block.build.queue';
|
|
37
|
+
const BUILD_TRIGGER_LOCK_TTL_MS = 30_000;
|
|
38
|
+
const BUILD_RUN_LOCK_TTL_MS = Math.max(
|
|
39
|
+
60_000,
|
|
40
|
+
Number.parseInt(process.env.BUILD_GUIDE_RUN_LOCK_TTL_MS || '', 10) || 24 * 60 * 60 * 1000,
|
|
41
|
+
);
|
|
42
|
+
const BUILD_HEARTBEAT_INTERVAL_MS = Math.max(
|
|
43
|
+
5_000,
|
|
44
|
+
Number.parseInt(process.env.BUILD_GUIDE_HEARTBEAT_MS || '', 10) || 30_000,
|
|
45
|
+
);
|
|
46
|
+
const BUILD_STALE_MS = Math.max(
|
|
47
|
+
BUILD_HEARTBEAT_INTERVAL_MS * 2,
|
|
48
|
+
Number.parseInt(process.env.BUILD_GUIDE_STALE_MS || '', 10) || 120_000,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const TEXT_EXTENSIONS = new Set([
|
|
52
|
+
'.txt',
|
|
53
|
+
'.md',
|
|
54
|
+
'.markdown',
|
|
55
|
+
'.csv',
|
|
56
|
+
'.tsv',
|
|
57
|
+
'.json',
|
|
58
|
+
'.xml',
|
|
59
|
+
'.html',
|
|
60
|
+
'.htm',
|
|
61
|
+
'.yaml',
|
|
62
|
+
'.yml',
|
|
63
|
+
'.log',
|
|
64
|
+
'.sql',
|
|
65
|
+
'.js',
|
|
66
|
+
'.jsx',
|
|
67
|
+
'.ts',
|
|
68
|
+
'.tsx',
|
|
69
|
+
'.css',
|
|
70
|
+
'.scss',
|
|
71
|
+
'.less',
|
|
72
|
+
]);
|
|
73
|
+
const TEXT_MIMETYPES = new Set([
|
|
74
|
+
'application/json',
|
|
75
|
+
'application/xml',
|
|
76
|
+
'application/yaml',
|
|
77
|
+
'application/x-yaml',
|
|
78
|
+
'application/javascript',
|
|
79
|
+
'application/typescript',
|
|
80
|
+
'image/svg+xml',
|
|
81
|
+
]);
|
|
19
82
|
|
|
20
83
|
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
|
|
21
84
|
allowedTags: [
|
|
22
|
-
'div',
|
|
23
|
-
'
|
|
24
|
-
'
|
|
85
|
+
'div',
|
|
86
|
+
'p',
|
|
87
|
+
'h1',
|
|
88
|
+
'h2',
|
|
89
|
+
'h3',
|
|
90
|
+
'h4',
|
|
91
|
+
'h5',
|
|
92
|
+
'h6',
|
|
93
|
+
'ul',
|
|
94
|
+
'ol',
|
|
95
|
+
'li',
|
|
96
|
+
'table',
|
|
97
|
+
'thead',
|
|
98
|
+
'tbody',
|
|
99
|
+
'tr',
|
|
100
|
+
'td',
|
|
101
|
+
'th',
|
|
102
|
+
'a',
|
|
103
|
+
'img',
|
|
104
|
+
'span',
|
|
105
|
+
'strong',
|
|
106
|
+
'em',
|
|
107
|
+
'code',
|
|
108
|
+
'pre',
|
|
109
|
+
'blockquote',
|
|
110
|
+
'br',
|
|
111
|
+
'hr',
|
|
25
112
|
],
|
|
26
113
|
allowedAttributes: {
|
|
27
114
|
a: ['href', 'target'],
|
|
@@ -49,13 +136,115 @@ type GuidePlan = {
|
|
|
49
136
|
chapters: GuidePlanItem[];
|
|
50
137
|
};
|
|
51
138
|
|
|
139
|
+
type BuildGuideQueueMessage = {
|
|
140
|
+
spaceId: string;
|
|
141
|
+
runId: string;
|
|
142
|
+
userId?: number | string | null;
|
|
143
|
+
queuedAt?: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type BuildRunContext = {
|
|
147
|
+
spaceId: string;
|
|
148
|
+
runId: string;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
let buildQueueTimer: NodeJS.Timeout | null = null;
|
|
152
|
+
let buildQueueKickTimer: NodeJS.Timeout | null = null;
|
|
153
|
+
let buildQueueProcessing = false;
|
|
154
|
+
let buildQueueWakeHandler: ((message?: any) => Promise<void>) | null = null;
|
|
155
|
+
|
|
156
|
+
class StaleBuildRunError extends Error {
|
|
157
|
+
constructor(spaceId: string, runId: string) {
|
|
158
|
+
super(`Build run ${runId} for space ${spaceId} is no longer current`);
|
|
159
|
+
this.name = 'StaleBuildRunError';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
52
163
|
function clampChapterCount(value: unknown) {
|
|
53
164
|
const count = Number(value);
|
|
54
165
|
if (!Number.isFinite(count)) return DEFAULT_TARGET_CHAPTERS;
|
|
55
166
|
return Math.max(MIN_CHAPTERS, Math.min(MAX_CHAPTERS, Math.round(count)));
|
|
56
167
|
}
|
|
57
168
|
|
|
58
|
-
|
|
169
|
+
function resolveExtname(file: any) {
|
|
170
|
+
const explicit = file?.extname;
|
|
171
|
+
if (typeof explicit === 'string' && explicit) return explicit.toLowerCase();
|
|
172
|
+
const name = file?.filename || file?.name || '';
|
|
173
|
+
const index = String(name).lastIndexOf('.');
|
|
174
|
+
return index >= 0 ? String(name).slice(index).toLowerCase() : '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isTextDocument(file: any) {
|
|
178
|
+
const mimetype = String(file?.mimetype || '').toLowerCase();
|
|
179
|
+
if (mimetype.startsWith('text/')) return true;
|
|
180
|
+
if (TEXT_MIMETYPES.has(mimetype)) return true;
|
|
181
|
+
return TEXT_EXTENSIONS.has(resolveExtname(file));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function createParserContext(app: any) {
|
|
185
|
+
const headers: Record<string, string> = { 'x-timezone': '+00:00', 'x-locale': 'en-US' };
|
|
186
|
+
return {
|
|
187
|
+
app,
|
|
188
|
+
db: app.db,
|
|
189
|
+
log: app.log || app.logger || console,
|
|
190
|
+
logger: app.logger || app.log || console,
|
|
191
|
+
state: {},
|
|
192
|
+
auth: {},
|
|
193
|
+
req: { headers },
|
|
194
|
+
request: { headers },
|
|
195
|
+
get(name: string) {
|
|
196
|
+
return headers[String(name).toLowerCase()] || '';
|
|
197
|
+
},
|
|
198
|
+
getCurrentLocale() {
|
|
199
|
+
return 'en-US';
|
|
200
|
+
},
|
|
201
|
+
t(key: string) {
|
|
202
|
+
return key;
|
|
203
|
+
},
|
|
204
|
+
i18n: {
|
|
205
|
+
t(key: string) {
|
|
206
|
+
return key;
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function extractParsedText(value: any): string {
|
|
213
|
+
if (!value) return '';
|
|
214
|
+
if (typeof value === 'string') return value;
|
|
215
|
+
if (Array.isArray(value)) {
|
|
216
|
+
return value.map(extractParsedText).filter(Boolean).join('\n');
|
|
217
|
+
}
|
|
218
|
+
if (typeof value === 'object') {
|
|
219
|
+
if (typeof value.text === 'string') return value.text;
|
|
220
|
+
if (typeof value.content === 'string') return value.content;
|
|
221
|
+
if (value.content) return extractParsedText(value.content);
|
|
222
|
+
if (value.message) return extractParsedText(value.message);
|
|
223
|
+
}
|
|
224
|
+
return '';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function getDocumentParserPlugin(app: any) {
|
|
228
|
+
return app.pm?.get?.('@nocobase/plugin-document-parser') || app.pm?.get?.('plugin-document-parser') || null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function unsupportedDocumentMessage(file: any) {
|
|
232
|
+
const filename = file?.filename || file?.name || file?.id || 'document';
|
|
233
|
+
const type = file?.mimetype || resolveExtname(file) || 'unknown type';
|
|
234
|
+
return `[Unsupported document type: ${filename} (${type}). Install or enable plugin-document-parser/MarkItDown to extract this file.]`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function fetchTextFileContent(app: any, file: any): Promise<string> {
|
|
238
|
+
if (!isTextDocument(file)) {
|
|
239
|
+
return unsupportedDocumentMessage(file);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const docParserPlugin = getDocumentParserPlugin(app);
|
|
243
|
+
if (docParserPlugin?.fetchFileBuffer) {
|
|
244
|
+
const { buffer } = await docParserPlugin.fetchFileBuffer(createParserContext(app), file);
|
|
245
|
+
return buffer.toString('utf8');
|
|
246
|
+
}
|
|
247
|
+
|
|
59
248
|
const fileManager = app.pm.get('file-manager') as PluginFileManagerServer;
|
|
60
249
|
if (!fileManager) return '';
|
|
61
250
|
const url = await fileManager.getFileURL(file);
|
|
@@ -78,6 +267,47 @@ async function fetchFileContent(app: any, file: any): Promise<string> {
|
|
|
78
267
|
}
|
|
79
268
|
}
|
|
80
269
|
|
|
270
|
+
async function parseWithDocumentParser(app: any, file: any): Promise<string> {
|
|
271
|
+
const docParserPlugin = getDocumentParserPlugin(app);
|
|
272
|
+
if (!docParserPlugin) return '';
|
|
273
|
+
|
|
274
|
+
const parserCtx = createParserContext(app);
|
|
275
|
+
const defaultParser = async () => ({
|
|
276
|
+
placement: 'contentBlocks',
|
|
277
|
+
content: {
|
|
278
|
+
type: 'text',
|
|
279
|
+
text: await fetchTextFileContent(app, file),
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
if (docParserPlugin.parseRouter?.route) {
|
|
285
|
+
const result = await docParserPlugin.parseRouter.route(parserCtx, file, defaultParser);
|
|
286
|
+
const text = extractParsedText(result?.content);
|
|
287
|
+
if (text && !text.startsWith('[Unsupported document type:')) {
|
|
288
|
+
return text;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (docParserPlugin.internalParserRegistry?.parse) {
|
|
293
|
+
const result = await docParserPlugin.internalParserRegistry.parse(file, parserCtx);
|
|
294
|
+
if (result?.handled && result?.text?.trim()) {
|
|
295
|
+
return result.text;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
app.log?.warn?.(`[plugin-build-guide-block] Document parser failed for ${file?.filename || file?.id}`, err);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return '';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function fetchFileContent(app: any, file: any): Promise<string> {
|
|
306
|
+
const parsedText = await parseWithDocumentParser(app, file);
|
|
307
|
+
if (parsedText) return parsedText;
|
|
308
|
+
return fetchTextFileContent(app, file);
|
|
309
|
+
}
|
|
310
|
+
|
|
81
311
|
function toPlainText(value: unknown) {
|
|
82
312
|
if (typeof value === 'string') return value;
|
|
83
313
|
if (Array.isArray(value)) {
|
|
@@ -98,6 +328,10 @@ function toPlainText(value: unknown) {
|
|
|
98
328
|
return JSON.stringify(value);
|
|
99
329
|
}
|
|
100
330
|
|
|
331
|
+
function stripThink(text: string) {
|
|
332
|
+
return text.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, '').trim();
|
|
333
|
+
}
|
|
334
|
+
|
|
101
335
|
function stripFence(text: string) {
|
|
102
336
|
return text
|
|
103
337
|
.replace(/^```(?:json|markdown|md|html)?\s*/i, '')
|
|
@@ -168,7 +402,7 @@ function createFallbackPlan(guideTitle: string, targetChapterCount: number): Gui
|
|
|
168
402
|
|
|
169
403
|
function normalizePlan(rawText: string, guideTitle: string, targetChapterCount: number): GuidePlan {
|
|
170
404
|
const targetCount = clampChapterCount(targetChapterCount);
|
|
171
|
-
const cleanText = stripFence(rawText);
|
|
405
|
+
const cleanText = stripFence(stripThink(rawText));
|
|
172
406
|
const jsonStart = cleanText.indexOf('{');
|
|
173
407
|
const jsonEnd = cleanText.lastIndexOf('}');
|
|
174
408
|
const jsonText = jsonStart >= 0 && jsonEnd > jsonStart ? cleanText.slice(jsonStart, jsonEnd + 1) : cleanText;
|
|
@@ -250,7 +484,13 @@ ${documentsText.slice(0, MAX_SOURCE_CHARS)}`),
|
|
|
250
484
|
return normalizePlan(toPlainText(response.content), title || 'User guide', targetCount);
|
|
251
485
|
}
|
|
252
486
|
|
|
253
|
-
async function buildPageMarkdown(
|
|
487
|
+
async function buildPageMarkdown(
|
|
488
|
+
provider: any,
|
|
489
|
+
space: any,
|
|
490
|
+
plan: GuidePlan,
|
|
491
|
+
chapter: GuidePlanItem,
|
|
492
|
+
documentsText: string,
|
|
493
|
+
) {
|
|
254
494
|
const { title, systemPrompt } = space.get();
|
|
255
495
|
const messages = [];
|
|
256
496
|
if (systemPrompt) {
|
|
@@ -278,7 +518,7 @@ Source documents:
|
|
|
278
518
|
${documentsText.slice(0, MAX_SOURCE_CHARS)}`),
|
|
279
519
|
);
|
|
280
520
|
const response = await provider.chatModel.invoke(messages);
|
|
281
|
-
return stripFence(toPlainText(response.content));
|
|
521
|
+
return stripFence(stripThink(toPlainText(response.content)));
|
|
282
522
|
}
|
|
283
523
|
|
|
284
524
|
async function markdownToCleanHtml(markdown: string) {
|
|
@@ -301,15 +541,85 @@ async function readDocuments(app: any, space: any) {
|
|
|
301
541
|
return texts.join('\n');
|
|
302
542
|
}
|
|
303
543
|
|
|
304
|
-
|
|
544
|
+
function getSpaceModel(app: any) {
|
|
545
|
+
return app.db.getModel('aiBuildGuideSpaces');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function updateSpaceForRun(app: any, run: BuildRunContext, values: Record<string, any>, optional = false) {
|
|
549
|
+
const SpaceModel = getSpaceModel(app);
|
|
550
|
+
const [affected] = await SpaceModel.update(values, {
|
|
551
|
+
where: {
|
|
552
|
+
id: run.spaceId,
|
|
553
|
+
buildRunId: run.runId,
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
if (!affected && !optional) {
|
|
557
|
+
throw new StaleBuildRunError(run.spaceId, run.runId);
|
|
558
|
+
}
|
|
559
|
+
return affected > 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function claimBuildRun(app: any, run: BuildRunContext, workerId: string) {
|
|
563
|
+
const now = new Date();
|
|
564
|
+
const SpaceModel = getSpaceModel(app);
|
|
565
|
+
const [affected] = await SpaceModel.update(
|
|
566
|
+
{
|
|
567
|
+
buildPhase: 'running',
|
|
568
|
+
buildStartedAt: now,
|
|
569
|
+
buildHeartbeatAt: now,
|
|
570
|
+
buildWorkerId: workerId,
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
where: {
|
|
574
|
+
id: run.spaceId,
|
|
575
|
+
status: 'building',
|
|
576
|
+
buildPhase: 'queued',
|
|
577
|
+
buildRunId: run.runId,
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
);
|
|
581
|
+
return affected > 0;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function getBuildWorkerId(app: any) {
|
|
585
|
+
return [
|
|
586
|
+
process.env.HOSTNAME || process.env.COMPUTERNAME || 'worker',
|
|
587
|
+
app.name || 'app',
|
|
588
|
+
app.instanceId || '0',
|
|
589
|
+
process.pid,
|
|
590
|
+
].join(':');
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function startBuildHeartbeat(app: any, run: BuildRunContext) {
|
|
594
|
+
const timer = setInterval(() => {
|
|
595
|
+
updateSpaceForRun(
|
|
596
|
+
app,
|
|
597
|
+
run,
|
|
598
|
+
{
|
|
599
|
+
buildHeartbeatAt: new Date(),
|
|
600
|
+
},
|
|
601
|
+
true,
|
|
602
|
+
).catch((error) => {
|
|
603
|
+
app.log?.warn?.(`[plugin-build-guide-block] Failed to update heartbeat for build ${run.runId}`, error);
|
|
604
|
+
});
|
|
605
|
+
}, BUILD_HEARTBEAT_INTERVAL_MS);
|
|
606
|
+
|
|
607
|
+
return () => clearInterval(timer);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function runBuild(app: any, db: any, run: BuildRunContext) {
|
|
305
611
|
const spaceRepo = db.getRepository('aiBuildGuideSpaces') as Repository;
|
|
306
612
|
const pageRepo = db.getRepository('aiBuildGuidePages') as Repository;
|
|
307
|
-
const space = await spaceRepo.findById(
|
|
613
|
+
const space = await spaceRepo.findById(run.spaceId);
|
|
308
614
|
|
|
309
615
|
if (!space) {
|
|
310
616
|
throw new Error('Space not found');
|
|
311
617
|
}
|
|
312
618
|
|
|
619
|
+
if (space.get('buildRunId') !== run.runId) {
|
|
620
|
+
throw new StaleBuildRunError(run.spaceId, run.runId);
|
|
621
|
+
}
|
|
622
|
+
|
|
313
623
|
const { llmService, model } = space.get();
|
|
314
624
|
if (!llmService || !model) {
|
|
315
625
|
throw new Error('LLM Service or model is missing in space configuration');
|
|
@@ -317,51 +627,42 @@ async function runBuild(app: any, db: any, filterByTk: string) {
|
|
|
317
627
|
|
|
318
628
|
await pageRepo.destroy({
|
|
319
629
|
filter: {
|
|
320
|
-
spaceId:
|
|
630
|
+
spaceId: run.spaceId,
|
|
321
631
|
},
|
|
322
632
|
});
|
|
323
633
|
|
|
324
|
-
await
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
planJson: null,
|
|
332
|
-
pageCount: 0,
|
|
333
|
-
},
|
|
634
|
+
await updateSpaceForRun(app, run, {
|
|
635
|
+
buildPhase: 'reading',
|
|
636
|
+
buildLog: 'Reading source documents',
|
|
637
|
+
generatedHtml: null,
|
|
638
|
+
generatedMarkdown: null,
|
|
639
|
+
planJson: null,
|
|
640
|
+
pageCount: 0,
|
|
334
641
|
});
|
|
335
642
|
|
|
336
643
|
const documentsText = await readDocuments(app, space);
|
|
337
644
|
const sourceHash = crypto.createHash('sha256').update(documentsText).digest('hex');
|
|
338
645
|
const provider = await getLLMProvider(app, llmService, model);
|
|
339
646
|
|
|
340
|
-
await
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
buildLog: 'Creating guide breakdown plan',
|
|
345
|
-
sourceHash,
|
|
346
|
-
},
|
|
647
|
+
await updateSpaceForRun(app, run, {
|
|
648
|
+
buildPhase: 'planning',
|
|
649
|
+
buildLog: 'Creating guide breakdown plan',
|
|
650
|
+
sourceHash,
|
|
347
651
|
});
|
|
348
652
|
|
|
349
653
|
const plan = await buildPlan(provider, space, documentsText);
|
|
350
|
-
await
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
buildPhase: 'building_pages',
|
|
356
|
-
buildLog: `Plan created with ${plan.chapters.length} chapters`,
|
|
357
|
-
},
|
|
654
|
+
await updateSpaceForRun(app, run, {
|
|
655
|
+
planJson: plan,
|
|
656
|
+
pageCount: plan.chapters.length,
|
|
657
|
+
buildPhase: 'building_pages',
|
|
658
|
+
buildLog: `Plan created with ${plan.chapters.length} chapters`,
|
|
358
659
|
});
|
|
359
660
|
|
|
360
661
|
const pageRecords = [];
|
|
361
662
|
for (const [index, chapter] of plan.chapters.entries()) {
|
|
362
663
|
const page = await pageRepo.create({
|
|
363
664
|
values: {
|
|
364
|
-
spaceId:
|
|
665
|
+
spaceId: run.spaceId,
|
|
365
666
|
sort: index + 1,
|
|
366
667
|
title: chapter.title,
|
|
367
668
|
slug: slugify(chapter.title, `chapter-${index + 1}`),
|
|
@@ -383,12 +684,9 @@ async function runBuild(app: any, db: any, filterByTk: string) {
|
|
|
383
684
|
buildLog: 'Building chapter with LLM',
|
|
384
685
|
},
|
|
385
686
|
});
|
|
386
|
-
await
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
buildPhase: 'building_pages',
|
|
390
|
-
buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`,
|
|
391
|
-
},
|
|
687
|
+
await updateSpaceForRun(app, run, {
|
|
688
|
+
buildPhase: 'building_pages',
|
|
689
|
+
buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`,
|
|
392
690
|
});
|
|
393
691
|
|
|
394
692
|
try {
|
|
@@ -417,7 +715,7 @@ async function runBuild(app: any, db: any, filterByTk: string) {
|
|
|
417
715
|
|
|
418
716
|
const completedPages = await pageRepo.find({
|
|
419
717
|
filter: {
|
|
420
|
-
spaceId:
|
|
718
|
+
spaceId: run.spaceId,
|
|
421
719
|
status: 'completed',
|
|
422
720
|
},
|
|
423
721
|
sort: ['sort'],
|
|
@@ -428,74 +726,462 @@ async function runBuild(app: any, db: any, filterByTk: string) {
|
|
|
428
726
|
.join('\n\n---\n\n');
|
|
429
727
|
const combinedHtml = await markdownToCleanHtml(combinedMarkdown);
|
|
430
728
|
|
|
431
|
-
await
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
generatedHtml: combinedHtml,
|
|
439
|
-
},
|
|
729
|
+
await updateSpaceForRun(app, run, {
|
|
730
|
+
status: 'completed',
|
|
731
|
+
buildPhase: 'completed',
|
|
732
|
+
buildLog: `Built ${completedPages.length} chapters successfully`,
|
|
733
|
+
generatedMarkdown: combinedMarkdown,
|
|
734
|
+
generatedHtml: combinedHtml,
|
|
735
|
+
buildHeartbeatAt: new Date(),
|
|
440
736
|
});
|
|
441
737
|
}
|
|
442
738
|
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
739
|
+
function isBuildGuideWorker(app: Application) {
|
|
740
|
+
const workerMode = process.env.WORKER_MODE || '';
|
|
741
|
+
return (
|
|
742
|
+
app.serving(WORKER_JOB_BUILD_GUIDE_PROCESS) ||
|
|
743
|
+
workerMode === 'worker' ||
|
|
744
|
+
workerMode === 'task' ||
|
|
745
|
+
process.env.APP_ROLE === 'worker'
|
|
746
|
+
);
|
|
747
|
+
}
|
|
446
748
|
|
|
447
|
-
|
|
749
|
+
function clearLocalBuildMemoryQueue(app: Application) {
|
|
750
|
+
const eventQueue = (app as any).eventQueue;
|
|
751
|
+
const adapter = eventQueue?.adapter;
|
|
752
|
+
const fullChannel = eventQueue?.getFullChannel?.(BUILD_GUIDE_QUEUE_CHANNEL);
|
|
753
|
+
const queue = fullChannel ? adapter?.queues?.get?.(fullChannel) : null;
|
|
754
|
+
if (!queue?.length) return;
|
|
448
755
|
|
|
449
|
-
|
|
450
|
-
|
|
756
|
+
adapter.queues.set(fullChannel, []);
|
|
757
|
+
app.log?.warn?.(
|
|
758
|
+
`[plugin-build-guide-block] Cleared ${queue.length} stale local memory message(s) on non-worker node; queued DB builds will be picked up by workers`,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function getBuildQueueRedisKey(app: Application): string {
|
|
763
|
+
const appName = (app as any).name || process.env.APP_NAME || 'main';
|
|
764
|
+
return `${appName}:plugin-build-guide-block:build:queue`;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function getBuildQueueRedis(app: Application): Promise<any | null> {
|
|
768
|
+
const manager = (app as any).redisConnectionManager;
|
|
769
|
+
if (!manager?.getConnectionSync) {
|
|
770
|
+
return null;
|
|
451
771
|
}
|
|
452
772
|
|
|
453
|
-
|
|
454
|
-
|
|
773
|
+
try {
|
|
774
|
+
const connectionString = process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
|
|
775
|
+
return await manager.getConnectionSync(
|
|
776
|
+
BUILD_GUIDE_QUEUE_REDIS_CONNECTION,
|
|
777
|
+
connectionString ? { connectionString } : undefined,
|
|
778
|
+
);
|
|
779
|
+
} catch (error: any) {
|
|
780
|
+
app.log?.debug?.(
|
|
781
|
+
`[plugin-build-guide-block] Redis queue unavailable; DB polling fallback active: ${error?.message || error}`,
|
|
782
|
+
);
|
|
783
|
+
return null;
|
|
455
784
|
}
|
|
785
|
+
}
|
|
456
786
|
|
|
457
|
-
|
|
458
|
-
const
|
|
787
|
+
async function enqueueBuildToRedis(app: Application, message: BuildGuideQueueMessage): Promise<boolean> {
|
|
788
|
+
const redis = await getBuildQueueRedis(app);
|
|
789
|
+
if (!redis) return false;
|
|
459
790
|
|
|
460
791
|
try {
|
|
461
|
-
await
|
|
462
|
-
|
|
792
|
+
await redis.sendCommand(['RPUSH', getBuildQueueRedisKey(app), JSON.stringify(message)]);
|
|
793
|
+
app.log?.debug?.(
|
|
794
|
+
`[plugin-build-guide-block] Enqueued build ${message.runId} for space "${message.spaceId}" to Redis`,
|
|
795
|
+
);
|
|
796
|
+
return true;
|
|
797
|
+
} catch (error: any) {
|
|
798
|
+
app.log?.warn?.(`[plugin-build-guide-block] Failed to enqueue build to Redis; DB polling fallback active`, error);
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function publishBuildQueueWake(app: Application, message?: BuildGuideQueueMessage) {
|
|
804
|
+
try {
|
|
805
|
+
await (app as any).pubSubManager?.publish?.(
|
|
806
|
+
BUILD_GUIDE_QUEUE_WAKE_CHANNEL,
|
|
807
|
+
{ spaceId: message?.spaceId, runId: message?.runId },
|
|
808
|
+
{ skipSelf: !isBuildGuideWorker(app) },
|
|
809
|
+
);
|
|
810
|
+
} catch (error: any) {
|
|
811
|
+
app.log?.debug?.(`[plugin-build-guide-block] Wake publish skipped: ${error?.message || error}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function startBuildGuideQueueProcessor(app: Application) {
|
|
816
|
+
if (!isBuildGuideWorker(app)) {
|
|
817
|
+
app.log?.debug?.('[plugin-build-guide-block] Build queue processor disabled on non-worker node');
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (buildQueueTimer) return;
|
|
821
|
+
|
|
822
|
+
buildQueueWakeHandler = async () => {
|
|
823
|
+
scheduleBuildQueueTick(app, 0);
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const subscribe = (app as any).pubSubManager?.subscribe?.(BUILD_GUIDE_QUEUE_WAKE_CHANNEL, buildQueueWakeHandler);
|
|
827
|
+
if (subscribe?.catch) {
|
|
828
|
+
subscribe.catch((error: any) => {
|
|
829
|
+
app.log?.debug?.(`[plugin-build-guide-block] Wake subscribe skipped: ${error?.message || error}`);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
buildQueueTimer = setInterval(() => scheduleBuildQueueTick(app, 0), BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS);
|
|
834
|
+
(buildQueueTimer as any).unref?.();
|
|
835
|
+
scheduleBuildQueueTick(app, 1000);
|
|
836
|
+
app.log?.info?.(
|
|
837
|
+
`[plugin-build-guide-block] Build queue processor started (interval ${BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS}ms)`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function stopBuildGuideQueueProcessor(app: Application) {
|
|
842
|
+
if (buildQueueTimer) {
|
|
843
|
+
clearInterval(buildQueueTimer);
|
|
844
|
+
buildQueueTimer = null;
|
|
845
|
+
}
|
|
846
|
+
if (buildQueueKickTimer) {
|
|
847
|
+
clearTimeout(buildQueueKickTimer);
|
|
848
|
+
buildQueueKickTimer = null;
|
|
849
|
+
}
|
|
850
|
+
if (buildQueueWakeHandler) {
|
|
851
|
+
const unsubscribe = (app as any).pubSubManager?.unsubscribe?.(
|
|
852
|
+
BUILD_GUIDE_QUEUE_WAKE_CHANNEL,
|
|
853
|
+
buildQueueWakeHandler,
|
|
854
|
+
);
|
|
855
|
+
if (unsubscribe?.catch) {
|
|
856
|
+
unsubscribe.catch(() => undefined);
|
|
857
|
+
}
|
|
858
|
+
buildQueueWakeHandler = null;
|
|
859
|
+
}
|
|
860
|
+
buildQueueProcessing = false;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function scheduleBuildQueueTick(app: Application, delayMs: number) {
|
|
864
|
+
if (buildQueueKickTimer) return;
|
|
865
|
+
buildQueueKickTimer = setTimeout(() => {
|
|
866
|
+
buildQueueKickTimer = null;
|
|
867
|
+
runBuildQueueTick(app).catch((error) => {
|
|
868
|
+
app.log?.error?.('[plugin-build-guide-block] Build queue tick failed', error);
|
|
869
|
+
});
|
|
870
|
+
}, delayMs);
|
|
871
|
+
(buildQueueKickTimer as any).unref?.();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function runBuildQueueTick(app: Application) {
|
|
875
|
+
if (buildQueueProcessing || !isBuildGuideWorker(app)) return;
|
|
876
|
+
|
|
877
|
+
buildQueueProcessing = true;
|
|
878
|
+
try {
|
|
879
|
+
const redisMessages = await drainRedisBuildQueue(app, BUILD_GUIDE_QUEUE_CONCURRENCY);
|
|
880
|
+
await processBuildQueueMessages(app, redisMessages);
|
|
881
|
+
|
|
882
|
+
const remaining = Math.max(1, BUILD_GUIDE_QUEUE_CONCURRENCY - redisMessages.length);
|
|
883
|
+
await processQueuedBuildsFromDb(app, remaining);
|
|
884
|
+
} finally {
|
|
885
|
+
buildQueueProcessing = false;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function drainRedisBuildQueue(app: Application, count: number): Promise<BuildGuideQueueMessage[]> {
|
|
890
|
+
const redis = await getBuildQueueRedis(app);
|
|
891
|
+
if (!redis) return [];
|
|
892
|
+
|
|
893
|
+
const key = getBuildQueueRedisKey(app);
|
|
894
|
+
const messages: BuildGuideQueueMessage[] = [];
|
|
895
|
+
for (let i = 0; i < count; i += 1) {
|
|
896
|
+
const raw = await redis.sendCommand(['LPOP', key]);
|
|
897
|
+
if (!raw) break;
|
|
898
|
+
try {
|
|
899
|
+
messages.push(JSON.parse(String(raw)));
|
|
900
|
+
} catch (error: any) {
|
|
901
|
+
app.log?.warn?.(`[plugin-build-guide-block] Dropped invalid Redis build message: ${error?.message || error}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return messages;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function createBuildQueueMessageFromSpace(space: any): BuildGuideQueueMessage | null {
|
|
908
|
+
const runId = space.get('buildRunId');
|
|
909
|
+
if (!runId) return null;
|
|
910
|
+
return {
|
|
911
|
+
spaceId: String(space.get('id')),
|
|
912
|
+
runId: String(runId),
|
|
913
|
+
queuedAt: space.get('buildQueuedAt') ? new Date(space.get('buildQueuedAt')).toISOString() : undefined,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function processQueuedBuildsFromDb(app: Application, count: number) {
|
|
918
|
+
const spaceRepo = app.db.getRepository('aiBuildGuideSpaces') as Repository;
|
|
919
|
+
const spaces = await spaceRepo.find({
|
|
920
|
+
filter: {
|
|
921
|
+
status: 'building',
|
|
922
|
+
buildPhase: 'queued',
|
|
923
|
+
},
|
|
924
|
+
sort: ['buildQueuedAt'],
|
|
925
|
+
limit: count,
|
|
926
|
+
});
|
|
927
|
+
const messages = spaces.map(createBuildQueueMessageFromSpace).filter(Boolean) as BuildGuideQueueMessage[];
|
|
928
|
+
await processBuildQueueMessages(app, messages);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function processBuildQueueMessages(app: Application, messages: BuildGuideQueueMessage[]) {
|
|
932
|
+
if (!messages.length) return;
|
|
933
|
+
await Promise.all(messages.map((message) => processQueuedBuild(app, message)));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function markBuildError(app: Application, spaceId: string, runId: string | undefined, error: any) {
|
|
937
|
+
const buildLog = error?.message || String(error);
|
|
938
|
+
let updated = false;
|
|
939
|
+
if (runId) {
|
|
940
|
+
updated = await updateSpaceForRun(
|
|
941
|
+
app,
|
|
942
|
+
{ spaceId, runId },
|
|
943
|
+
{
|
|
944
|
+
status: 'error',
|
|
945
|
+
buildPhase: 'error',
|
|
946
|
+
buildLog,
|
|
947
|
+
buildHeartbeatAt: new Date(),
|
|
948
|
+
},
|
|
949
|
+
true,
|
|
950
|
+
);
|
|
951
|
+
} else {
|
|
952
|
+
await app.db.getRepository('aiBuildGuideSpaces').update({
|
|
953
|
+
filterByTk: spaceId,
|
|
463
954
|
values: {
|
|
464
|
-
status: '
|
|
465
|
-
buildPhase: '
|
|
466
|
-
buildLog
|
|
955
|
+
status: 'error',
|
|
956
|
+
buildPhase: 'error',
|
|
957
|
+
buildLog,
|
|
467
958
|
},
|
|
468
959
|
});
|
|
960
|
+
updated = true;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (!updated) {
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
469
966
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
967
|
+
await app.db.getRepository('aiBuildGuidePages').update({
|
|
968
|
+
filter: {
|
|
969
|
+
spaceId,
|
|
970
|
+
status: 'building',
|
|
971
|
+
},
|
|
972
|
+
values: {
|
|
973
|
+
status: 'error',
|
|
974
|
+
buildLog,
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function enqueueBuild(app: Application, message: BuildGuideQueueMessage) {
|
|
980
|
+
try {
|
|
981
|
+
const queuedInRedis = await enqueueBuildToRedis(app, message);
|
|
982
|
+
if (queuedInRedis) {
|
|
983
|
+
await publishBuildQueueWake(app, message);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
await publishBuildQueueWake(app, message);
|
|
988
|
+
|
|
989
|
+
if (isBuildGuideWorker(app)) {
|
|
990
|
+
await app.eventQueue.publish(BUILD_GUIDE_QUEUE_CHANNEL, message, {
|
|
991
|
+
timeout: BUILD_GUIDE_QUEUE_TIMEOUT_MS,
|
|
992
|
+
maxRetries: 0,
|
|
993
|
+
});
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
app.log?.warn?.(
|
|
998
|
+
`[plugin-build-guide-block] Redis queue is unavailable; build ${message.runId} for space "${message.spaceId}" will remain queued until a worker DB poller picks it up`,
|
|
999
|
+
);
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
await markBuildError(app, message.spaceId, message.runId, error);
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function processQueuedBuild(app: Application, message: BuildGuideQueueMessage) {
|
|
1007
|
+
const spaceId = message?.spaceId;
|
|
1008
|
+
const runId = message?.runId;
|
|
1009
|
+
if (!spaceId || !runId) {
|
|
1010
|
+
app.log?.warn?.('[plugin-build-guide-block] Build queue message missing spaceId or runId');
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
await withBuildRunLock(app, spaceId, async () => {
|
|
1015
|
+
const run = { spaceId, runId };
|
|
1016
|
+
const workerId = getBuildWorkerId(app);
|
|
1017
|
+
const claimed = await claimBuildRun(app, run, workerId);
|
|
1018
|
+
if (!claimed) {
|
|
1019
|
+
app.log?.info?.(`[plugin-build-guide-block] Build ${runId} for space "${spaceId}" was already claimed or stale`);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const spaceRepo = app.db.getRepository('aiBuildGuideSpaces') as Repository;
|
|
1024
|
+
const space = await spaceRepo.findById(spaceId);
|
|
1025
|
+
if (!space) {
|
|
1026
|
+
app.log?.warn?.(`[plugin-build-guide-block] Build space "${spaceId}" not found; skipping queued build`);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (space.get('status') !== 'building') {
|
|
1031
|
+
app.log?.info?.(
|
|
1032
|
+
`[plugin-build-guide-block] Build space "${spaceId}" is ${space.get('status')}; skipping queued build`,
|
|
1033
|
+
);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const stopHeartbeat = startBuildHeartbeat(app, run);
|
|
1038
|
+
try {
|
|
1039
|
+
await runBuild(app, app.db, run);
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
if (error instanceof StaleBuildRunError) {
|
|
1042
|
+
app.log?.info?.(`[plugin-build-guide-block] ${error.message}`);
|
|
1043
|
+
return;
|
|
483
1044
|
}
|
|
1045
|
+
app.log?.error?.('Build Guide Worker Error', error);
|
|
1046
|
+
await markBuildError(app, spaceId, runId, error);
|
|
1047
|
+
} finally {
|
|
1048
|
+
stopHeartbeat();
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
export function registerBuildGuideQueue(app: Application) {
|
|
1054
|
+
app.eventQueue.subscribe(BUILD_GUIDE_QUEUE_CHANNEL, {
|
|
1055
|
+
concurrency: BUILD_GUIDE_QUEUE_CONCURRENCY,
|
|
1056
|
+
idle: () => isBuildGuideWorker(app),
|
|
1057
|
+
process: async (message: BuildGuideQueueMessage) => {
|
|
1058
|
+
await processQueuedBuild(app, message);
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
1061
|
+
if (!isBuildGuideWorker(app)) {
|
|
1062
|
+
app.on('afterStart', () => clearLocalBuildMemoryQueue(app));
|
|
1063
|
+
}
|
|
1064
|
+
startBuildGuideQueueProcessor(app);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export function unregisterBuildGuideQueue(app: Application) {
|
|
1068
|
+
app.eventQueue.unsubscribe(BUILD_GUIDE_QUEUE_CHANNEL);
|
|
1069
|
+
stopBuildGuideQueueProcessor(app);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function withBuildTriggerLock<T>(app: Application, spaceId: string, fn: () => Promise<T>) {
|
|
1073
|
+
return app.lockManager.runExclusive(`build-guide:trigger:${spaceId}`, fn, BUILD_TRIGGER_LOCK_TTL_MS);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function withBuildRunLock<T>(app: Application, spaceId: string, fn: () => Promise<T>) {
|
|
1077
|
+
return app.lockManager.runExclusive(`build-guide:run:${spaceId}`, fn, BUILD_RUN_LOCK_TTL_MS);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export async function recoverInterruptedBuilds(app: Application) {
|
|
1081
|
+
const spaceRepo = app.db.getRepository('aiBuildGuideSpaces') as Repository;
|
|
1082
|
+
const pageRepo = app.db.getRepository('aiBuildGuidePages') as Repository;
|
|
1083
|
+
const staleBefore = new Date(Date.now() - BUILD_STALE_MS);
|
|
1084
|
+
const spaces = await spaceRepo.find({
|
|
1085
|
+
filter: {
|
|
1086
|
+
status: 'building',
|
|
1087
|
+
$or: [{ buildHeartbeatAt: null }, { buildHeartbeatAt: { $lt: staleBefore } }],
|
|
1088
|
+
},
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
for (const space of spaces) {
|
|
1092
|
+
const spaceId = String(space.get('id'));
|
|
1093
|
+
const runId = String(space.get('buildRunId') || crypto.randomUUID());
|
|
1094
|
+
const SpaceModel = getSpaceModel(app);
|
|
1095
|
+
const [affected] = await SpaceModel.update(
|
|
1096
|
+
{
|
|
1097
|
+
buildPhase: 'queued',
|
|
1098
|
+
buildLog: 'Build re-queued after worker restart',
|
|
1099
|
+
buildRunId: runId,
|
|
1100
|
+
buildQueuedAt: new Date(),
|
|
1101
|
+
buildStartedAt: null,
|
|
1102
|
+
buildHeartbeatAt: null,
|
|
1103
|
+
buildWorkerId: null,
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
where: {
|
|
1107
|
+
id: spaceId,
|
|
1108
|
+
status: 'building',
|
|
1109
|
+
buildRunId: space.get('buildRunId') || null,
|
|
1110
|
+
},
|
|
1111
|
+
},
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
if (!affected) {
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
await pageRepo.update({
|
|
1119
|
+
filter: {
|
|
1120
|
+
spaceId,
|
|
1121
|
+
status: 'building',
|
|
1122
|
+
},
|
|
1123
|
+
values: {
|
|
1124
|
+
status: 'pending',
|
|
1125
|
+
buildLog: 'Build re-queued after worker restart',
|
|
1126
|
+
},
|
|
1127
|
+
});
|
|
1128
|
+
await enqueueBuild(app, {
|
|
1129
|
+
spaceId,
|
|
1130
|
+
runId,
|
|
1131
|
+
queuedAt: new Date().toISOString(),
|
|
484
1132
|
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (spaces.length) {
|
|
1136
|
+
app.log?.info?.(`[plugin-build-guide-block] Re-queued ${spaces.length} interrupted build(s)`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
export async function build(ctx: Context, next: Next) {
|
|
1141
|
+
const { filterByTk } = ctx.action.params;
|
|
1142
|
+
if (!filterByTk) {
|
|
1143
|
+
ctx.throw(400, 'Space id is required');
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const app = ctx.app as Application;
|
|
1147
|
+
const repository = ctx.db.getRepository('aiBuildGuideSpaces') as Repository;
|
|
1148
|
+
|
|
1149
|
+
const body = await withBuildTriggerLock(app, String(filterByTk), async () => {
|
|
1150
|
+
const runId = crypto.randomUUID();
|
|
1151
|
+
const space = await repository.findById(filterByTk);
|
|
1152
|
+
|
|
1153
|
+
if (!space) {
|
|
1154
|
+
ctx.throw(404, 'Space not found');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (space.get('status') === 'building') {
|
|
1158
|
+
ctx.throw(409, 'A build is already in progress for this space');
|
|
1159
|
+
}
|
|
485
1160
|
|
|
486
|
-
ctx.body = { status: 'building' };
|
|
487
|
-
} catch (error: any) {
|
|
488
|
-
app.log.error('Build Guide Error', error);
|
|
489
1161
|
await repository.update({
|
|
490
1162
|
filterByTk,
|
|
491
1163
|
values: {
|
|
492
|
-
status: '
|
|
493
|
-
buildPhase: '
|
|
494
|
-
buildLog:
|
|
1164
|
+
status: 'building',
|
|
1165
|
+
buildPhase: 'queued',
|
|
1166
|
+
buildLog: 'Build queued',
|
|
1167
|
+
buildRunId: runId,
|
|
1168
|
+
buildQueuedAt: new Date(),
|
|
1169
|
+
buildStartedAt: null,
|
|
1170
|
+
buildHeartbeatAt: null,
|
|
1171
|
+
buildWorkerId: null,
|
|
495
1172
|
},
|
|
496
1173
|
});
|
|
497
|
-
ctx.throw(500, error.message || 'Error occurred during build');
|
|
498
|
-
}
|
|
499
1174
|
|
|
1175
|
+
await enqueueBuild(app, {
|
|
1176
|
+
spaceId: String(filterByTk),
|
|
1177
|
+
runId,
|
|
1178
|
+
userId: (ctx as any).state?.currentUser?.id ?? null,
|
|
1179
|
+
queuedAt: new Date().toISOString(),
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
return { status: 'building' };
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
ctx.body = body;
|
|
500
1186
|
await next();
|
|
501
1187
|
}
|