design-learn-server 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/package.json +29 -0
- package/src/cli.js +152 -0
- package/src/mcp/index.js +556 -0
- package/src/pipeline/index.js +335 -0
- package/src/playwrightSupport.js +65 -0
- package/src/preview/index.js +204 -0
- package/src/server.js +1385 -0
- package/src/stdio.js +464 -0
- package/src/storage/fileStore.js +45 -0
- package/src/storage/index.js +983 -0
- package/src/storage/paths.js +113 -0
- package/src/storage/sqliteStore.js +114 -0
- package/src/uipro/bm25.js +121 -0
- package/src/uipro/config.js +264 -0
- package/src/uipro/csv.js +90 -0
- package/src/uipro/data/charts.csv +26 -0
- package/src/uipro/data/colors.csv +97 -0
- package/src/uipro/data/icons.csv +101 -0
- package/src/uipro/data/landing.csv +31 -0
- package/src/uipro/data/products.csv +97 -0
- package/src/uipro/data/prompts.csv +24 -0
- package/src/uipro/data/stacks/flutter.csv +53 -0
- package/src/uipro/data/stacks/html-tailwind.csv +56 -0
- package/src/uipro/data/stacks/nextjs.csv +53 -0
- package/src/uipro/data/stacks/nuxt-ui.csv +51 -0
- package/src/uipro/data/stacks/nuxtjs.csv +59 -0
- package/src/uipro/data/stacks/react-native.csv +52 -0
- package/src/uipro/data/stacks/react.csv +54 -0
- package/src/uipro/data/stacks/shadcn.csv +61 -0
- package/src/uipro/data/stacks/svelte.csv +54 -0
- package/src/uipro/data/stacks/swiftui.csv +51 -0
- package/src/uipro/data/stacks/vue.csv +50 -0
- package/src/uipro/data/styles.csv +59 -0
- package/src/uipro/data/typography.csv +58 -0
- package/src/uipro/data/ux-guidelines.csv +100 -0
- package/src/uipro/index.js +581 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const { randomUUID } = require('crypto');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { pathToFileURL } = require('url');
|
|
5
|
+
const { ensurePlaywrightInstalled, loadPlaywright } = require('../playwrightSupport');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
8
|
+
|
|
9
|
+
function createExtractionPipeline({ storage }) {
|
|
10
|
+
const emitter = new EventEmitter();
|
|
11
|
+
const jobs = new Map();
|
|
12
|
+
const queue = [];
|
|
13
|
+
let running = false;
|
|
14
|
+
let closed = false;
|
|
15
|
+
|
|
16
|
+
function toPublicJob(job) {
|
|
17
|
+
return {
|
|
18
|
+
id: job.id,
|
|
19
|
+
type: job.type,
|
|
20
|
+
status: job.status,
|
|
21
|
+
progress: job.progress,
|
|
22
|
+
message: job.message,
|
|
23
|
+
createdAt: job.createdAt,
|
|
24
|
+
updatedAt: job.updatedAt,
|
|
25
|
+
result: job.result || null,
|
|
26
|
+
error: job.error || null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function emitProgress(job, extra = {}) {
|
|
31
|
+
job.updatedAt = new Date().toISOString();
|
|
32
|
+
emitter.emit('progress', {
|
|
33
|
+
job: toPublicJob(job),
|
|
34
|
+
event: extra.event || 'progress',
|
|
35
|
+
detail: extra.detail || null,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function enqueue(type, payload) {
|
|
40
|
+
if (closed) {
|
|
41
|
+
throw new Error('pipeline_closed');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
const job = {
|
|
46
|
+
id: randomUUID(),
|
|
47
|
+
type,
|
|
48
|
+
status: 'queued',
|
|
49
|
+
progress: 0,
|
|
50
|
+
message: 'queued',
|
|
51
|
+
createdAt: now,
|
|
52
|
+
updatedAt: now,
|
|
53
|
+
payload,
|
|
54
|
+
result: null,
|
|
55
|
+
error: null,
|
|
56
|
+
};
|
|
57
|
+
jobs.set(job.id, job);
|
|
58
|
+
queue.push(job.id);
|
|
59
|
+
emitProgress(job, { event: 'queued' });
|
|
60
|
+
processQueue();
|
|
61
|
+
return toPublicJob(job);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function onProgress(listener) {
|
|
65
|
+
emitter.on('progress', listener);
|
|
66
|
+
return () => emitter.off('progress', listener);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function listJobs() {
|
|
70
|
+
return Array.from(jobs.values()).map((job) => toPublicJob(job));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getJob(jobId) {
|
|
74
|
+
const job = jobs.get(jobId);
|
|
75
|
+
return job ? toPublicJob(job) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateJob(job, progress, message, detail) {
|
|
79
|
+
if (typeof progress === 'number') {
|
|
80
|
+
job.progress = Math.max(0, Math.min(100, progress));
|
|
81
|
+
}
|
|
82
|
+
if (message) {
|
|
83
|
+
job.message = message;
|
|
84
|
+
}
|
|
85
|
+
emitProgress(job, { event: 'progress', detail });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function processQueue() {
|
|
89
|
+
if (running || closed) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
running = true;
|
|
93
|
+
while (queue.length > 0 && !closed) {
|
|
94
|
+
const jobId = queue.shift();
|
|
95
|
+
const job = jobs.get(jobId);
|
|
96
|
+
if (!job || job.status !== 'queued') {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
job.status = 'running';
|
|
101
|
+
job.progress = 5;
|
|
102
|
+
job.message = 'started';
|
|
103
|
+
emitProgress(job, { event: 'started' });
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const result = await runJob(job, updateJob);
|
|
107
|
+
job.status = 'completed';
|
|
108
|
+
job.progress = 100;
|
|
109
|
+
job.message = 'completed';
|
|
110
|
+
job.result = result;
|
|
111
|
+
emitProgress(job, { event: 'completed' });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
job.status = 'failed';
|
|
114
|
+
job.message = 'failed';
|
|
115
|
+
job.error = { message: error.message };
|
|
116
|
+
emitProgress(job, { event: 'failed' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
running = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function runJob(job, report) {
|
|
123
|
+
if (job.type === 'import_browser') {
|
|
124
|
+
return importFromBrowser(storage, job.payload, (progress, message, detail) =>
|
|
125
|
+
report(job, progress, message, detail)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (job.type === 'import_url') {
|
|
129
|
+
return importFromUrl(storage, job.payload, (progress, message, detail) =>
|
|
130
|
+
report(job, progress, message, detail)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`unknown_job_type:${job.type}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function close() {
|
|
137
|
+
closed = true;
|
|
138
|
+
emitter.removeAllListeners();
|
|
139
|
+
queue.length = 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
enqueueImportFromBrowser: (payload) => enqueue('import_browser', payload),
|
|
144
|
+
enqueueImportFromUrl: (payload) => enqueue('import_url', payload),
|
|
145
|
+
onProgress,
|
|
146
|
+
listJobs,
|
|
147
|
+
getJob,
|
|
148
|
+
close,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeBrowserPayload(payload = {}) {
|
|
153
|
+
const website = payload.website || {};
|
|
154
|
+
let snapshot = payload.snapshot || null;
|
|
155
|
+
|
|
156
|
+
if (!snapshot && Array.isArray(payload.snapshots) && payload.snapshots.length > 0) {
|
|
157
|
+
snapshot = payload.snapshots[0];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!snapshot && (payload.html || payload.css)) {
|
|
161
|
+
snapshot = payload;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const url = website.url || payload.url || (snapshot ? snapshot.url : '') || '';
|
|
165
|
+
if (!snapshot) {
|
|
166
|
+
throw new Error('snapshot_required');
|
|
167
|
+
}
|
|
168
|
+
if (!url) {
|
|
169
|
+
throw new Error('url_required');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
website: {
|
|
174
|
+
url,
|
|
175
|
+
title: website.title || payload.title || snapshot.title || '',
|
|
176
|
+
favicon: website.favicon || payload.favicon || '',
|
|
177
|
+
},
|
|
178
|
+
snapshot,
|
|
179
|
+
analysis: payload.analysis || null,
|
|
180
|
+
source: payload.source || payload.extractedFrom || 'browser-extension',
|
|
181
|
+
version: payload.version || payload.extractorVersion || '',
|
|
182
|
+
designId: payload.designId || null,
|
|
183
|
+
mergeByDomain: payload.mergeByDomain ?? true,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function importFromBrowser(storage, payload, report) {
|
|
188
|
+
report(15, 'normalizing');
|
|
189
|
+
const normalized = normalizeBrowserPayload(payload);
|
|
190
|
+
report(30, 'storing_design');
|
|
191
|
+
const result = await storeImport(storage, normalized, report);
|
|
192
|
+
report(90, 'stored');
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function importFromUrl(storage, payload = {}, report) {
|
|
197
|
+
const url = payload.url || '';
|
|
198
|
+
if (!url) {
|
|
199
|
+
throw new Error('url_required');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await ensurePlaywrightInstalled({
|
|
203
|
+
onProgress: (message) => report(8, message),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
report(10, 'launching_browser');
|
|
207
|
+
const snapshot = await extractWithPlaywright(url, payload.options || {}, report);
|
|
208
|
+
report(65, 'extracted');
|
|
209
|
+
|
|
210
|
+
const normalized = {
|
|
211
|
+
website: {
|
|
212
|
+
url,
|
|
213
|
+
title: snapshot.title || '',
|
|
214
|
+
favicon: '',
|
|
215
|
+
},
|
|
216
|
+
snapshot,
|
|
217
|
+
analysis: payload.analysis || null,
|
|
218
|
+
source: 'playwright',
|
|
219
|
+
version: payload.extractorVersion || snapshot.metadata?.extractorVersion || '',
|
|
220
|
+
designId: payload.designId || null,
|
|
221
|
+
mergeByDomain: payload.mergeByDomain ?? true,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
report(75, 'storing_design');
|
|
225
|
+
const result = await storeImport(storage, normalized, report);
|
|
226
|
+
report(95, 'stored');
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function extractDomain(url) {
|
|
231
|
+
try {
|
|
232
|
+
return new URL(url).hostname;
|
|
233
|
+
} catch {
|
|
234
|
+
return '';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function storeImport(storage, normalized, report) {
|
|
239
|
+
const { website, snapshot, analysis, source, version, designId, mergeByDomain } = normalized;
|
|
240
|
+
const url = website.url || snapshot.url || '';
|
|
241
|
+
const title = website.title || snapshot.title || url || 'Untitled';
|
|
242
|
+
const domain = extractDomain(url);
|
|
243
|
+
|
|
244
|
+
let design = null;
|
|
245
|
+
if (designId) {
|
|
246
|
+
design = await storage.getDesign(designId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 先按完全相同 URL 查找
|
|
250
|
+
if (!design && url) {
|
|
251
|
+
const existing = storage.listDesigns().find((item) => item.url === url);
|
|
252
|
+
if (existing) {
|
|
253
|
+
design = existing;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 如果启用域名合并,按域名查找现有 design
|
|
258
|
+
if (!design && mergeByDomain && domain) {
|
|
259
|
+
const existing = storage.listDesigns().find((item) => extractDomain(item.url) === domain);
|
|
260
|
+
if (existing) {
|
|
261
|
+
design = existing;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!design) {
|
|
266
|
+
design = await storage.createDesign({
|
|
267
|
+
name: mergeByDomain && domain ? domain : title,
|
|
268
|
+
url,
|
|
269
|
+
source: source === 'playwright' ? 'script' : 'browser',
|
|
270
|
+
metadata: {
|
|
271
|
+
extractedFrom: source,
|
|
272
|
+
extractorVersion: version || '',
|
|
273
|
+
tags: [],
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
report(55, 'storing_version');
|
|
279
|
+
const versionRecord = await storage.createVersion({
|
|
280
|
+
designId: design.id,
|
|
281
|
+
styleguideMarkdown: analysis?.styleguide || '',
|
|
282
|
+
rules: analysis?.rules || {},
|
|
283
|
+
snapshots: snapshot ? [snapshot] : [],
|
|
284
|
+
createdBy: 'import',
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
designId: design.id,
|
|
289
|
+
versionId: versionRecord.id,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function extractWithPlaywright(url, options = {}, report) {
|
|
294
|
+
const playwright = await loadPlaywright({
|
|
295
|
+
onProgress: (message) => report(8, message),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const extractorPath = path.resolve(__dirname, '../../../scripts/lib/extractor.js');
|
|
299
|
+
let extractPage;
|
|
300
|
+
try {
|
|
301
|
+
const extractorModule = await import(pathToFileURL(extractorPath).href);
|
|
302
|
+
extractPage = extractorModule.extractPage;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
throw new Error('extractor_script_missing');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (typeof extractPage !== 'function') {
|
|
308
|
+
throw new Error('extractor_script_invalid');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const { chromium } = playwright;
|
|
312
|
+
const timeoutMs = Number(options.timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
313
|
+
const headless = options.headless !== false;
|
|
314
|
+
|
|
315
|
+
const browser = await chromium.launch({ headless });
|
|
316
|
+
report(20, 'browser_launched');
|
|
317
|
+
const context = await browser.newContext();
|
|
318
|
+
const page = await context.newPage();
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
|
322
|
+
report(40, 'page_loaded');
|
|
323
|
+
const snapshot = await extractPage(page, options.extractOptions || {});
|
|
324
|
+
report(55, 'snapshot_ready');
|
|
325
|
+
return snapshot;
|
|
326
|
+
} finally {
|
|
327
|
+
await page.close().catch(() => undefined);
|
|
328
|
+
await context.close().catch(() => undefined);
|
|
329
|
+
await browser.close().catch(() => undefined);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
createExtractionPipeline,
|
|
335
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const serverRoot = path.resolve(__dirname, '..');
|
|
5
|
+
const autoInstallPlaywright = process.env.DESIGN_LEARN_AUTO_INSTALL_PLAYWRIGHT !== '0';
|
|
6
|
+
let installPromise = null;
|
|
7
|
+
|
|
8
|
+
function isPlaywrightInstalled() {
|
|
9
|
+
try {
|
|
10
|
+
require.resolve('playwright', { paths: [serverRoot] });
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function installPlaywright(logger) {
|
|
18
|
+
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const child = spawn(npmCmd, ['install', 'playwright'], {
|
|
21
|
+
cwd: serverRoot,
|
|
22
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
23
|
+
});
|
|
24
|
+
child.stdout?.on('data', (chunk) => logger?.(chunk.toString()));
|
|
25
|
+
child.stderr?.on('data', (chunk) => logger?.(chunk.toString()));
|
|
26
|
+
child.on('error', reject);
|
|
27
|
+
child.on('exit', (code) => {
|
|
28
|
+
if (code === 0) resolve();
|
|
29
|
+
else reject(new Error(`npm_install_failed:${code ?? 'unknown'}`));
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function ensurePlaywrightInstalled(options = {}) {
|
|
35
|
+
if (!autoInstallPlaywright) return;
|
|
36
|
+
if (isPlaywrightInstalled()) return;
|
|
37
|
+
if (typeof options.onProgress === 'function') {
|
|
38
|
+
options.onProgress('installing_playwright');
|
|
39
|
+
}
|
|
40
|
+
if (!installPromise) {
|
|
41
|
+
const logger = options.logger || ((text) => process.stderr.write(text));
|
|
42
|
+
installPromise = installPlaywright(logger).finally(() => {
|
|
43
|
+
installPromise = null;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
await installPromise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadPlaywright(options = {}) {
|
|
50
|
+
try {
|
|
51
|
+
return await import('playwright');
|
|
52
|
+
} catch {
|
|
53
|
+
await ensurePlaywrightInstalled(options);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return await import('playwright');
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error('playwright_not_installed');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
ensurePlaywrightInstalled,
|
|
64
|
+
loadPlaywright,
|
|
65
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const { randomUUID } = require('crypto');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_DAILY_LIMIT = Number(process.env.PREVIEW_DAILY_LIMIT || 100);
|
|
5
|
+
const DEFAULT_MAX_ATTEMPTS = Number(process.env.PREVIEW_MAX_ATTEMPTS || 3);
|
|
6
|
+
const DEFAULT_RETRY_DELAY_MS = Number(process.env.PREVIEW_RETRY_DELAY_MS || 500);
|
|
7
|
+
|
|
8
|
+
function getDayStamp(date = new Date()) {
|
|
9
|
+
return date.toISOString().slice(0, 10);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function escapeXml(input = '') {
|
|
13
|
+
return String(input)
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createPreviewPipeline({ storage }) {
|
|
22
|
+
const emitter = new EventEmitter();
|
|
23
|
+
const jobs = new Map();
|
|
24
|
+
const queue = [];
|
|
25
|
+
let running = false;
|
|
26
|
+
let closed = false;
|
|
27
|
+
let quota = { day: getDayStamp(), count: 0 };
|
|
28
|
+
|
|
29
|
+
function toPublicJob(job) {
|
|
30
|
+
return {
|
|
31
|
+
id: job.id,
|
|
32
|
+
type: job.type,
|
|
33
|
+
status: job.status,
|
|
34
|
+
attempts: job.attempts,
|
|
35
|
+
maxAttempts: job.maxAttempts,
|
|
36
|
+
createdAt: job.createdAt,
|
|
37
|
+
updatedAt: job.updatedAt,
|
|
38
|
+
result: job.result || null,
|
|
39
|
+
error: job.error || null,
|
|
40
|
+
payload: {
|
|
41
|
+
componentId: job.payload.componentId,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function emitProgress(job, event, detail) {
|
|
47
|
+
job.updatedAt = new Date().toISOString();
|
|
48
|
+
emitter.emit('progress', {
|
|
49
|
+
job: toPublicJob(job),
|
|
50
|
+
event,
|
|
51
|
+
detail: detail || null,
|
|
52
|
+
});
|
|
53
|
+
if (event === 'completed' || event === 'failed' || event === 'retrying') {
|
|
54
|
+
console.log(`[preview] ${job.id} ${event}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function enqueuePreview(payload) {
|
|
59
|
+
if (closed) {
|
|
60
|
+
throw new Error('preview_pipeline_closed');
|
|
61
|
+
}
|
|
62
|
+
if (!payload || !payload.componentId) {
|
|
63
|
+
throw new Error('component_id_required');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
const job = {
|
|
68
|
+
id: randomUUID(),
|
|
69
|
+
type: 'preview_generate',
|
|
70
|
+
status: 'queued',
|
|
71
|
+
attempts: 0,
|
|
72
|
+
maxAttempts: DEFAULT_MAX_ATTEMPTS,
|
|
73
|
+
createdAt: now,
|
|
74
|
+
updatedAt: now,
|
|
75
|
+
payload,
|
|
76
|
+
result: null,
|
|
77
|
+
error: null,
|
|
78
|
+
};
|
|
79
|
+
jobs.set(job.id, job);
|
|
80
|
+
queue.push(job.id);
|
|
81
|
+
emitProgress(job, 'queued');
|
|
82
|
+
processQueue();
|
|
83
|
+
return toPublicJob(job);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onProgress(listener) {
|
|
87
|
+
emitter.on('progress', listener);
|
|
88
|
+
return () => emitter.off('progress', listener);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function listJobs() {
|
|
92
|
+
return Array.from(jobs.values()).map((job) => toPublicJob(job));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getJob(jobId) {
|
|
96
|
+
const job = jobs.get(jobId);
|
|
97
|
+
return job ? toPublicJob(job) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function updateQuota() {
|
|
101
|
+
const today = getDayStamp();
|
|
102
|
+
if (quota.day !== today) {
|
|
103
|
+
quota = { day: today, count: 0 };
|
|
104
|
+
}
|
|
105
|
+
return quota;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function processQueue() {
|
|
109
|
+
if (running || closed) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
running = true;
|
|
113
|
+
while (queue.length > 0 && !closed) {
|
|
114
|
+
const jobId = queue.shift();
|
|
115
|
+
const job = jobs.get(jobId);
|
|
116
|
+
if (!job || job.status !== 'queued') {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const currentQuota = updateQuota();
|
|
121
|
+
if (currentQuota.count >= DEFAULT_DAILY_LIMIT) {
|
|
122
|
+
job.status = 'failed';
|
|
123
|
+
job.error = { message: 'quota_exceeded' };
|
|
124
|
+
emitProgress(job, 'failed', { reason: 'quota_exceeded' });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
job.status = 'running';
|
|
129
|
+
job.attempts += 1;
|
|
130
|
+
currentQuota.count += 1;
|
|
131
|
+
emitProgress(job, 'started');
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const result = await runJob(job);
|
|
135
|
+
job.status = 'completed';
|
|
136
|
+
job.result = result;
|
|
137
|
+
emitProgress(job, 'completed');
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (job.attempts < job.maxAttempts) {
|
|
140
|
+
job.status = 'queued';
|
|
141
|
+
emitProgress(job, 'retrying', { attempt: job.attempts });
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
if (!closed) {
|
|
144
|
+
queue.push(job.id);
|
|
145
|
+
processQueue();
|
|
146
|
+
}
|
|
147
|
+
}, DEFAULT_RETRY_DELAY_MS);
|
|
148
|
+
} else {
|
|
149
|
+
job.status = 'failed';
|
|
150
|
+
job.error = { message: error.message };
|
|
151
|
+
emitProgress(job, 'failed');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
running = false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function runJob(job) {
|
|
159
|
+
if (job.type !== 'preview_generate') {
|
|
160
|
+
throw new Error(`unknown_job_type:${job.type}`);
|
|
161
|
+
}
|
|
162
|
+
const component = await storage.getComponent(job.payload.componentId);
|
|
163
|
+
if (!component) {
|
|
164
|
+
throw new Error('component_not_found');
|
|
165
|
+
}
|
|
166
|
+
const preview = buildPreview(component);
|
|
167
|
+
await storage.updateComponentPreview(component.id, preview);
|
|
168
|
+
return { componentId: component.id, preview };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildPreview(component) {
|
|
172
|
+
const label = escapeXml(component.name || component.id || 'component');
|
|
173
|
+
const svg = [
|
|
174
|
+
'<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"320\" height=\"180\">',
|
|
175
|
+
'<rect width=\"100%\" height=\"100%\" fill=\"#111827\"/>',
|
|
176
|
+
`<text x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" fill=\"#F9FAFB\" font-size=\"14\">${label}</text>`,
|
|
177
|
+
'</svg>',
|
|
178
|
+
].join('');
|
|
179
|
+
const imageUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
|
180
|
+
return {
|
|
181
|
+
provider: 'nanobanana_stub',
|
|
182
|
+
imageUrl,
|
|
183
|
+
generatedAt: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function close() {
|
|
188
|
+
closed = true;
|
|
189
|
+
emitter.removeAllListeners();
|
|
190
|
+
queue.length = 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
enqueuePreview,
|
|
195
|
+
listJobs,
|
|
196
|
+
getJob,
|
|
197
|
+
onProgress,
|
|
198
|
+
close,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
createPreviewPipeline,
|
|
204
|
+
};
|