@siftd/connect-agent 0.2.29 → 0.2.31
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/dist/agent.js +35 -7
- package/dist/core/asset-api.d.ts +94 -0
- package/dist/core/asset-api.js +182 -0
- package/dist/core/assets.d.ts +176 -0
- package/dist/core/assets.js +192 -0
- package/dist/core/preview-worker.d.ts +98 -0
- package/dist/core/preview-worker.js +395 -0
- package/dist/genesis/tool-patterns.json +9 -4
- package/dist/orchestrator.js +24 -5
- package/dist/prompts/worker-system.d.ts +1 -1
- package/dist/prompts/worker-system.js +34 -7
- package/dist/websocket.d.ts +19 -2
- package/dist/websocket.js +40 -2
- package/package.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset System - Fast-path preview architecture
|
|
3
|
+
*
|
|
4
|
+
* Assets are files produced by workers with metadata for instant preview.
|
|
5
|
+
* The preview-worker generates thumbnails/metadata WITHOUT using the LLM.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'crypto';
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
|
|
9
|
+
import { basename, extname, join } from 'path';
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// UTILITY FUNCTIONS
|
|
12
|
+
// ============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* Generate SHA256 hash of file contents
|
|
15
|
+
*/
|
|
16
|
+
export function hashFile(filePath) {
|
|
17
|
+
const content = readFileSync(filePath);
|
|
18
|
+
return createHash('sha256').update(content).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Get MIME type from file extension
|
|
22
|
+
*/
|
|
23
|
+
export function getMimeType(filePath) {
|
|
24
|
+
const ext = extname(filePath).toLowerCase().slice(1);
|
|
25
|
+
const mimeMap = {
|
|
26
|
+
// Images
|
|
27
|
+
png: 'image/png',
|
|
28
|
+
jpg: 'image/jpeg',
|
|
29
|
+
jpeg: 'image/jpeg',
|
|
30
|
+
gif: 'image/gif',
|
|
31
|
+
svg: 'image/svg+xml',
|
|
32
|
+
webp: 'image/webp',
|
|
33
|
+
ico: 'image/x-icon',
|
|
34
|
+
// Documents
|
|
35
|
+
pdf: 'application/pdf',
|
|
36
|
+
doc: 'application/msword',
|
|
37
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
38
|
+
// Code/Text
|
|
39
|
+
js: 'text/javascript',
|
|
40
|
+
ts: 'text/typescript',
|
|
41
|
+
jsx: 'text/javascript',
|
|
42
|
+
tsx: 'text/typescript',
|
|
43
|
+
json: 'application/json',
|
|
44
|
+
html: 'text/html',
|
|
45
|
+
css: 'text/css',
|
|
46
|
+
md: 'text/markdown',
|
|
47
|
+
txt: 'text/plain',
|
|
48
|
+
csv: 'text/csv',
|
|
49
|
+
xml: 'text/xml',
|
|
50
|
+
yaml: 'text/yaml',
|
|
51
|
+
yml: 'text/yaml',
|
|
52
|
+
sh: 'text/x-shellscript',
|
|
53
|
+
py: 'text/x-python',
|
|
54
|
+
// Data
|
|
55
|
+
sqlite: 'application/x-sqlite3',
|
|
56
|
+
db: 'application/x-sqlite3',
|
|
57
|
+
// Video/Audio
|
|
58
|
+
mp4: 'video/mp4',
|
|
59
|
+
webm: 'video/webm',
|
|
60
|
+
mp3: 'audio/mpeg',
|
|
61
|
+
wav: 'audio/wav',
|
|
62
|
+
};
|
|
63
|
+
return mimeMap[ext] || 'application/octet-stream';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Generate unique asset ID
|
|
67
|
+
*/
|
|
68
|
+
export function generateAssetId() {
|
|
69
|
+
const timestamp = Date.now().toString(36);
|
|
70
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
71
|
+
return `asset_${timestamp}_${random}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Create asset manifest for a file
|
|
75
|
+
*/
|
|
76
|
+
export function createAssetManifest(filePath, options = {}) {
|
|
77
|
+
const stats = statSync(filePath);
|
|
78
|
+
return {
|
|
79
|
+
id: generateAssetId(),
|
|
80
|
+
path: filePath,
|
|
81
|
+
name: basename(filePath),
|
|
82
|
+
mime: getMimeType(filePath),
|
|
83
|
+
sha256: hashFile(filePath),
|
|
84
|
+
size: stats.size,
|
|
85
|
+
createdAt: Date.now(),
|
|
86
|
+
groupId: options.groupId,
|
|
87
|
+
label: options.label,
|
|
88
|
+
kind: 'file',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Write asset manifest alongside the file
|
|
93
|
+
*/
|
|
94
|
+
export function writeAssetManifest(manifest) {
|
|
95
|
+
const manifestPath = manifest.path + '.asset.json';
|
|
96
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Read asset manifest for a file
|
|
100
|
+
*/
|
|
101
|
+
export function readAssetManifest(filePath) {
|
|
102
|
+
const manifestPath = filePath + '.asset.json';
|
|
103
|
+
if (!existsSync(manifestPath))
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get preview cache directory
|
|
114
|
+
*/
|
|
115
|
+
export function getPreviewCacheDir() {
|
|
116
|
+
const home = process.env.HOME || '/tmp';
|
|
117
|
+
const cacheDir = join(home, '.connect-hub', 'preview-cache');
|
|
118
|
+
if (!existsSync(cacheDir)) {
|
|
119
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
return cacheDir;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Generate cache key for preview
|
|
125
|
+
*/
|
|
126
|
+
export function getPreviewCacheKey(sha256, params) {
|
|
127
|
+
const paramsStr = params ? JSON.stringify(params) : '';
|
|
128
|
+
return createHash('sha256').update(sha256 + paramsStr).digest('hex').substring(0, 16);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Check if preview exists in cache
|
|
132
|
+
*/
|
|
133
|
+
export function getPreviewFromCache(cacheKey) {
|
|
134
|
+
const cachePath = join(getPreviewCacheDir(), `${cacheKey}.json`);
|
|
135
|
+
if (!existsSync(cachePath))
|
|
136
|
+
return null;
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(readFileSync(cachePath, 'utf-8'));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Save preview to cache
|
|
146
|
+
*/
|
|
147
|
+
export function savePreviewToCache(preview) {
|
|
148
|
+
const cachePath = join(getPreviewCacheDir(), `${preview.cacheKey}.json`);
|
|
149
|
+
writeFileSync(cachePath, JSON.stringify(preview, null, 2));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Create a ViewSpec for comparing two assets
|
|
153
|
+
*/
|
|
154
|
+
export function createCompareView(beforeAssetId, afterAssetId, title = 'Comparison') {
|
|
155
|
+
return {
|
|
156
|
+
id: `view_${Date.now().toString(36)}`,
|
|
157
|
+
title,
|
|
158
|
+
layout: 'compare',
|
|
159
|
+
before: { type: 'asset', assetId: beforeAssetId, label: 'Before' },
|
|
160
|
+
after: { type: 'asset', assetId: afterAssetId, label: 'After' },
|
|
161
|
+
createdAt: Date.now(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Create a ViewSpec for a grid of assets
|
|
166
|
+
*/
|
|
167
|
+
export function createGridView(assetIds, title = 'Gallery') {
|
|
168
|
+
return {
|
|
169
|
+
id: `view_${Date.now().toString(36)}`,
|
|
170
|
+
title,
|
|
171
|
+
layout: 'grid',
|
|
172
|
+
items: assetIds.map(id => ({ type: 'asset', assetId: id })),
|
|
173
|
+
createdAt: Date.now(),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Create a ViewSpec for a report with markdown and assets
|
|
178
|
+
*/
|
|
179
|
+
export function createReportView(title, sections) {
|
|
180
|
+
return {
|
|
181
|
+
id: `view_${Date.now().toString(36)}`,
|
|
182
|
+
title,
|
|
183
|
+
layout: 'stack',
|
|
184
|
+
items: sections.map(section => {
|
|
185
|
+
if (section.markdown) {
|
|
186
|
+
return { type: 'markdown', content: section.markdown, label: section.label };
|
|
187
|
+
}
|
|
188
|
+
return { type: 'asset', assetId: section.assetId, label: section.label };
|
|
189
|
+
}),
|
|
190
|
+
createdAt: Date.now(),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview Worker - Fast-path local preview generation
|
|
3
|
+
*
|
|
4
|
+
* Watches ~/Lia-Hub/shared/outputs/ and generates:
|
|
5
|
+
* - Thumbnails (for images)
|
|
6
|
+
* - Metadata extraction (dimensions, page count, text snippets)
|
|
7
|
+
* - Preview JSON for instant gallery display
|
|
8
|
+
*
|
|
9
|
+
* NO LLM involvement - pure local, deterministic processing.
|
|
10
|
+
* Targets sub-second response for most assets.
|
|
11
|
+
*/
|
|
12
|
+
import { AssetManifest, AssetPreview } from './assets.js';
|
|
13
|
+
export interface PreviewWorkerOptions {
|
|
14
|
+
/** Directories to watch */
|
|
15
|
+
watchDirs?: string[];
|
|
16
|
+
/** Max concurrent preview jobs */
|
|
17
|
+
concurrency?: number;
|
|
18
|
+
/** Callback when asset is discovered */
|
|
19
|
+
onAsset?: (manifest: AssetManifest) => void;
|
|
20
|
+
/** Callback when preview is ready */
|
|
21
|
+
onPreview?: (preview: AssetPreview) => void;
|
|
22
|
+
/** Enable verbose logging */
|
|
23
|
+
verbose?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare class PreviewWorker {
|
|
26
|
+
private watchers;
|
|
27
|
+
private queue;
|
|
28
|
+
private processing;
|
|
29
|
+
private concurrency;
|
|
30
|
+
private options;
|
|
31
|
+
private running;
|
|
32
|
+
constructor(options?: PreviewWorkerOptions);
|
|
33
|
+
/**
|
|
34
|
+
* Start watching directories
|
|
35
|
+
*/
|
|
36
|
+
start(): void;
|
|
37
|
+
/**
|
|
38
|
+
* Stop watching
|
|
39
|
+
*/
|
|
40
|
+
stop(): void;
|
|
41
|
+
/**
|
|
42
|
+
* Scan directory for existing files
|
|
43
|
+
*/
|
|
44
|
+
private scanDirectory;
|
|
45
|
+
/**
|
|
46
|
+
* Add file to processing queue
|
|
47
|
+
*/
|
|
48
|
+
private enqueue;
|
|
49
|
+
/**
|
|
50
|
+
* Process queue with concurrency limit
|
|
51
|
+
*/
|
|
52
|
+
private processQueue;
|
|
53
|
+
/**
|
|
54
|
+
* Process a single file - generate manifest and preview
|
|
55
|
+
*/
|
|
56
|
+
private processFile;
|
|
57
|
+
/**
|
|
58
|
+
* Generate preview for an asset
|
|
59
|
+
*/
|
|
60
|
+
private generatePreview;
|
|
61
|
+
/**
|
|
62
|
+
* Get image dimensions using sips (macOS) or file command
|
|
63
|
+
*/
|
|
64
|
+
private getImageInfo;
|
|
65
|
+
/**
|
|
66
|
+
* Generate thumbnail as base64 data URL
|
|
67
|
+
* Uses sips on macOS for fast resizing
|
|
68
|
+
*/
|
|
69
|
+
private generateImageThumbnail;
|
|
70
|
+
/**
|
|
71
|
+
* Get PDF info using pdfinfo or mdls
|
|
72
|
+
*/
|
|
73
|
+
private getPdfInfo;
|
|
74
|
+
/**
|
|
75
|
+
* Get text snippet from file
|
|
76
|
+
*/
|
|
77
|
+
private getTextSnippet;
|
|
78
|
+
/**
|
|
79
|
+
* Extract group ID from file path
|
|
80
|
+
* e.g., ~/Lia-Hub/shared/outputs/run_abc123/file.png -> run_abc123
|
|
81
|
+
*/
|
|
82
|
+
private extractGroupId;
|
|
83
|
+
private log;
|
|
84
|
+
}
|
|
85
|
+
export interface AssetRegistry {
|
|
86
|
+
assets: Map<string, AssetManifest>;
|
|
87
|
+
previews: Map<string, AssetPreview>;
|
|
88
|
+
byGroup: Map<string, Set<string>>;
|
|
89
|
+
}
|
|
90
|
+
export declare function getAssetRegistry(): AssetRegistry;
|
|
91
|
+
export declare function registerAsset(manifest: AssetManifest): void;
|
|
92
|
+
export declare function registerPreview(preview: AssetPreview): void;
|
|
93
|
+
export declare function getAsset(assetId: string): AssetManifest | undefined;
|
|
94
|
+
export declare function getPreview(assetId: string): AssetPreview | undefined;
|
|
95
|
+
export declare function getAssetsByGroup(groupId: string): AssetManifest[];
|
|
96
|
+
export declare function getAllAssets(): AssetManifest[];
|
|
97
|
+
export declare function startPreviewWorker(options?: PreviewWorkerOptions): PreviewWorker;
|
|
98
|
+
export declare function stopPreviewWorker(): void;
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview Worker - Fast-path local preview generation
|
|
3
|
+
*
|
|
4
|
+
* Watches ~/Lia-Hub/shared/outputs/ and generates:
|
|
5
|
+
* - Thumbnails (for images)
|
|
6
|
+
* - Metadata extraction (dimensions, page count, text snippets)
|
|
7
|
+
* - Preview JSON for instant gallery display
|
|
8
|
+
*
|
|
9
|
+
* NO LLM involvement - pure local, deterministic processing.
|
|
10
|
+
* Targets sub-second response for most assets.
|
|
11
|
+
*/
|
|
12
|
+
import { watch } from 'fs';
|
|
13
|
+
import { readFileSync, existsSync, readdirSync, statSync, mkdirSync } from 'fs';
|
|
14
|
+
import { join, basename } from 'path';
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import { createAssetManifest, writeAssetManifest, readAssetManifest, getPreviewCacheDir, getPreviewCacheKey, getPreviewFromCache, savePreviewToCache, } from './assets.js';
|
|
17
|
+
import { getSharedOutputPath } from './hub.js';
|
|
18
|
+
export class PreviewWorker {
|
|
19
|
+
watchers = [];
|
|
20
|
+
queue = [];
|
|
21
|
+
processing = new Set();
|
|
22
|
+
concurrency;
|
|
23
|
+
options;
|
|
24
|
+
running = false;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
this.concurrency = options.concurrency || 4;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Start watching directories
|
|
31
|
+
*/
|
|
32
|
+
start() {
|
|
33
|
+
if (this.running)
|
|
34
|
+
return;
|
|
35
|
+
this.running = true;
|
|
36
|
+
const dirs = this.options.watchDirs || [getSharedOutputPath()];
|
|
37
|
+
for (const dir of dirs) {
|
|
38
|
+
if (!existsSync(dir)) {
|
|
39
|
+
mkdirSync(dir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
this.log(`Watching: ${dir}`);
|
|
42
|
+
// Initial scan
|
|
43
|
+
this.scanDirectory(dir);
|
|
44
|
+
// Watch for changes
|
|
45
|
+
const watcher = watch(dir, { recursive: true }, (event, filename) => {
|
|
46
|
+
if (!filename)
|
|
47
|
+
return;
|
|
48
|
+
const filePath = join(dir, filename);
|
|
49
|
+
// Skip manifest files and hidden files
|
|
50
|
+
if (filename.endsWith('.asset.json') || filename.startsWith('.'))
|
|
51
|
+
return;
|
|
52
|
+
// Skip directories
|
|
53
|
+
try {
|
|
54
|
+
if (statSync(filePath).isDirectory())
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return; // File may have been deleted
|
|
59
|
+
}
|
|
60
|
+
this.enqueue(filePath);
|
|
61
|
+
});
|
|
62
|
+
this.watchers.push(watcher);
|
|
63
|
+
}
|
|
64
|
+
// Start processing queue
|
|
65
|
+
this.processQueue();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Stop watching
|
|
69
|
+
*/
|
|
70
|
+
stop() {
|
|
71
|
+
this.running = false;
|
|
72
|
+
for (const watcher of this.watchers) {
|
|
73
|
+
watcher.close();
|
|
74
|
+
}
|
|
75
|
+
this.watchers = [];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Scan directory for existing files
|
|
79
|
+
*/
|
|
80
|
+
scanDirectory(dir) {
|
|
81
|
+
try {
|
|
82
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const fullPath = join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
this.scanDirectory(fullPath);
|
|
87
|
+
}
|
|
88
|
+
else if (!entry.name.endsWith('.asset.json') && !entry.name.startsWith('.')) {
|
|
89
|
+
this.enqueue(fullPath);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
this.log(`Scan error: ${error}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Add file to processing queue
|
|
99
|
+
*/
|
|
100
|
+
enqueue(filePath) {
|
|
101
|
+
if (this.queue.includes(filePath) || this.processing.has(filePath)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.queue.push(filePath);
|
|
105
|
+
this.processQueue();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Process queue with concurrency limit
|
|
109
|
+
*/
|
|
110
|
+
async processQueue() {
|
|
111
|
+
while (this.running && this.queue.length > 0 && this.processing.size < this.concurrency) {
|
|
112
|
+
const filePath = this.queue.shift();
|
|
113
|
+
if (!filePath)
|
|
114
|
+
continue;
|
|
115
|
+
this.processing.add(filePath);
|
|
116
|
+
this.processFile(filePath)
|
|
117
|
+
.catch(err => this.log(`Error processing ${filePath}: ${err}`))
|
|
118
|
+
.finally(() => {
|
|
119
|
+
this.processing.delete(filePath);
|
|
120
|
+
this.processQueue();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Process a single file - generate manifest and preview
|
|
126
|
+
*/
|
|
127
|
+
async processFile(filePath) {
|
|
128
|
+
// Skip if file no longer exists
|
|
129
|
+
if (!existsSync(filePath))
|
|
130
|
+
return;
|
|
131
|
+
const startTime = Date.now();
|
|
132
|
+
// Check for existing manifest
|
|
133
|
+
let manifest = readAssetManifest(filePath);
|
|
134
|
+
if (!manifest) {
|
|
135
|
+
// Create new manifest
|
|
136
|
+
manifest = createAssetManifest(filePath, {
|
|
137
|
+
groupId: this.extractGroupId(filePath),
|
|
138
|
+
});
|
|
139
|
+
writeAssetManifest(manifest);
|
|
140
|
+
this.log(`Created manifest: ${manifest.name} (${manifest.id})`);
|
|
141
|
+
this.options.onAsset?.(manifest);
|
|
142
|
+
}
|
|
143
|
+
// Check preview cache
|
|
144
|
+
const cacheKey = getPreviewCacheKey(manifest.sha256);
|
|
145
|
+
let preview = getPreviewFromCache(cacheKey);
|
|
146
|
+
if (!preview) {
|
|
147
|
+
// Generate preview
|
|
148
|
+
preview = await this.generatePreview(manifest, cacheKey);
|
|
149
|
+
savePreviewToCache(preview);
|
|
150
|
+
const elapsed = Date.now() - startTime;
|
|
151
|
+
this.log(`Generated preview: ${manifest.name} (${elapsed}ms)`);
|
|
152
|
+
}
|
|
153
|
+
this.options.onPreview?.(preview);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Generate preview for an asset
|
|
157
|
+
*/
|
|
158
|
+
async generatePreview(manifest, cacheKey) {
|
|
159
|
+
const preview = {
|
|
160
|
+
assetId: manifest.id,
|
|
161
|
+
generatedAt: Date.now(),
|
|
162
|
+
cacheKey,
|
|
163
|
+
};
|
|
164
|
+
const mime = manifest.mime;
|
|
165
|
+
// Image preview
|
|
166
|
+
if (mime.startsWith('image/')) {
|
|
167
|
+
const imageInfo = this.getImageInfo(manifest.path);
|
|
168
|
+
if (imageInfo) {
|
|
169
|
+
preview.dimensions = imageInfo.dimensions;
|
|
170
|
+
preview.thumbnailData = this.generateImageThumbnail(manifest.path);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// PDF preview
|
|
174
|
+
else if (mime === 'application/pdf') {
|
|
175
|
+
const pdfInfo = this.getPdfInfo(manifest.path);
|
|
176
|
+
if (pdfInfo) {
|
|
177
|
+
preview.pageCount = pdfInfo.pageCount;
|
|
178
|
+
preview.textSnippet = pdfInfo.textSnippet;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Text/code preview
|
|
182
|
+
else if (mime.startsWith('text/') || mime === 'application/json') {
|
|
183
|
+
preview.textSnippet = this.getTextSnippet(manifest.path);
|
|
184
|
+
}
|
|
185
|
+
return preview;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Get image dimensions using sips (macOS) or file command
|
|
189
|
+
*/
|
|
190
|
+
getImageInfo(filePath) {
|
|
191
|
+
try {
|
|
192
|
+
// Try sips on macOS
|
|
193
|
+
if (process.platform === 'darwin') {
|
|
194
|
+
const output = execSync(`sips -g pixelWidth -g pixelHeight "${filePath}" 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 });
|
|
195
|
+
const widthMatch = output.match(/pixelWidth:\s*(\d+)/);
|
|
196
|
+
const heightMatch = output.match(/pixelHeight:\s*(\d+)/);
|
|
197
|
+
if (widthMatch && heightMatch) {
|
|
198
|
+
return {
|
|
199
|
+
dimensions: {
|
|
200
|
+
width: parseInt(widthMatch[1], 10),
|
|
201
|
+
height: parseInt(heightMatch[1], 10),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Try file command (cross-platform)
|
|
207
|
+
const output = execSync(`file "${filePath}"`, { encoding: 'utf-8', timeout: 5000 });
|
|
208
|
+
const match = output.match(/(\d+)\s*x\s*(\d+)/);
|
|
209
|
+
if (match) {
|
|
210
|
+
return {
|
|
211
|
+
dimensions: {
|
|
212
|
+
width: parseInt(match[1], 10),
|
|
213
|
+
height: parseInt(match[2], 10),
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Ignore errors
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Generate thumbnail as base64 data URL
|
|
225
|
+
* Uses sips on macOS for fast resizing
|
|
226
|
+
*/
|
|
227
|
+
generateImageThumbnail(filePath, maxSize = 200) {
|
|
228
|
+
try {
|
|
229
|
+
if (process.platform !== 'darwin')
|
|
230
|
+
return undefined;
|
|
231
|
+
const cacheDir = getPreviewCacheDir();
|
|
232
|
+
const thumbName = `thumb_${basename(filePath)}.jpg`;
|
|
233
|
+
const thumbPath = join(cacheDir, thumbName);
|
|
234
|
+
// Use sips to resize
|
|
235
|
+
execSync(`sips -Z ${maxSize} -s format jpeg "${filePath}" --out "${thumbPath}" 2>/dev/null`, { timeout: 10000 });
|
|
236
|
+
// Read as base64
|
|
237
|
+
const data = readFileSync(thumbPath);
|
|
238
|
+
return `data:image/jpeg;base64,${data.toString('base64')}`;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Get PDF info using pdfinfo or mdls
|
|
246
|
+
*/
|
|
247
|
+
getPdfInfo(filePath) {
|
|
248
|
+
try {
|
|
249
|
+
let pageCount;
|
|
250
|
+
// Try pdfinfo
|
|
251
|
+
try {
|
|
252
|
+
const output = execSync(`pdfinfo "${filePath}" 2>/dev/null`, {
|
|
253
|
+
encoding: 'utf-8',
|
|
254
|
+
timeout: 5000,
|
|
255
|
+
});
|
|
256
|
+
const match = output.match(/Pages:\s*(\d+)/);
|
|
257
|
+
if (match) {
|
|
258
|
+
pageCount = parseInt(match[1], 10);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Try mdls on macOS
|
|
263
|
+
if (process.platform === 'darwin') {
|
|
264
|
+
const output = execSync(`mdls -name kMDItemNumberOfPages "${filePath}" 2>/dev/null`, {
|
|
265
|
+
encoding: 'utf-8',
|
|
266
|
+
timeout: 5000,
|
|
267
|
+
});
|
|
268
|
+
const match = output.match(/kMDItemNumberOfPages\s*=\s*(\d+)/);
|
|
269
|
+
if (match) {
|
|
270
|
+
pageCount = parseInt(match[1], 10);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Try to extract text snippet
|
|
275
|
+
let textSnippet;
|
|
276
|
+
try {
|
|
277
|
+
const output = execSync(`pdftotext -l 1 -q "${filePath}" - 2>/dev/null | head -c 500`, {
|
|
278
|
+
encoding: 'utf-8',
|
|
279
|
+
timeout: 10000,
|
|
280
|
+
});
|
|
281
|
+
if (output.trim()) {
|
|
282
|
+
textSnippet = output.trim().substring(0, 500);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// pdftotext not available
|
|
287
|
+
}
|
|
288
|
+
return { pageCount, textSnippet };
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Get text snippet from file
|
|
296
|
+
*/
|
|
297
|
+
getTextSnippet(filePath, maxChars = 500) {
|
|
298
|
+
try {
|
|
299
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
300
|
+
return content.substring(0, maxChars);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Extract group ID from file path
|
|
308
|
+
* e.g., ~/Lia-Hub/shared/outputs/run_abc123/file.png -> run_abc123
|
|
309
|
+
*/
|
|
310
|
+
extractGroupId(filePath) {
|
|
311
|
+
const outputPath = getSharedOutputPath();
|
|
312
|
+
if (filePath.startsWith(outputPath)) {
|
|
313
|
+
const relative = filePath.substring(outputPath.length + 1);
|
|
314
|
+
const parts = relative.split('/');
|
|
315
|
+
if (parts.length > 1) {
|
|
316
|
+
return parts[0]; // First directory is the group
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
log(message) {
|
|
322
|
+
if (this.options.verbose) {
|
|
323
|
+
console.log(`[PREVIEW] ${message}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
let registry = null;
|
|
328
|
+
export function getAssetRegistry() {
|
|
329
|
+
if (!registry) {
|
|
330
|
+
registry = {
|
|
331
|
+
assets: new Map(),
|
|
332
|
+
previews: new Map(),
|
|
333
|
+
byGroup: new Map(),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return registry;
|
|
337
|
+
}
|
|
338
|
+
export function registerAsset(manifest) {
|
|
339
|
+
const reg = getAssetRegistry();
|
|
340
|
+
reg.assets.set(manifest.id, manifest);
|
|
341
|
+
if (manifest.groupId) {
|
|
342
|
+
if (!reg.byGroup.has(manifest.groupId)) {
|
|
343
|
+
reg.byGroup.set(manifest.groupId, new Set());
|
|
344
|
+
}
|
|
345
|
+
reg.byGroup.get(manifest.groupId).add(manifest.id);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
export function registerPreview(preview) {
|
|
349
|
+
const reg = getAssetRegistry();
|
|
350
|
+
reg.previews.set(preview.assetId, preview);
|
|
351
|
+
}
|
|
352
|
+
export function getAsset(assetId) {
|
|
353
|
+
return getAssetRegistry().assets.get(assetId);
|
|
354
|
+
}
|
|
355
|
+
export function getPreview(assetId) {
|
|
356
|
+
return getAssetRegistry().previews.get(assetId);
|
|
357
|
+
}
|
|
358
|
+
export function getAssetsByGroup(groupId) {
|
|
359
|
+
const reg = getAssetRegistry();
|
|
360
|
+
const ids = reg.byGroup.get(groupId);
|
|
361
|
+
if (!ids)
|
|
362
|
+
return [];
|
|
363
|
+
return Array.from(ids).map(id => reg.assets.get(id)).filter(Boolean);
|
|
364
|
+
}
|
|
365
|
+
export function getAllAssets() {
|
|
366
|
+
return Array.from(getAssetRegistry().assets.values());
|
|
367
|
+
}
|
|
368
|
+
// ============================================================================
|
|
369
|
+
// SINGLETON WORKER
|
|
370
|
+
// ============================================================================
|
|
371
|
+
let previewWorker = null;
|
|
372
|
+
export function startPreviewWorker(options = {}) {
|
|
373
|
+
if (previewWorker) {
|
|
374
|
+
return previewWorker;
|
|
375
|
+
}
|
|
376
|
+
previewWorker = new PreviewWorker({
|
|
377
|
+
...options,
|
|
378
|
+
onAsset: (manifest) => {
|
|
379
|
+
registerAsset(manifest);
|
|
380
|
+
options.onAsset?.(manifest);
|
|
381
|
+
},
|
|
382
|
+
onPreview: (preview) => {
|
|
383
|
+
registerPreview(preview);
|
|
384
|
+
options.onPreview?.(preview);
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
previewWorker.start();
|
|
388
|
+
return previewWorker;
|
|
389
|
+
}
|
|
390
|
+
export function stopPreviewWorker() {
|
|
391
|
+
if (previewWorker) {
|
|
392
|
+
previewWorker.stop();
|
|
393
|
+
previewWorker = null;
|
|
394
|
+
}
|
|
395
|
+
}
|