imageStore.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { mkdir, open } from 'fs/promises'
  2. import { join } from 'path'
  3. import { getSessionId } from '../bootstrap/state.js'
  4. import type { PastedContent } from './config.js'
  5. import { logForDebugging } from './debug.js'
  6. import { getClaudeConfigHomeDir } from './envUtils.js'
  7. import { getFsImplementation } from './fsOperations.js'
  8. const IMAGE_STORE_DIR = 'image-cache'
  9. const MAX_STORED_IMAGE_PATHS = 200
  10. // In-memory cache of stored image paths
  11. const storedImagePaths = new Map<number, string>()
  12. /**
  13. * Get the image store directory for the current session.
  14. */
  15. function getImageStoreDir(): string {
  16. return join(getClaudeConfigHomeDir(), IMAGE_STORE_DIR, getSessionId())
  17. }
  18. /**
  19. * Ensure the image store directory exists.
  20. */
  21. async function ensureImageStoreDir(): Promise<void> {
  22. const dir = getImageStoreDir()
  23. await mkdir(dir, { recursive: true })
  24. }
  25. /**
  26. * Get the file path for an image by ID.
  27. */
  28. function getImagePath(imageId: number, mediaType: string): string {
  29. const extension = mediaType.split('/')[1] || 'png'
  30. return join(getImageStoreDir(), `${imageId}.${extension}`)
  31. }
  32. /**
  33. * Cache the image path immediately (fast, no file I/O).
  34. */
  35. export function cacheImagePath(content: PastedContent): string | null {
  36. if (content.type !== 'image') {
  37. return null
  38. }
  39. const imagePath = getImagePath(content.id, content.mediaType || 'image/png')
  40. evictOldestIfAtCap()
  41. storedImagePaths.set(content.id, imagePath)
  42. return imagePath
  43. }
  44. /**
  45. * Store an image from pastedContents to disk.
  46. */
  47. export async function storeImage(
  48. content: PastedContent,
  49. ): Promise<string | null> {
  50. if (content.type !== 'image') {
  51. return null
  52. }
  53. try {
  54. await ensureImageStoreDir()
  55. const imagePath = getImagePath(content.id, content.mediaType || 'image/png')
  56. const fh = await open(imagePath, 'w', 0o600)
  57. try {
  58. await fh.writeFile(content.content, { encoding: 'base64' })
  59. await fh.datasync()
  60. } finally {
  61. await fh.close()
  62. }
  63. evictOldestIfAtCap()
  64. storedImagePaths.set(content.id, imagePath)
  65. logForDebugging(`Stored image ${content.id} to ${imagePath}`)
  66. return imagePath
  67. } catch (error) {
  68. logForDebugging(`Failed to store image: ${error}`)
  69. return null
  70. }
  71. }
  72. /**
  73. * Store all images from pastedContents to disk.
  74. */
  75. export async function storeImages(
  76. pastedContents: Record<number, PastedContent>,
  77. ): Promise<Map<number, string>> {
  78. const pathMap = new Map<number, string>()
  79. for (const [id, content] of Object.entries(pastedContents)) {
  80. if (content.type === 'image') {
  81. const path = await storeImage(content)
  82. if (path) {
  83. pathMap.set(Number(id), path)
  84. }
  85. }
  86. }
  87. return pathMap
  88. }
  89. /**
  90. * Get the file path for a stored image by ID.
  91. */
  92. export function getStoredImagePath(imageId: number): string | null {
  93. return storedImagePaths.get(imageId) ?? null
  94. }
  95. /**
  96. * Clear the in-memory cache of stored image paths.
  97. */
  98. export function clearStoredImagePaths(): void {
  99. storedImagePaths.clear()
  100. }
  101. function evictOldestIfAtCap(): void {
  102. while (storedImagePaths.size >= MAX_STORED_IMAGE_PATHS) {
  103. const oldest = storedImagePaths.keys().next().value
  104. if (oldest !== undefined) {
  105. storedImagePaths.delete(oldest)
  106. } else {
  107. break
  108. }
  109. }
  110. }
  111. /**
  112. * Clean up old image cache directories from previous sessions.
  113. */
  114. export async function cleanupOldImageCaches(): Promise<void> {
  115. const fsImpl = getFsImplementation()
  116. const baseDir = join(getClaudeConfigHomeDir(), IMAGE_STORE_DIR)
  117. const currentSessionId = getSessionId()
  118. try {
  119. let sessionDirs
  120. try {
  121. sessionDirs = await fsImpl.readdir(baseDir)
  122. } catch {
  123. return
  124. }
  125. for (const sessionDir of sessionDirs) {
  126. if (sessionDir.name === currentSessionId) {
  127. continue
  128. }
  129. const sessionPath = join(baseDir, sessionDir.name)
  130. try {
  131. await fsImpl.rm(sessionPath, { recursive: true, force: true })
  132. logForDebugging(`Cleaned up old image cache: ${sessionPath}`)
  133. } catch {
  134. // Ignore errors for individual directories
  135. }
  136. }
  137. try {
  138. const remaining = await fsImpl.readdir(baseDir)
  139. if (remaining.length === 0) {
  140. await fsImpl.rmdir(baseDir)
  141. }
  142. } catch {
  143. // Ignore
  144. }
  145. } catch {
  146. // Ignore errors reading base directory
  147. }
  148. }