workspaceAssets.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { randomUUID, createHash } from 'node:crypto'
  2. import { mkdirSync, writeFileSync } from 'node:fs'
  3. import { join, basename } from 'node:path'
  4. import { lookup } from 'mime-types'
  5. import { WorkspaceStore } from './workspaceStore.js'
  6. import { WorkspaceNodes } from './workspaceGateway.js'
  7. import type { WorkspaceAgent, WorkspaceFile, WorkspaceMessage } from '../shared/workspace.js'
  8. export interface StoredWorkspaceFile extends WorkspaceFile {
  9. path: string
  10. digest?: string
  11. }
  12. export function publicFile(f: StoredWorkspaceFile): WorkspaceFile {
  13. const { path: _path, digest: _digest, ...result } = f
  14. return result
  15. }
  16. export function libraryFile(
  17. f: WorkspaceFile,
  18. id: number,
  19. conversation?: { id: string; name: string },
  20. ) {
  21. const path = `/api/app/files/${f.id}/download`
  22. const origin = {
  23. id,
  24. sourceKind: f.sender === 'user' ? 'upload' : 'agent_generated',
  25. eventKind: f.sender === 'user' ? 'uploaded' : 'generated',
  26. username: '',
  27. profile: f.profile ?? '',
  28. agent: f.profile ?? '',
  29. contextType: 'session',
  30. sessionId: f.conversationId ?? '',
  31. sessionTitle: conversation?.name ?? '',
  32. workspaceConversationId: conversation?.id,
  33. messageId: f.messageId ?? '',
  34. runId: '',
  35. workspace: '',
  36. originalPath: path,
  37. referencePath: path,
  38. observedAt: f.createdAt,
  39. messageTimestamp: f.createdAt,
  40. authorKind: f.sender,
  41. }
  42. return {
  43. ...f,
  44. id,
  45. itemId: id,
  46. path,
  47. extension: f.name.split('.').at(-1) ?? '',
  48. modifiedAt: f.createdAt,
  49. exists: true,
  50. firstSeenAt: f.createdAt,
  51. lastSeenAt: f.createdAt,
  52. messageTimestamp: f.createdAt,
  53. origins: [origin],
  54. archiveStatus: 'ready',
  55. availability: 'archived',
  56. archivedAt: f.createdAt,
  57. messageId: f.messageId ?? null,
  58. sessionId: f.conversationId ?? null,
  59. displayName: f.name,
  60. contentType: f.mimeType,
  61. byteCount: f.size,
  62. sizeBytes: f.size,
  63. kind: f.mimeType.startsWith('image/')
  64. ? 'image'
  65. : f.mimeType.startsWith('video/')
  66. ? 'video'
  67. : f.mimeType.startsWith('text/')
  68. ? 'text'
  69. : 'file',
  70. createdAt: new Date(f.createdAt).toISOString(),
  71. downloadUrl: path,
  72. previewUrl: `/api/app/files/${f.id}/preview`,
  73. }
  74. }
  75. export class WorkspaceAssets {
  76. private stopped = false
  77. private pending = new Set<string>()
  78. constructor(
  79. readonly store: WorkspaceStore,
  80. readonly nodes: WorkspaceNodes,
  81. readonly home: string,
  82. ) {}
  83. async archive(owner: string, message: WorkspaceMessage): Promise<void> {
  84. if (!message.agentId) return
  85. const agent = this.store.require<WorkspaceAgent>(owner, 'agent', message.agentId)
  86. await this.archiveText(
  87. owner,
  88. message.content + '\n' + JSON.stringify(message.tools),
  89. agent.nodeId,
  90. agent.profile,
  91. message.conversationId,
  92. message.id,
  93. )
  94. if (this.stopped) return
  95. const attachments = this.store
  96. .list<StoredWorkspaceFile>(owner, 'file')
  97. .filter((f) => f.messageId === message.id)
  98. .map(publicFile)
  99. if (attachments.length) {
  100. message.attachments = attachments
  101. this.store.saveMessage(owner, message)
  102. }
  103. }
  104. async archiveText(
  105. owner: string,
  106. text: string,
  107. nodeId: string,
  108. profile: string,
  109. conversationId: string,
  110. messageId: string,
  111. sender: 'user' | 'agent' = 'agent',
  112. ): Promise<void> {
  113. if (this.stopped) return
  114. const paths = new Set<string>()
  115. for (const m of text.matchAll(
  116. /(?:MEDIA:\s*|\]\(<?|"(?:path|file_path|output_path)"\s*:\s*")((?:\/|~\/)[^\n"<>)]{1,4096})/g,
  117. ))
  118. paths.add(m[1]!.replace(/\\n.*$/, '').trim())
  119. for (const path of [...paths].slice(0, 8)) {
  120. const key = createHash('sha256')
  121. .update(JSON.stringify([nodeId, profile, messageId, path]))
  122. .digest('hex')
  123. if (this.store.get(owner, 'archived-path', key) || this.pending.has(`${owner}:${key}`))
  124. continue
  125. this.pending.add(`${owner}:${key}`)
  126. try {
  127. const response = await this.nodes
  128. .target(owner, nodeId)
  129. .session.request('/api/files/download', {
  130. search: new URLSearchParams({ profile, path }),
  131. maxResponseBytes: 25 * 1024 * 1024,
  132. })
  133. if (response.status !== 200 || this.stopped) continue
  134. const dir = join(this.home, 'workspace-files', owner)
  135. mkdirSync(dir, { recursive: true, mode: 0o700 })
  136. const digest = createHash('sha256').update(response.body).digest('hex')
  137. const id = randomUUID(),
  138. saved = join(dir, digest),
  139. name = basename(path)
  140. try { writeFileSync(saved, response.body, { mode: 0o600, flag: 'wx' }) }
  141. catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error }
  142. const file: StoredWorkspaceFile = {
  143. id,
  144. sourcePath: path,
  145. path: saved,
  146. digest,
  147. name,
  148. mimeType: String(
  149. response.headers.get('content-type') || lookup(name) || 'application/octet-stream',
  150. ).split(';')[0]!,
  151. size: response.body.length,
  152. sender,
  153. profile,
  154. conversationId,
  155. messageId,
  156. createdAt: Date.now(),
  157. }
  158. this.store.atomic(() => {
  159. this.store.put(owner, 'file', id, file)
  160. this.store.put(owner, 'archived-path', key, true)
  161. this.store.event(owner, 'files.changed', publicFile(file), conversationId)
  162. })
  163. } catch {
  164. /* A missing upstream file does not fail an otherwise completed reply. */
  165. } finally {
  166. this.pending.delete(`${owner}:${key}`)
  167. }
  168. }
  169. }
  170. close(): void {
  171. this.stopped = true
  172. }
  173. }