@scrymore/scry-deployer 0.2.1 ā 0.3.0
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/bin/cli.js +118 -5
- package/lib/apiClient.js +6 -5
- package/lib/imageUpload.js +120 -0
- package/lib/localImageProcessing.js +663 -0
- package/lib/logger.js +16 -1
- package/package.json +2 -1
package/bin/cli.js
CHANGED
|
@@ -18,6 +18,8 @@ const { runCoverageAnalysis, loadCoverageReport, extractCoverageSummary } = requ
|
|
|
18
18
|
const { postPRComment } = require('../lib/pr-comment.js');
|
|
19
19
|
const { runInit } = require('../lib/init.js');
|
|
20
20
|
const { runUpdateWorkflows } = require('../lib/update-workflows.js');
|
|
21
|
+
const { runQueueImageUpload } = require('../lib/imageUpload.js');
|
|
22
|
+
const { runLocalImageProcessing } = require('../lib/localImageProcessing.js');
|
|
21
23
|
|
|
22
24
|
async function runAnalysis(argv) {
|
|
23
25
|
const logger = createLogger(argv);
|
|
@@ -123,9 +125,30 @@ async function runDeployment(argv) {
|
|
|
123
125
|
|
|
124
126
|
await postPRComment(buildDeployResult(argv, coverageSummary, uploadResult), coverageSummary);
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
// Report only what actually completed. Uploading is synchronous;
|
|
129
|
+
// indexing is not. A build can fail in the queue seconds after this
|
|
130
|
+
// point ā during one run the pipeline died 7s later on a revoked
|
|
131
|
+
// credential ā and this command previously printed
|
|
132
|
+
// "Deployment successful" over it, sending people looking for the
|
|
133
|
+
// cause three steps downstream (ISSUES.md #4).
|
|
134
|
+
logger.success('\nā
Upload complete.');
|
|
127
135
|
logUploadLinks(argv, coverageSummary, uploadResult, logger);
|
|
128
136
|
|
|
137
|
+
if (uploadResult?.metadataUpload?.queued) {
|
|
138
|
+
logger.info(
|
|
139
|
+
'\nā³ Indexing has been queued, not finished.\n' +
|
|
140
|
+
' This command cannot confirm it succeeded. Components will not be\n' +
|
|
141
|
+
' searchable until processing completes, and a failed build reports\n' +
|
|
142
|
+
' nothing here. Before relying on search, confirm the build shows\n' +
|
|
143
|
+
" processingStatus 'completed' rather than 'failed'."
|
|
144
|
+
);
|
|
145
|
+
} else if (uploadResult?.metadataUpload) {
|
|
146
|
+
logger.warn(
|
|
147
|
+
'\nā ļø Metadata was uploaded but not queued for processing.\n' +
|
|
148
|
+
' The Storybook is hosted, but its components are NOT being indexed.'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
129
152
|
} finally {
|
|
130
153
|
// 4. Clean up the local archive
|
|
131
154
|
if (fs.existsSync(outPath)) {
|
|
@@ -182,7 +205,6 @@ async function main() {
|
|
|
182
205
|
environment: process.env.NODE_ENV || 'production',
|
|
183
206
|
});
|
|
184
207
|
|
|
185
|
-
let config;
|
|
186
208
|
try {
|
|
187
209
|
const args = await yargs(hideBin(process.argv))
|
|
188
210
|
.command('$0', 'Deploy Storybook static build', (yargs) => {
|
|
@@ -255,7 +277,7 @@ async function main() {
|
|
|
255
277
|
});
|
|
256
278
|
}, async (argv) => {
|
|
257
279
|
// Load and merge configuration
|
|
258
|
-
config = loadConfig(argv);
|
|
280
|
+
const config = loadConfig(argv);
|
|
259
281
|
|
|
260
282
|
// Validate required fields
|
|
261
283
|
if (!config.dir) {
|
|
@@ -311,7 +333,7 @@ async function main() {
|
|
|
311
333
|
});
|
|
312
334
|
}, async (argv) => {
|
|
313
335
|
// Load and merge configuration
|
|
314
|
-
config = loadConfig(argv);
|
|
336
|
+
const config = loadConfig(argv);
|
|
315
337
|
|
|
316
338
|
await runAnalysis(config);
|
|
317
339
|
})
|
|
@@ -422,6 +444,97 @@ async function main() {
|
|
|
422
444
|
|
|
423
445
|
await runInit(initConfig);
|
|
424
446
|
})
|
|
447
|
+
.command('upload-images', 'Upload a folder of images for search indexing', (yargs) => {
|
|
448
|
+
return yargs
|
|
449
|
+
.option('dir', {
|
|
450
|
+
describe: 'Path to the image directory',
|
|
451
|
+
type: 'string',
|
|
452
|
+
demandOption: true,
|
|
453
|
+
})
|
|
454
|
+
.option('project', {
|
|
455
|
+
describe: 'Project name/identifier',
|
|
456
|
+
type: 'string',
|
|
457
|
+
demandOption: true,
|
|
458
|
+
})
|
|
459
|
+
.option('local', {
|
|
460
|
+
describe: 'Process images locally instead of uploading to the queue',
|
|
461
|
+
type: 'boolean',
|
|
462
|
+
default: false,
|
|
463
|
+
})
|
|
464
|
+
.option('openai-api-key', {
|
|
465
|
+
describe: 'OpenAI API key (for --local mode)',
|
|
466
|
+
type: 'string',
|
|
467
|
+
})
|
|
468
|
+
.option('jina-api-key', {
|
|
469
|
+
describe: 'Jina API key (for --local mode)',
|
|
470
|
+
type: 'string',
|
|
471
|
+
})
|
|
472
|
+
.option('milvus-address', {
|
|
473
|
+
describe: 'Milvus/Zilliz endpoint (for --local mode)',
|
|
474
|
+
type: 'string',
|
|
475
|
+
})
|
|
476
|
+
.option('milvus-token', {
|
|
477
|
+
describe: 'Milvus/Zilliz auth token (for --local mode)',
|
|
478
|
+
type: 'string',
|
|
479
|
+
})
|
|
480
|
+
.option('milvus-collection', {
|
|
481
|
+
describe: 'Milvus collection name (for --local mode)',
|
|
482
|
+
type: 'string',
|
|
483
|
+
})
|
|
484
|
+
.option('api-key', {
|
|
485
|
+
describe: 'API key for the deployment service (queue mode)',
|
|
486
|
+
type: 'string',
|
|
487
|
+
})
|
|
488
|
+
.option('api-url', {
|
|
489
|
+
describe: 'Base URL for the deployment service API (queue mode)',
|
|
490
|
+
type: 'string',
|
|
491
|
+
})
|
|
492
|
+
.option('verbose', {
|
|
493
|
+
describe: 'Enable verbose logging',
|
|
494
|
+
type: 'boolean',
|
|
495
|
+
});
|
|
496
|
+
}, async (argv) => {
|
|
497
|
+
const config = loadConfig(argv);
|
|
498
|
+
|
|
499
|
+
if (!config.dir) {
|
|
500
|
+
throw new Error('--dir is required. Provide a path to the image directory.');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (!fs.existsSync(config.dir)) {
|
|
504
|
+
throw new Error(`Directory not found: ${config.dir}`);
|
|
505
|
+
}
|
|
506
|
+
if (!fs.lstatSync(config.dir).isDirectory()) {
|
|
507
|
+
throw new Error(`Path is not a directory: ${config.dir}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (config.local) {
|
|
511
|
+
// Local mode: process images directly via LLM + embeddings + Milvus
|
|
512
|
+
const requiredLocalKeys = {
|
|
513
|
+
openaiApiKey: { flag: '--openai-api-key', env: 'OPENAI_API_KEY' },
|
|
514
|
+
jinaApiKey: { flag: '--jina-api-key', env: 'JINA_API_KEY' },
|
|
515
|
+
milvusAddress: { flag: '--milvus-address', env: 'MILVUS_ADDRESS' },
|
|
516
|
+
milvusToken: { flag: '--milvus-token', env: 'MILVUS_TOKEN' },
|
|
517
|
+
milvusCollection: { flag: '--milvus-collection', env: 'MILVUS_COLLECTION' },
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const resolved = {};
|
|
521
|
+
for (const [key, { flag, env }] of Object.entries(requiredLocalKeys)) {
|
|
522
|
+
resolved[key] = config[key] || process.env[env];
|
|
523
|
+
if (!resolved[key]) {
|
|
524
|
+
throw new Error(`${flag} or ${env} env var is required for local mode`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
await runLocalImageProcessing({
|
|
529
|
+
dir: config.dir,
|
|
530
|
+
project: config.project,
|
|
531
|
+
...resolved,
|
|
532
|
+
verbose: config.verbose,
|
|
533
|
+
});
|
|
534
|
+
} else {
|
|
535
|
+
await runQueueImageUpload(config);
|
|
536
|
+
}
|
|
537
|
+
})
|
|
425
538
|
.command('debug-sentry', 'Test Sentry integration by throwing an error', () => {}, () => {
|
|
426
539
|
throw new Error('Sentry debug error from scry-node CLI');
|
|
427
540
|
})
|
|
@@ -432,7 +545,7 @@ async function main() {
|
|
|
432
545
|
.parse();
|
|
433
546
|
|
|
434
547
|
} catch (error) {
|
|
435
|
-
await handleError(error, config);
|
|
548
|
+
await handleError(error, error.config || {});
|
|
436
549
|
}
|
|
437
550
|
}
|
|
438
551
|
|
package/lib/apiClient.js
CHANGED
|
@@ -153,14 +153,15 @@ function validatePresignedUrl(presignedUrl) {
|
|
|
153
153
|
* Upload a buffer to a presigned URL.
|
|
154
154
|
*
|
|
155
155
|
* @param {string} presignedUrl
|
|
156
|
-
* @param {Buffer}
|
|
156
|
+
* @param {Buffer|import('stream').Readable} data
|
|
157
157
|
* @param {string} contentType
|
|
158
158
|
* @returns {Promise<{status:number}>}
|
|
159
159
|
*/
|
|
160
|
-
async function putToPresignedUrl(presignedUrl,
|
|
161
|
-
|
|
160
|
+
async function putToPresignedUrl(presignedUrl, data, contentType) {
|
|
161
|
+
const size = Buffer.isBuffer(data) ? `${data.length} bytes` : 'stream';
|
|
162
|
+
logger.debug(`Starting PUT upload to presigned URL. Size: ${size}, Content-Type: ${contentType}`);
|
|
162
163
|
|
|
163
|
-
const uploadResponse = await axios.put(presignedUrl,
|
|
164
|
+
const uploadResponse = await axios.put(presignedUrl, data, {
|
|
164
165
|
headers: {
|
|
165
166
|
'Content-Type': contentType,
|
|
166
167
|
},
|
|
@@ -321,7 +322,7 @@ async function uploadMetadataZip(apiClient, target, metadataZipPath, customLogge
|
|
|
321
322
|
};
|
|
322
323
|
} catch (error) {
|
|
323
324
|
const message = error.response?.data?.error || error.message || 'Unknown error';
|
|
324
|
-
customLogger.
|
|
325
|
+
customLogger.error(`Metadata ZIP upload failed: ${message}`);
|
|
325
326
|
return { success: false, error: message };
|
|
326
327
|
}
|
|
327
328
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { zipDirectory } = require('./archive.js');
|
|
5
|
+
const { getApiClient, requestPresignedUrl, putToPresignedUrl } = require('./apiClient.js');
|
|
6
|
+
const { createLogger } = require('./logger.js');
|
|
7
|
+
|
|
8
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recursively find image files in a directory.
|
|
12
|
+
* Excludes __MACOSX and hidden files.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} dir
|
|
15
|
+
* @returns {string[]} Array of absolute file paths
|
|
16
|
+
*/
|
|
17
|
+
function findImageFiles(dir) {
|
|
18
|
+
const results = [];
|
|
19
|
+
|
|
20
|
+
function walk(currentDir) {
|
|
21
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
22
|
+
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
// Skip hidden files/dirs and __MACOSX
|
|
25
|
+
if (entry.name.startsWith('.') || entry.name === '__MACOSX') continue;
|
|
26
|
+
|
|
27
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
28
|
+
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
walk(fullPath);
|
|
31
|
+
} else if (entry.isFile()) {
|
|
32
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
33
|
+
if (IMAGE_EXTENSIONS.has(ext)) {
|
|
34
|
+
results.push(fullPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
walk(dir);
|
|
41
|
+
return results;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run queue-mode image upload.
|
|
46
|
+
* ZIPs the directory, uploads via presigned URL, signals completion.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} config
|
|
49
|
+
* @param {string} config.dir - Path to image directory
|
|
50
|
+
* @param {string} config.project - Project identifier
|
|
51
|
+
* @param {string} config.apiKey - API key
|
|
52
|
+
* @param {string} config.apiUrl - API base URL
|
|
53
|
+
* @param {boolean} [config.verbose]
|
|
54
|
+
*/
|
|
55
|
+
async function runQueueImageUpload(config) {
|
|
56
|
+
const logger = createLogger(config);
|
|
57
|
+
|
|
58
|
+
// 1. Validate directory and count images
|
|
59
|
+
logger.info('Scanning for images...');
|
|
60
|
+
const imageFiles = findImageFiles(config.dir);
|
|
61
|
+
if (imageFiles.length === 0) {
|
|
62
|
+
throw new Error(`No image files (.png, .jpg, .jpeg) found in: ${config.dir}`);
|
|
63
|
+
}
|
|
64
|
+
logger.success(`Found ${imageFiles.length} images`);
|
|
65
|
+
|
|
66
|
+
const zipPath = path.join(os.tmpdir(), `scry-image-upload-${Date.now()}.zip`);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
// 2. ZIP the directory
|
|
70
|
+
logger.info('Creating ZIP archive...');
|
|
71
|
+
await zipDirectory(config.dir, zipPath);
|
|
72
|
+
const zipSize = fs.statSync(zipPath).size;
|
|
73
|
+
logger.success(`ZIP created: ${(zipSize / 1024 / 1024).toFixed(1)} MB`);
|
|
74
|
+
logger.debug(`ZIP path: ${zipPath}`);
|
|
75
|
+
|
|
76
|
+
// 3. Initialize upload ā get presigned URL and uploadId
|
|
77
|
+
logger.info('Initializing upload...');
|
|
78
|
+
const apiClient = getApiClient(config.apiUrl, config.apiKey);
|
|
79
|
+
|
|
80
|
+
const initResponse = await apiClient.post(
|
|
81
|
+
`/upload-images/${config.project}`,
|
|
82
|
+
{ imageCount: imageFiles.length },
|
|
83
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const { uploadId, uploadNumber, presignedUrl, zipKey } = initResponse.data;
|
|
87
|
+
logger.info(`Upload #${uploadNumber} initialized (id: ${uploadId})`);
|
|
88
|
+
logger.debug(`Presigned URL received, zipKey: ${zipKey}`);
|
|
89
|
+
|
|
90
|
+
// 4. PUT ZIP to presigned URL (stream to avoid loading entire file into memory)
|
|
91
|
+
logger.info('Uploading ZIP to storage...');
|
|
92
|
+
const fileStream = fs.createReadStream(zipPath);
|
|
93
|
+
await putToPresignedUrl(presignedUrl, fileStream, 'application/zip');
|
|
94
|
+
logger.success('ZIP uploaded to storage');
|
|
95
|
+
|
|
96
|
+
// 5. Signal completion
|
|
97
|
+
logger.info('Signaling upload complete...');
|
|
98
|
+
await apiClient.post(
|
|
99
|
+
`/upload-images/${config.project}/complete`,
|
|
100
|
+
{ uploadId, zipKey },
|
|
101
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
logger.success(`\nUpload #${uploadNumber} queued for processing (${imageFiles.length} images)`);
|
|
105
|
+
logger.info(`Upload ID: ${uploadId}`);
|
|
106
|
+
|
|
107
|
+
return { uploadId, uploadNumber, imageCount: imageFiles.length };
|
|
108
|
+
} finally {
|
|
109
|
+
// 6. Cleanup
|
|
110
|
+
if (fs.existsSync(zipPath)) {
|
|
111
|
+
fs.unlinkSync(zipPath);
|
|
112
|
+
logger.debug(`Cleaned up temporary ZIP: ${zipPath}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
findImageFiles,
|
|
119
|
+
runQueueImageUpload,
|
|
120
|
+
};
|
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { XMLParser } = require('fast-xml-parser');
|
|
5
|
+
const { createLogger } = require('./logger.js');
|
|
6
|
+
const { findImageFiles } = require('./imageUpload.js');
|
|
7
|
+
|
|
8
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāā XML Parsing āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
9
|
+
|
|
10
|
+
const xmlParser = new XMLParser({
|
|
11
|
+
ignoreAttributes: true,
|
|
12
|
+
trimValues: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function parseXmlToJson(xmlContent) {
|
|
16
|
+
const wrapped = `<root>${xmlContent}</root>`;
|
|
17
|
+
const parsed = xmlParser.parse(wrapped);
|
|
18
|
+
const root = parsed.root || {};
|
|
19
|
+
|
|
20
|
+
// Navigate into any wrapper element (e.g. component-documentation)
|
|
21
|
+
const doc = root['component-documentation'] || root;
|
|
22
|
+
|
|
23
|
+
const result = {};
|
|
24
|
+
|
|
25
|
+
if (doc['screen-name']) result.screenName = String(doc['screen-name']).trim();
|
|
26
|
+
if (doc.description) result.description = String(doc.description).trim();
|
|
27
|
+
|
|
28
|
+
if (doc.tags) {
|
|
29
|
+
const rawTags = doc.tags.tag;
|
|
30
|
+
result.tags = Array.isArray(rawTags)
|
|
31
|
+
? rawTags.map(t => String(t).trim())
|
|
32
|
+
: rawTags ? [String(rawTags).trim()] : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (doc['search-queries']) {
|
|
36
|
+
const rawQueries = doc['search-queries'].query;
|
|
37
|
+
result.searchQueries = Array.isArray(rawQueries)
|
|
38
|
+
? rawQueries.map(q => String(q).trim())
|
|
39
|
+
: rawQueries ? [String(rawQueries).trim()] : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseBatchXmlResponse(xmlResponse, count) {
|
|
46
|
+
const wrapped = `<root>${xmlResponse}</root>`;
|
|
47
|
+
const parsed = xmlParser.parse(wrapped);
|
|
48
|
+
const root = parsed.root || {};
|
|
49
|
+
const results = [];
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < count; i++) {
|
|
52
|
+
const key = `component-${i + 1}`;
|
|
53
|
+
const component = root[key] || (root['batch-analysis'] && root['batch-analysis'][key]);
|
|
54
|
+
if (!component) {
|
|
55
|
+
results.push({});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// Re-serialize the component back to extract fields
|
|
59
|
+
const result = {};
|
|
60
|
+
if (component['screen-name']) result.screenName = String(component['screen-name']).trim();
|
|
61
|
+
if (component.description) result.description = String(component.description).trim();
|
|
62
|
+
if (component.tags) {
|
|
63
|
+
const rawTags = component.tags.tag;
|
|
64
|
+
result.tags = Array.isArray(rawTags)
|
|
65
|
+
? rawTags.map(t => String(t).trim())
|
|
66
|
+
: rawTags ? [String(rawTags).trim()] : [];
|
|
67
|
+
}
|
|
68
|
+
if (component['search-queries']) {
|
|
69
|
+
const rawQueries = component['search-queries'].query;
|
|
70
|
+
result.searchQueries = Array.isArray(rawQueries)
|
|
71
|
+
? rawQueries.map(q => String(q).trim())
|
|
72
|
+
: rawQueries ? [String(rawQueries).trim()] : [];
|
|
73
|
+
}
|
|
74
|
+
results.push(result);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return results;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāā Prompt āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
81
|
+
|
|
82
|
+
const IMAGE_UPLOAD_INSPECTOR_PROMPT = `# UI Screenshot Documentation Instructions
|
|
83
|
+
|
|
84
|
+
## Overview
|
|
85
|
+
You will analyze UI screenshots (mobile app screens, web pages, or other user interface captures) and generate standardized documentation in XML format consisting of a screen name, description, tags, and search queries.
|
|
86
|
+
|
|
87
|
+
## Input Materials
|
|
88
|
+
- **Screenshot**: Visual representation of a UI screen
|
|
89
|
+
|
|
90
|
+
## Output Format (XML Structure)
|
|
91
|
+
|
|
92
|
+
\`\`\`xml
|
|
93
|
+
<component-documentation>
|
|
94
|
+
<screen-name>
|
|
95
|
+
[Short, descriptive name for this screen, e.g. "Home Feed", "Login Page", "Settings Menu"]
|
|
96
|
+
</screen-name>
|
|
97
|
+
<description>
|
|
98
|
+
[2-3 sentence description following the template below]
|
|
99
|
+
</description>
|
|
100
|
+
<tags>
|
|
101
|
+
<tag>Screen Type</tag>
|
|
102
|
+
<tag>App Category</tag>
|
|
103
|
+
<tag>Visual Descriptor</tag>
|
|
104
|
+
<tag>UI Pattern</tag>
|
|
105
|
+
<tag>Functional Category</tag>
|
|
106
|
+
<!-- 8-15 total tags -->
|
|
107
|
+
</tags>
|
|
108
|
+
<search-queries>
|
|
109
|
+
<query>UI screen type keyword</query>
|
|
110
|
+
<query>Visual description phrase</query>
|
|
111
|
+
<query>User-friendly search phrase</query>
|
|
112
|
+
<query>Functional description</query>
|
|
113
|
+
<query>Design pattern query</query>
|
|
114
|
+
<!-- 5-7 total queries -->
|
|
115
|
+
</search-queries>
|
|
116
|
+
</component-documentation>
|
|
117
|
+
\`\`\`
|
|
118
|
+
|
|
119
|
+
## Content Guidelines
|
|
120
|
+
|
|
121
|
+
### 1. Screen Name (short label)
|
|
122
|
+
- A concise, human-readable name for the screen
|
|
123
|
+
- Examples: "Home Feed", "Product Detail", "Search Results", "User Profile", "Checkout Flow"
|
|
124
|
+
|
|
125
|
+
### 2. Description (2-3 sentences)
|
|
126
|
+
- **First sentence**: Screen type and primary purpose
|
|
127
|
+
- **Second sentence**: Visual characteristics and key UI elements
|
|
128
|
+
- **Third sentence** (if needed): Notable features or interaction patterns
|
|
129
|
+
|
|
130
|
+
**Template**: "This is a [screen type] that [primary function]. It features [key UI elements and visual characteristics]. [Additional notable features]."
|
|
131
|
+
|
|
132
|
+
### 3. Tags (8-15 keywords)
|
|
133
|
+
List relevant tags in this priority order:
|
|
134
|
+
1. Screen type (Home, Settings, Profile, etc.)
|
|
135
|
+
2. App category (Social, E-commerce, Productivity, etc.)
|
|
136
|
+
3. Visual descriptors (colors, layout style)
|
|
137
|
+
4. UI patterns (tab bar, card layout, list view, etc.)
|
|
138
|
+
5. Functional categories (navigation, content, form, etc.)
|
|
139
|
+
6. Platform indicators (iOS, Android, Web)
|
|
140
|
+
|
|
141
|
+
### 4. Search Queries (5-7 phrases)
|
|
142
|
+
Create search-friendly phrases without quotes:
|
|
143
|
+
- Include screen type + descriptive terms
|
|
144
|
+
- Use common UI/UX terminology
|
|
145
|
+
- Consider both technical and casual language
|
|
146
|
+
- Include design pattern references
|
|
147
|
+
|
|
148
|
+
## Quality Standards
|
|
149
|
+
- **Accuracy**: Description must match the visual exactly
|
|
150
|
+
- **Completeness**: Include all significant visual and functional aspects
|
|
151
|
+
- **Consistency**: Use the same terminology and format for similar screens
|
|
152
|
+
- **Searchability**: Tags and queries should help users find this screen easily
|
|
153
|
+
- **Valid XML**: Ensure all tags are properly closed and content is escaped if needed
|
|
154
|
+
|
|
155
|
+
Remember: Accuracy and consistency are critical. When in doubt, describe exactly what you see in the screenshot.`;
|
|
156
|
+
|
|
157
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāā Batch Processor āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
158
|
+
|
|
159
|
+
async function processBatches(items, batchSize, maxConcurrent, processFn, options = {}) {
|
|
160
|
+
if (items.length === 0) return [];
|
|
161
|
+
|
|
162
|
+
const batches = [];
|
|
163
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
164
|
+
batches.push(items.slice(i, i + batchSize));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const results = [];
|
|
168
|
+
let batchIndex = 0;
|
|
169
|
+
let completedItems = 0;
|
|
170
|
+
|
|
171
|
+
const processBatch = async () => {
|
|
172
|
+
while (batchIndex < batches.length) {
|
|
173
|
+
const currentIndex = batchIndex++;
|
|
174
|
+
const batch = batches[currentIndex];
|
|
175
|
+
|
|
176
|
+
if (options.delayMs && currentIndex > 0) {
|
|
177
|
+
await new Promise(resolve => setTimeout(resolve, options.delayMs));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const batchResults = await processFn(batch);
|
|
181
|
+
results.push(...batchResults);
|
|
182
|
+
completedItems += batch.length;
|
|
183
|
+
|
|
184
|
+
if (options.onProgress) {
|
|
185
|
+
options.onProgress(completedItems, items.length);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const workers = Array.from(
|
|
191
|
+
{ length: Math.min(maxConcurrent, batches.length) },
|
|
192
|
+
() => processBatch()
|
|
193
|
+
);
|
|
194
|
+
await Promise.all(workers);
|
|
195
|
+
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāā Vector Utils āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
200
|
+
|
|
201
|
+
const DEFAULT_TARGET_DIM = 2048;
|
|
202
|
+
|
|
203
|
+
function padVector(vector, targetDim) {
|
|
204
|
+
if (!vector || !Array.isArray(vector)) {
|
|
205
|
+
return new Array(targetDim).fill(0);
|
|
206
|
+
}
|
|
207
|
+
if (vector.length === targetDim) return vector;
|
|
208
|
+
if (vector.length > targetDim) return vector.slice(0, targetDim);
|
|
209
|
+
const padded = [...vector];
|
|
210
|
+
while (padded.length < targetDim) padded.push(0);
|
|
211
|
+
return padded;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāā LLM Inspector āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
215
|
+
|
|
216
|
+
function createImageBatchPrompt(count) {
|
|
217
|
+
const batchInstructions = `
|
|
218
|
+
|
|
219
|
+
## Batch Analysis Instructions
|
|
220
|
+
|
|
221
|
+
You will analyze ${count} UI screenshot(s) in a single request.
|
|
222
|
+
|
|
223
|
+
**Format your response as:**
|
|
224
|
+
\`\`\`xml
|
|
225
|
+
<batch-analysis>
|
|
226
|
+
<component-1>
|
|
227
|
+
<screen-name>...</screen-name>
|
|
228
|
+
<description>...</description>
|
|
229
|
+
<tags><tag>...</tag>...</tags>
|
|
230
|
+
<search-queries><query>...</query>...</search-queries>
|
|
231
|
+
</component-1>
|
|
232
|
+
${count > 1 ? `<component-2>...</component-2>` : ''}
|
|
233
|
+
${count > 2 ? `<!-- ... up to component-${count} -->` : ''}
|
|
234
|
+
</batch-analysis>
|
|
235
|
+
\`\`\`
|
|
236
|
+
|
|
237
|
+
- Component numbers must match the image order exactly (1st image = component-1, etc.)
|
|
238
|
+
- Each screenshot must have complete documentation including screen-name
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
return IMAGE_UPLOAD_INSPECTOR_PROMPT + batchInstructions;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function batchInspectImages(images, apiKey, options = {}) {
|
|
245
|
+
const model = options.model || 'gpt-5-mini';
|
|
246
|
+
const maxRetries = options.maxRetries ?? 2;
|
|
247
|
+
|
|
248
|
+
const batchPrompt = createImageBatchPrompt(images.length);
|
|
249
|
+
|
|
250
|
+
const content = [
|
|
251
|
+
{
|
|
252
|
+
type: 'text',
|
|
253
|
+
text: batchPrompt + `\n\nAnalyze these ${images.length} UI screenshots and provide numbered documentation for each.`,
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
for (const image of images) {
|
|
258
|
+
const base64 = image.screenshotBytes.toString('base64');
|
|
259
|
+
const ext = image.filename.toLowerCase().endsWith('.png') ? 'png' : 'jpeg';
|
|
260
|
+
content.push({
|
|
261
|
+
type: 'image_url',
|
|
262
|
+
image_url: {
|
|
263
|
+
url: `data:image/${ext};base64,${base64}`,
|
|
264
|
+
detail: 'high',
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let lastError = null;
|
|
270
|
+
|
|
271
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
272
|
+
try {
|
|
273
|
+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: {
|
|
276
|
+
'Content-Type': 'application/json',
|
|
277
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
278
|
+
},
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
model,
|
|
281
|
+
max_completion_tokens: 1500 * images.length,
|
|
282
|
+
messages: [{ role: 'user', content }],
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (!response.ok) {
|
|
287
|
+
const errorText = await response.text();
|
|
288
|
+
throw new Error(`OpenAI API error ${response.status}: ${errorText}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const data = await response.json();
|
|
292
|
+
const xmlResponse = data.choices?.[0]?.message?.content;
|
|
293
|
+
|
|
294
|
+
if (!xmlResponse) {
|
|
295
|
+
throw new Error('No response content from OpenAI API');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const parsedComponents = parseBatchXmlResponse(xmlResponse, images.length);
|
|
299
|
+
|
|
300
|
+
return parsedComponents.map((parsed, i) => ({
|
|
301
|
+
screenName: parsed.screenName || images[i].filename.replace(/\.[^.]+$/, ''),
|
|
302
|
+
description: parsed.description || '',
|
|
303
|
+
tags: parsed.tags || [],
|
|
304
|
+
searchQueries: parsed.searchQueries || [],
|
|
305
|
+
metadata: {
|
|
306
|
+
imagePath: images[i].screenshotPath,
|
|
307
|
+
model,
|
|
308
|
+
timestamp: new Date().toISOString(),
|
|
309
|
+
batchIndex: i + 1,
|
|
310
|
+
},
|
|
311
|
+
}));
|
|
312
|
+
} catch (error) {
|
|
313
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
314
|
+
console.error(`[LLM] Image batch inspection attempt ${attempt + 1} failed:`, lastError.message);
|
|
315
|
+
|
|
316
|
+
if (attempt < maxRetries) {
|
|
317
|
+
await new Promise(resolve => setTimeout(resolve, 2000 * (attempt + 1)));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
throw lastError || new Error('Image batch inspection failed');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāā Searchable Text āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
326
|
+
|
|
327
|
+
function createSearchableTextFromImage(inspection) {
|
|
328
|
+
if (!inspection) return '';
|
|
329
|
+
|
|
330
|
+
const parts = [
|
|
331
|
+
inspection.screenName || '',
|
|
332
|
+
inspection.description || '',
|
|
333
|
+
...(inspection.tags || []).map(tag => `${tag} element`),
|
|
334
|
+
...(inspection.searchQueries || []),
|
|
335
|
+
'mobile app screen',
|
|
336
|
+
'UI screenshot',
|
|
337
|
+
'user interface',
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
return parts
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.join(' ')
|
|
343
|
+
.toLowerCase()
|
|
344
|
+
.replace(/\s+/g, ' ')
|
|
345
|
+
.trim();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāā Embedding Generator āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
349
|
+
|
|
350
|
+
async function callJinaEmbeddings(input, apiKey, maxRetries = 4) {
|
|
351
|
+
let lastError = null;
|
|
352
|
+
|
|
353
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
354
|
+
try {
|
|
355
|
+
const response = await fetch('https://api.jina.ai/v1/embeddings', {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
headers: {
|
|
358
|
+
'Content-Type': 'application/json',
|
|
359
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
360
|
+
},
|
|
361
|
+
body: JSON.stringify({
|
|
362
|
+
model: 'jina-embeddings-v4',
|
|
363
|
+
task: 'retrieval.document',
|
|
364
|
+
input,
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (!response.ok) {
|
|
369
|
+
const errorText = await response.text();
|
|
370
|
+
const is429 = response.status === 429;
|
|
371
|
+
const err = new Error(`Jina API error ${response.status}: ${errorText}`);
|
|
372
|
+
if (is429 && attempt < maxRetries) {
|
|
373
|
+
const backoff = 15000 * Math.pow(2, attempt);
|
|
374
|
+
console.warn(`[EMBEDDINGS] Rate limited, waiting ${backoff / 1000}s before retry...`);
|
|
375
|
+
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
376
|
+
lastError = err;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const data = await response.json();
|
|
383
|
+
if (!data.data) {
|
|
384
|
+
throw new Error('No embedding data in Jina API response');
|
|
385
|
+
}
|
|
386
|
+
return data.data.map(d => d.embedding);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
389
|
+
console.error(`[EMBEDDINGS] Attempt ${attempt + 1} failed:`, lastError.message);
|
|
390
|
+
|
|
391
|
+
if (attempt < maxRetries) {
|
|
392
|
+
await new Promise(resolve => setTimeout(resolve, 2000 * (attempt + 1)));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
throw lastError || new Error('Embedding generation failed');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function generateImageEmbeddings(imageBuffers, apiKey) {
|
|
401
|
+
const input = imageBuffers.map(buf => ({
|
|
402
|
+
image: `data:image/png;base64,${buf.toString('base64')}`,
|
|
403
|
+
}));
|
|
404
|
+
return callJinaEmbeddings(input, apiKey);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function generateTextEmbeddings(texts, apiKey) {
|
|
408
|
+
const input = texts.map(text => ({ text }));
|
|
409
|
+
return callJinaEmbeddings(input, apiKey);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāā Vector Inserter āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
413
|
+
|
|
414
|
+
function transformImageData(image, index, projectId, uploadId, targetDim) {
|
|
415
|
+
const timestamp = Date.now();
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
primary_key: crypto.randomUUID(),
|
|
419
|
+
text_embedding: padVector(image.textEmbedding, targetDim),
|
|
420
|
+
image_embedding: padVector(image.imageEmbedding, targetDim),
|
|
421
|
+
searchable_text: (image.searchableText || '').substring(0, 65535),
|
|
422
|
+
component_name: image.screenName || 'unknown',
|
|
423
|
+
project_id: projectId,
|
|
424
|
+
timestamp,
|
|
425
|
+
json_content: {
|
|
426
|
+
source_type: 'upload',
|
|
427
|
+
uploadId,
|
|
428
|
+
filename: image.filename,
|
|
429
|
+
screenName: image.screenName,
|
|
430
|
+
screenshotPath: image.screenshotPath,
|
|
431
|
+
inspection: image.inspection,
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function insertImageVectors(images, projectId, uploadId, milvusConfig, options = {}) {
|
|
437
|
+
const batchSize = options.batchSize || 50;
|
|
438
|
+
const maxRetries = options.maxRetries || 1;
|
|
439
|
+
const targetDim = options.targetDim || DEFAULT_TARGET_DIM;
|
|
440
|
+
let totalInserted = 0;
|
|
441
|
+
|
|
442
|
+
for (let i = 0; i < images.length; i += batchSize) {
|
|
443
|
+
const batch = images.slice(i, i + batchSize);
|
|
444
|
+
const records = batch.map((image, idx) => transformImageData(image, i + idx, projectId, uploadId, targetDim));
|
|
445
|
+
|
|
446
|
+
let lastError = null;
|
|
447
|
+
|
|
448
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
449
|
+
try {
|
|
450
|
+
const base = milvusConfig.address.startsWith('https://') || milvusConfig.address.startsWith('http://')
|
|
451
|
+
? milvusConfig.address
|
|
452
|
+
: `https://${milvusConfig.address}`;
|
|
453
|
+
const url = `${base.replace(/\/+$/, '')}/v2/vectordb/entities/insert`;
|
|
454
|
+
|
|
455
|
+
const response = await fetch(url, {
|
|
456
|
+
method: 'POST',
|
|
457
|
+
headers: {
|
|
458
|
+
'Content-Type': 'application/json',
|
|
459
|
+
'Authorization': `Bearer ${milvusConfig.token}`,
|
|
460
|
+
},
|
|
461
|
+
body: JSON.stringify({
|
|
462
|
+
collectionName: milvusConfig.collectionName,
|
|
463
|
+
data: records,
|
|
464
|
+
}),
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
if (!response.ok) {
|
|
468
|
+
const errorText = await response.text();
|
|
469
|
+
throw new Error(`Milvus HTTP error ${response.status}: ${errorText}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const result = await response.json();
|
|
473
|
+
|
|
474
|
+
if (result.code && result.code !== 0) {
|
|
475
|
+
throw new Error(`Milvus API error (code ${result.code}): ${result.message || JSON.stringify(result)}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const insertCount = result.data?.insertCount ?? 0;
|
|
479
|
+
totalInserted += insertCount;
|
|
480
|
+
lastError = null;
|
|
481
|
+
break;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
484
|
+
console.error(`[MILVUS] Insert attempt ${attempt + 1} failed:`, lastError.message);
|
|
485
|
+
|
|
486
|
+
if (attempt < maxRetries) {
|
|
487
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (lastError) throw lastError;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return { insertCount: totalInserted };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāā Orchestrator āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Run the full local image processing pipeline.
|
|
502
|
+
* Replicates the worker processUpload() flow but reads from local disk.
|
|
503
|
+
*
|
|
504
|
+
* @param {object} config
|
|
505
|
+
* @param {string} config.dir - Path to image directory
|
|
506
|
+
* @param {string} config.project - Project identifier
|
|
507
|
+
* @param {string} config.openaiApiKey - OpenAI API key
|
|
508
|
+
* @param {string} config.jinaApiKey - Jina API key
|
|
509
|
+
* @param {string} config.milvusAddress - Milvus/Zilliz endpoint
|
|
510
|
+
* @param {string} config.milvusToken - Milvus/Zilliz auth token
|
|
511
|
+
* @param {string} config.milvusCollection - Milvus collection name
|
|
512
|
+
* @param {boolean} [config.verbose]
|
|
513
|
+
*/
|
|
514
|
+
async function runLocalImageProcessing(config) {
|
|
515
|
+
const logger = createLogger(config);
|
|
516
|
+
const startTime = Date.now();
|
|
517
|
+
const elapsed = () => `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
|
|
518
|
+
|
|
519
|
+
// Generate a local upload ID
|
|
520
|
+
const uploadId = `local-${Date.now()}`;
|
|
521
|
+
|
|
522
|
+
logger.info('=== Local Image Processing Pipeline ===');
|
|
523
|
+
logger.info(`Project: ${config.project}`);
|
|
524
|
+
logger.info(`Upload ID: ${uploadId}`);
|
|
525
|
+
|
|
526
|
+
// Step 1: Find images
|
|
527
|
+
logger.info('Step 1: Scanning for images...');
|
|
528
|
+
const imageFiles = findImageFiles(config.dir);
|
|
529
|
+
if (imageFiles.length === 0) {
|
|
530
|
+
throw new Error(`No image files (.png, .jpg, .jpeg) found in: ${config.dir}`);
|
|
531
|
+
}
|
|
532
|
+
logger.success(`Found ${imageFiles.length} images (${elapsed()})`);
|
|
533
|
+
|
|
534
|
+
// Build image items with file bytes
|
|
535
|
+
const images = imageFiles.map(filePath => {
|
|
536
|
+
const relativePath = path.relative(config.dir, filePath);
|
|
537
|
+
return {
|
|
538
|
+
filename: path.basename(filePath),
|
|
539
|
+
screenshotPath: relativePath,
|
|
540
|
+
screenshotBytes: fs.readFileSync(filePath),
|
|
541
|
+
};
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Step 2: LLM Vision Inspection
|
|
545
|
+
logger.info(`Step 2: LLM inspection for ${images.length} images (batch=5, concurrency=2)...`);
|
|
546
|
+
const inspectionResults = await processBatches(
|
|
547
|
+
images,
|
|
548
|
+
5,
|
|
549
|
+
2,
|
|
550
|
+
async (batch) => batchInspectImages(batch, config.openaiApiKey),
|
|
551
|
+
{
|
|
552
|
+
delayMs: 2000,
|
|
553
|
+
onProgress: (done, total) => logger.debug(` LLM inspection: ${done}/${total} images`),
|
|
554
|
+
}
|
|
555
|
+
);
|
|
556
|
+
logger.success(`LLM inspection complete (${elapsed()})`);
|
|
557
|
+
|
|
558
|
+
// Step 3: Create searchable text
|
|
559
|
+
logger.info('Step 3: Creating searchable text...');
|
|
560
|
+
const searchableTexts = inspectionResults.map(inspection => createSearchableTextFromImage(inspection));
|
|
561
|
+
logger.success(`Searchable text created (${elapsed()})`);
|
|
562
|
+
|
|
563
|
+
// Step 4: Generate image embeddings
|
|
564
|
+
logger.info(`Step 4: Generating image embeddings (batch=3, delay=3s)...`);
|
|
565
|
+
const imageEmbeddings = await processBatches(
|
|
566
|
+
images.map(img => img.screenshotBytes),
|
|
567
|
+
3,
|
|
568
|
+
1,
|
|
569
|
+
async (batch) => generateImageEmbeddings(batch, config.jinaApiKey),
|
|
570
|
+
{
|
|
571
|
+
delayMs: 3000,
|
|
572
|
+
onProgress: (done, total) => logger.debug(` Image embeddings: ${done}/${total}`),
|
|
573
|
+
}
|
|
574
|
+
);
|
|
575
|
+
logger.success(`Image embeddings complete (${elapsed()})`);
|
|
576
|
+
|
|
577
|
+
// Step 5: Generate text embeddings
|
|
578
|
+
logger.info(`Step 5: Generating text embeddings (batch=5, delay=2s)...`);
|
|
579
|
+
const textEmbeddings = await processBatches(
|
|
580
|
+
searchableTexts,
|
|
581
|
+
5,
|
|
582
|
+
1,
|
|
583
|
+
async (batch) => generateTextEmbeddings(batch, config.jinaApiKey),
|
|
584
|
+
{
|
|
585
|
+
delayMs: 2000,
|
|
586
|
+
onProgress: (done, total) => logger.debug(` Text embeddings: ${done}/${total}`),
|
|
587
|
+
}
|
|
588
|
+
);
|
|
589
|
+
logger.success(`Text embeddings complete (${elapsed()})`);
|
|
590
|
+
|
|
591
|
+
// Step 6: Assemble
|
|
592
|
+
logger.info('Step 6: Assembling processed images...');
|
|
593
|
+
const processedImages = [];
|
|
594
|
+
let failedCount = 0;
|
|
595
|
+
|
|
596
|
+
for (let i = 0; i < images.length; i++) {
|
|
597
|
+
const image = images[i];
|
|
598
|
+
const inspection = inspectionResults[i];
|
|
599
|
+
const searchableText = searchableTexts[i];
|
|
600
|
+
const imageEmbedding = imageEmbeddings[i];
|
|
601
|
+
const textEmbedding = textEmbeddings[i];
|
|
602
|
+
|
|
603
|
+
if (!inspection || !imageEmbedding || !textEmbedding) {
|
|
604
|
+
failedCount++;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
processedImages.push({
|
|
609
|
+
filename: image.filename,
|
|
610
|
+
screenName: inspection.screenName,
|
|
611
|
+
screenshotPath: image.screenshotPath,
|
|
612
|
+
inspection,
|
|
613
|
+
searchableText,
|
|
614
|
+
imageEmbedding,
|
|
615
|
+
textEmbedding,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
logger.success(`Assembled: ${processedImages.length} ok, ${failedCount} failed (${elapsed()})`);
|
|
619
|
+
|
|
620
|
+
// Step 7: Insert into Milvus
|
|
621
|
+
if (processedImages.length > 0) {
|
|
622
|
+
logger.info(`Step 7: Inserting ${processedImages.length} vectors into Milvus...`);
|
|
623
|
+
const insertResult = await insertImageVectors(
|
|
624
|
+
processedImages,
|
|
625
|
+
config.project,
|
|
626
|
+
uploadId,
|
|
627
|
+
{
|
|
628
|
+
address: config.milvusAddress,
|
|
629
|
+
token: config.milvusToken,
|
|
630
|
+
collectionName: config.milvusCollection,
|
|
631
|
+
}
|
|
632
|
+
);
|
|
633
|
+
logger.success(`Milvus insert complete: ${insertResult.insertCount} records (${elapsed()})`);
|
|
634
|
+
} else {
|
|
635
|
+
logger.info('Step 7: No images to insert into Milvus');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Summary
|
|
639
|
+
const status = failedCount === 0
|
|
640
|
+
? 'completed'
|
|
641
|
+
: processedImages.length > 0
|
|
642
|
+
? 'partial'
|
|
643
|
+
: 'failed';
|
|
644
|
+
|
|
645
|
+
logger.success(`\n=== Processing Complete (${elapsed()}) ===`);
|
|
646
|
+
logger.info(`Status: ${status}`);
|
|
647
|
+
logger.info(`Processed: ${processedImages.length}/${images.length} images`);
|
|
648
|
+
if (failedCount > 0) logger.info(`Failed: ${failedCount} images`);
|
|
649
|
+
logger.info(`Upload ID: ${uploadId}`);
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
uploadId,
|
|
653
|
+
projectId: config.project,
|
|
654
|
+
totalImages: images.length,
|
|
655
|
+
processedImages: processedImages.length,
|
|
656
|
+
failedImages: failedCount,
|
|
657
|
+
status,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
module.exports = {
|
|
662
|
+
runLocalImageProcessing,
|
|
663
|
+
};
|
package/lib/logger.js
CHANGED
|
@@ -5,7 +5,7 @@ const chalk = require('chalk');
|
|
|
5
5
|
* The logger's behavior is controlled by the arguments passed to the CLI.
|
|
6
6
|
* @param {object} argv The arguments object from yargs.
|
|
7
7
|
* @param {boolean} argv.verbose Whether to enable verbose (debug) logging.
|
|
8
|
-
* @returns {{info: Function, error: Function, debug: Function, success: Function}}
|
|
8
|
+
* @returns {{info: Function, warn: Function, error: Function, debug: Function, success: Function}}
|
|
9
9
|
*/
|
|
10
10
|
function createLogger({ verbose = false }) {
|
|
11
11
|
const Sentry = require('@sentry/node');
|
|
@@ -31,6 +31,21 @@ function createLogger({ verbose = false }) {
|
|
|
31
31
|
console.log(chalk.green(message));
|
|
32
32
|
},
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Logs a warning: something the operator must act on, but which did not
|
|
36
|
+
* fail the command. Distinct from error() so it does not read as a failed
|
|
37
|
+
* deployment.
|
|
38
|
+
* @param {string} message The message to log.
|
|
39
|
+
*/
|
|
40
|
+
warn: (message) => {
|
|
41
|
+
console.warn(chalk.yellow(message));
|
|
42
|
+
Sentry.addBreadcrumb({
|
|
43
|
+
category: 'log',
|
|
44
|
+
message: message,
|
|
45
|
+
level: 'warning',
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
|
|
34
49
|
/**
|
|
35
50
|
* Logs an error message.
|
|
36
51
|
* @param {string} message The message to log.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scrymore/scry-deployer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A CLI to automate the deployment of Storybook static builds.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"axios": "^1.12.2",
|
|
44
44
|
"chalk": "^4.1.2",
|
|
45
45
|
"commander": "^11.1.0",
|
|
46
|
+
"fast-xml-parser": "^5.5.7",
|
|
46
47
|
"form-data": "^4.0.0",
|
|
47
48
|
"inquirer": "^8.2.6",
|
|
48
49
|
"open": "^8.4.2",
|