@ian2018cs/agenthub 0.1.43 → 0.1.45
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/assets/index-C7OUtEPY.js +152 -0
- package/dist/assets/index-DJIdQPR_.css +32 -0
- package/dist/assets/{vendor-icons-CDkQcAKw.js → vendor-icons-B1SOlwgw.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/server/database/db.js +94 -1
- package/server/index.js +107 -4
- package/server/services/image-cleanup.js +79 -0
- package/server/services/image-storage.js +78 -0
- package/dist/assets/index-BFl_Dhvn.css +0 -32
- package/dist/assets/index-hV3zc8w4.js +0 -151
package/server/index.js
CHANGED
|
@@ -55,11 +55,13 @@ import usageRoutes from './routes/usage.js';
|
|
|
55
55
|
import skillsRoutes from './routes/skills.js';
|
|
56
56
|
import mcpReposRoutes from './routes/mcp-repos.js';
|
|
57
57
|
import settingsRoutes from './routes/settings.js';
|
|
58
|
-
import { initializeDatabase, userDb } from './database/db.js';
|
|
58
|
+
import { initializeDatabase, userDb, imageDb } from './database/db.js';
|
|
59
59
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
60
60
|
import { getUserPaths, initCodexDirectories, initGeminiDirectories } from './services/user-directories.js';
|
|
61
61
|
import { startUsageScanner } from './services/usage-scanner.js';
|
|
62
62
|
import { startFeishuService, stopFeishuService } from './services/feishu/index.js';
|
|
63
|
+
import { saveImage, getImagePath } from './services/image-storage.js';
|
|
64
|
+
import { startImageCleanup } from './services/image-cleanup.js';
|
|
63
65
|
|
|
64
66
|
// File system watcher for projects folder - per user
|
|
65
67
|
const userWatchers = new Map(); // Map<userUuid, { watcher, clients: Set<ws> }>
|
|
@@ -382,13 +384,40 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT
|
|
|
382
384
|
|
|
383
385
|
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset, req.user.uuid);
|
|
384
386
|
|
|
387
|
+
// Query persistent images for this session
|
|
388
|
+
let sessionImages = [];
|
|
389
|
+
try {
|
|
390
|
+
const images = imageDb.getImagesBySession(sessionId);
|
|
391
|
+
if (images.length > 0) {
|
|
392
|
+
// Group by upload_batch_id
|
|
393
|
+
const batchMap = new Map();
|
|
394
|
+
for (const img of images) {
|
|
395
|
+
if (!batchMap.has(img.upload_batch_id)) {
|
|
396
|
+
batchMap.set(img.upload_batch_id, {
|
|
397
|
+
batchId: img.upload_batch_id,
|
|
398
|
+
messageContent: img.message_content,
|
|
399
|
+
createdAt: img.created_at,
|
|
400
|
+
images: [],
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
batchMap.get(img.upload_batch_id).images.push({
|
|
404
|
+
id: img.id,
|
|
405
|
+
name: img.original_name,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
sessionImages = Array.from(batchMap.values());
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
console.error('[WARN] Failed to load session images:', err.message);
|
|
412
|
+
}
|
|
413
|
+
|
|
385
414
|
// Handle both old and new response formats
|
|
386
415
|
if (Array.isArray(result)) {
|
|
387
416
|
// Backward compatibility: no pagination parameters were provided
|
|
388
|
-
res.json({ messages: result });
|
|
417
|
+
res.json({ messages: result, sessionImages });
|
|
389
418
|
} else {
|
|
390
419
|
// New format with pagination info
|
|
391
|
-
res.json(result);
|
|
420
|
+
res.json({ ...result, sessionImages });
|
|
392
421
|
}
|
|
393
422
|
} catch (error) {
|
|
394
423
|
res.status(500).json({ error: error.message });
|
|
@@ -729,6 +758,22 @@ function handleChatConnection(ws, userData) {
|
|
|
729
758
|
...data.options,
|
|
730
759
|
userUuid,
|
|
731
760
|
}, writer);
|
|
761
|
+
|
|
762
|
+
// Associate uploaded images with the session after query completes
|
|
763
|
+
if (data.options?.uploadBatchId) {
|
|
764
|
+
const sessionId = writer.getSessionId();
|
|
765
|
+
if (sessionId) {
|
|
766
|
+
try {
|
|
767
|
+
imageDb.associateBatch(
|
|
768
|
+
data.options.uploadBatchId,
|
|
769
|
+
sessionId,
|
|
770
|
+
data.command?.substring(0, 500) || ''
|
|
771
|
+
);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
console.error('[WARN] Failed to associate image batch:', err.message);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
732
777
|
} else if (data.type === 'abort-session') {
|
|
733
778
|
console.log('[DEBUG] Abort session request:', data.sessionId);
|
|
734
779
|
// Use Claude Agents SDK
|
|
@@ -1749,6 +1794,40 @@ Agent instructions:`;
|
|
|
1749
1794
|
}
|
|
1750
1795
|
});
|
|
1751
1796
|
|
|
1797
|
+
// Serve persistent chat images
|
|
1798
|
+
app.get('/api/images/:imageId', authenticateToken, (req, res) => {
|
|
1799
|
+
try {
|
|
1800
|
+
const imageId = parseInt(req.params.imageId, 10);
|
|
1801
|
+
if (isNaN(imageId)) {
|
|
1802
|
+
return res.status(400).json({ error: 'Invalid image ID' });
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
const image = imageDb.getImageById(imageId);
|
|
1806
|
+
if (!image) {
|
|
1807
|
+
return res.status(404).json({ error: 'Image not found' });
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// Security: users can only access their own images
|
|
1811
|
+
if (image.user_uuid !== req.user.uuid) {
|
|
1812
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
const filePath = getImagePath(image.user_uuid, image.file_hash, image.file_ext);
|
|
1816
|
+
|
|
1817
|
+
// Check if file still exists on disk (may have been cleaned up)
|
|
1818
|
+
if (!fs.existsSync(filePath)) {
|
|
1819
|
+
return res.status(410).json({ error: 'Image expired' });
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
res.setHeader('Content-Type', image.mime_type);
|
|
1823
|
+
res.setHeader('Cache-Control', 'private, max-age=86400');
|
|
1824
|
+
res.sendFile(filePath);
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
console.error('Error serving image:', error);
|
|
1827
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1752
1831
|
// Image upload endpoint
|
|
1753
1832
|
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
|
|
1754
1833
|
try {
|
|
@@ -1800,6 +1879,11 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|
|
1800
1879
|
}
|
|
1801
1880
|
|
|
1802
1881
|
try {
|
|
1882
|
+
// Generate a batch ID for this upload group
|
|
1883
|
+
const { randomUUID } = await import('crypto');
|
|
1884
|
+
const uploadBatchId = randomUUID();
|
|
1885
|
+
const userUuid = req.user.uuid;
|
|
1886
|
+
|
|
1803
1887
|
// Process uploaded images
|
|
1804
1888
|
const processedImages = await Promise.all(
|
|
1805
1889
|
req.files.map(async (file) => {
|
|
@@ -1811,7 +1895,23 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|
|
1811
1895
|
// Clean up temp file immediately
|
|
1812
1896
|
await fs.unlink(file.path);
|
|
1813
1897
|
|
|
1898
|
+
// Persist image to file system (hash-deduplicated)
|
|
1899
|
+
const ext = file.originalname.split('.').pop()?.toLowerCase() || 'bin';
|
|
1900
|
+
const { fileHash } = await saveImage(userUuid, buffer, ext);
|
|
1901
|
+
|
|
1902
|
+
// Insert DB record (session_id=NULL, will be associated later)
|
|
1903
|
+
const imageId = imageDb.insertImage({
|
|
1904
|
+
upload_batch_id: uploadBatchId,
|
|
1905
|
+
user_uuid: userUuid,
|
|
1906
|
+
original_name: file.originalname,
|
|
1907
|
+
file_hash: fileHash,
|
|
1908
|
+
file_ext: ext,
|
|
1909
|
+
file_size: file.size,
|
|
1910
|
+
mime_type: mimeType,
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1814
1913
|
return {
|
|
1914
|
+
id: Number(imageId),
|
|
1815
1915
|
name: file.originalname,
|
|
1816
1916
|
data: `data:${mimeType};base64,${base64}`,
|
|
1817
1917
|
size: file.size,
|
|
@@ -1820,7 +1920,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|
|
1820
1920
|
})
|
|
1821
1921
|
);
|
|
1822
1922
|
|
|
1823
|
-
res.json({ images: processedImages });
|
|
1923
|
+
res.json({ images: processedImages, uploadBatchId });
|
|
1824
1924
|
} catch (error) {
|
|
1825
1925
|
console.error('Error processing images:', error);
|
|
1826
1926
|
// Clean up any remaining files
|
|
@@ -2251,6 +2351,9 @@ async function startServer() {
|
|
|
2251
2351
|
// Start usage scanner service
|
|
2252
2352
|
startUsageScanner();
|
|
2253
2353
|
|
|
2354
|
+
// Start image cleanup service (30-day expiry)
|
|
2355
|
+
startImageCleanup();
|
|
2356
|
+
|
|
2254
2357
|
// Start Feishu integration service (if configured)
|
|
2255
2358
|
if (process.env.FEISHU_APP_ID && process.env.FEISHU_APP_SECRET) {
|
|
2256
2359
|
startFeishuService().catch(err =>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* image-cleanup.js — 定时清理过期图片
|
|
3
|
+
*
|
|
4
|
+
* - 每小时运行一次
|
|
5
|
+
* - 删除 30 天以上的图片记录和文件
|
|
6
|
+
* - 删除 24 小时以上未关联 session 的孤儿图片
|
|
7
|
+
* - 删文件前检查同 hash 是否还有其他引用(去重安全)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { imageDb } from '../database/db.js';
|
|
11
|
+
import { deleteImageFile } from './image-storage.js';
|
|
12
|
+
|
|
13
|
+
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
14
|
+
const RETENTION_DAYS = 30;
|
|
15
|
+
const ORPHAN_HOURS = 24;
|
|
16
|
+
|
|
17
|
+
let cleanupInterval = null;
|
|
18
|
+
|
|
19
|
+
export function startImageCleanup() {
|
|
20
|
+
console.log('[ImageCleanup] Starting image cleanup service');
|
|
21
|
+
|
|
22
|
+
// Initial cleanup after 30s
|
|
23
|
+
setTimeout(() => runCleanup(), 30000);
|
|
24
|
+
|
|
25
|
+
// Schedule periodic cleanup
|
|
26
|
+
cleanupInterval = setInterval(() => runCleanup(), CLEANUP_INTERVAL_MS);
|
|
27
|
+
console.log(`[ImageCleanup] Scheduled to run every ${CLEANUP_INTERVAL_MS / 1000 / 60} minutes`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function stopImageCleanup() {
|
|
31
|
+
if (cleanupInterval) {
|
|
32
|
+
clearInterval(cleanupInterval);
|
|
33
|
+
cleanupInterval = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function runCleanup() {
|
|
38
|
+
try {
|
|
39
|
+
let deletedFiles = 0;
|
|
40
|
+
let deletedRecords = 0;
|
|
41
|
+
|
|
42
|
+
// 1. Clean expired images (>30 days)
|
|
43
|
+
const expiredImages = imageDb.getExpiredImages(RETENTION_DAYS);
|
|
44
|
+
if (expiredImages.length > 0) {
|
|
45
|
+
const expiredIds = expiredImages.map(img => img.id);
|
|
46
|
+
|
|
47
|
+
// Delete files only when no other records reference the same hash
|
|
48
|
+
for (const img of expiredImages) {
|
|
49
|
+
const otherRefs = imageDb.countByHash(img.user_uuid, img.file_hash, expiredIds);
|
|
50
|
+
if (otherRefs === 0) {
|
|
51
|
+
await deleteImageFile(img.user_uuid, img.file_hash, img.file_ext);
|
|
52
|
+
deletedFiles++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
imageDb.deleteImagesByIds(expiredIds);
|
|
57
|
+
deletedRecords += expiredIds.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Clean orphaned uploads (session_id IS NULL, >24h)
|
|
61
|
+
const orphaned = imageDb.deleteOrphanedImages(ORPHAN_HOURS);
|
|
62
|
+
if (orphaned.length > 0) {
|
|
63
|
+
for (const img of orphaned) {
|
|
64
|
+
const otherRefs = imageDb.countByHash(img.user_uuid, img.file_hash, [img.id]);
|
|
65
|
+
if (otherRefs === 0) {
|
|
66
|
+
await deleteImageFile(img.user_uuid, img.file_hash, img.file_ext);
|
|
67
|
+
deletedFiles++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
deletedRecords += orphaned.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (deletedRecords > 0) {
|
|
74
|
+
console.log(`[ImageCleanup] Cleaned up ${deletedRecords} records, ${deletedFiles} files`);
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error('[ImageCleanup] Error during cleanup:', error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* image-storage.js — 图片文件系统持久化存储
|
|
3
|
+
*
|
|
4
|
+
* 存储路径: data/images/{userUuid}/{hash前2字符}/{sha256hash}.{ext}
|
|
5
|
+
* 按内容 hash 去重:同一用户同内容图片只存一份文件。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import { promises as fs } from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
|
13
|
+
const IMAGES_DIR = path.join(DATA_DIR, 'images');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 计算 buffer 的 SHA-256 hex
|
|
17
|
+
*/
|
|
18
|
+
function computeHash(buffer) {
|
|
19
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 构建图片文件的绝对路径
|
|
24
|
+
*/
|
|
25
|
+
function buildImagePath(userUuid, fileHash, fileExt) {
|
|
26
|
+
const prefix = fileHash.substring(0, 2);
|
|
27
|
+
return path.join(IMAGES_DIR, userUuid, prefix, `${fileHash}.${fileExt}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 保存图片到文件系统(按内容 hash 去重)
|
|
32
|
+
* @param {string} userUuid
|
|
33
|
+
* @param {Buffer} buffer - 图片原始二进制
|
|
34
|
+
* @param {string} ext - 扩展名(不含点,如 'png')
|
|
35
|
+
* @returns {Promise<{fileHash: string, alreadyExisted: boolean}>}
|
|
36
|
+
*/
|
|
37
|
+
async function saveImage(userUuid, buffer, ext) {
|
|
38
|
+
const fileHash = computeHash(buffer);
|
|
39
|
+
const filePath = buildImagePath(userUuid, fileHash, ext);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await fs.access(filePath);
|
|
43
|
+
return { fileHash, alreadyExisted: true };
|
|
44
|
+
} catch {
|
|
45
|
+
// 文件不存在,写入
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
49
|
+
await fs.writeFile(filePath, buffer);
|
|
50
|
+
return { fileHash, alreadyExisted: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 获取图片文件的绝对路径
|
|
55
|
+
*/
|
|
56
|
+
function getImagePath(userUuid, fileHash, fileExt) {
|
|
57
|
+
return buildImagePath(userUuid, fileHash, fileExt);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 删除图片文件(忽略不存在的情况)
|
|
62
|
+
*/
|
|
63
|
+
async function deleteImageFile(userUuid, fileHash, fileExt) {
|
|
64
|
+
const filePath = buildImagePath(userUuid, fileHash, fileExt);
|
|
65
|
+
try {
|
|
66
|
+
await fs.unlink(filePath);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code !== 'ENOENT') throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
IMAGES_DIR,
|
|
74
|
+
computeHash,
|
|
75
|
+
saveImage,
|
|
76
|
+
getImagePath,
|
|
77
|
+
deleteImageFile,
|
|
78
|
+
};
|