@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/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
+ };