workspaceAssets.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import { randomUUID, createHash } from 'node:crypto'
  2. import { mkdirSync, writeFileSync } from 'node:fs'
  3. import { join, basename } from 'node:path'
  4. import {HttpError} from './errors.js'
  5. import { lookup } from 'mime-types'
  6. import { messageFileReferences, type MessageFileOrigin } from '../shared/messageFiles.js'
  7. import { WorkspaceStore } from './workspaceStore.js'
  8. import { WorkspaceNodes } from './workspaceGateway.js'
  9. import type { WorkspaceAgent, WorkspaceFile, WorkspaceMessage } from '../shared/workspace.js'
  10. export interface StoredWorkspaceFile extends WorkspaceFile, MessageFileOrigin {
  11. path: string
  12. digest?: string
  13. sourceNodeId?:string
  14. }
  15. export function publicFile(f: StoredWorkspaceFile): WorkspaceFile {
  16. const { path: _path, digest: _digest, messageFileSource: _source, ...result } = f
  17. return result
  18. }
  19. export function libraryFile(
  20. f: WorkspaceFile,
  21. id: number,
  22. conversation?: { id: string; name: string },
  23. ) {
  24. const path = `/api/app/files/${f.id}/download`
  25. const origin = {
  26. id,
  27. sourceKind: f.sender === 'user' ? 'upload' : 'agent_generated',
  28. eventKind: f.sender === 'user' ? 'uploaded' : 'generated',
  29. username: '',
  30. profile: f.profile ?? '',
  31. agent: f.profile ?? '',
  32. contextType: 'session',
  33. sessionId: f.conversationId ?? '',
  34. sessionTitle: conversation?.name ?? '',
  35. workspaceConversationId: conversation?.id,
  36. messageId: f.messageId ?? '',
  37. runId: '',
  38. workspace: '',
  39. originalPath: path,
  40. referencePath: path,
  41. observedAt: f.createdAt,
  42. messageTimestamp: f.createdAt,
  43. authorKind: f.sender,
  44. }
  45. return {
  46. ...f,
  47. id,
  48. itemId: id,
  49. path,
  50. extension: f.name.split('.').at(-1) ?? '',
  51. modifiedAt: f.createdAt,
  52. exists: true,
  53. firstSeenAt: f.createdAt,
  54. lastSeenAt: f.createdAt,
  55. messageTimestamp: f.createdAt,
  56. origins: [origin],
  57. archiveStatus: 'ready',
  58. availability: 'archived',
  59. archivedAt: f.createdAt,
  60. messageId: f.messageId ?? null,
  61. sessionId: f.conversationId ?? null,
  62. displayName: f.name,
  63. contentType: f.mimeType,
  64. byteCount: f.size,
  65. sizeBytes: f.size,
  66. kind: f.mimeType.startsWith('image/')
  67. ? 'image'
  68. : f.mimeType.startsWith('video/')
  69. ? 'video'
  70. : f.mimeType.startsWith('text/')
  71. ? 'text'
  72. : 'file',
  73. createdAt: new Date(f.createdAt).toISOString(),
  74. downloadUrl: path,
  75. previewUrl: `/api/app/files/${f.id}/preview`,
  76. }
  77. }
  78. export class WorkspaceAssets {
  79. private stopped = false
  80. private pending = new Set<string>()
  81. constructor(
  82. readonly store: WorkspaceStore,
  83. readonly nodes: WorkspaceNodes,
  84. readonly home: string,
  85. ) {}
  86. publish(owner:string,message:WorkspaceMessage,agent:WorkspaceAgent,name:string,bytes:Buffer):WorkspaceFile {
  87. const digest=createHash('sha256').update(bytes).digest('hex')
  88. name=basename(name).replace(/[\\/\u0000-\u001f\u007f]/g,'_').slice(0,240)||'artifact'
  89. const files=this.store.list<StoredWorkspaceFile>(owner,'file')
  90. const existing=files.find(file=>file.messageId===message.id&&file.name===name&&file.digest===digest)
  91. if(existing)return publicFile(existing)
  92. if(bytes.length>25*1024*1024||files.filter(file=>file.messageId===message.id).length>=8)throw new HttpError(413,'每轮最多 8 个产物,每个不超过 25 MiB','artifact_limit')
  93. const used=Number(this.store.db.prepare("SELECT coalesce(sum(json_extract(data,'$.size')),0) AS n FROM workspace_entities WHERE kind='file'").get()?.n??0)
  94. if(used+bytes.length>2*1024*1024*1024)throw new HttpError(413,'产物存储空间已达上限','artifact_quota')
  95. const dir=join(this.home,'workspace-files',owner);mkdirSync(dir,{recursive:true,mode:0o700})
  96. const path=join(dir,digest)
  97. try{writeFileSync(path,bytes,{mode:0o600,flag:'wx'})}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error}
  98. const file:StoredWorkspaceFile={id:randomUUID(),name,mimeType:lookup(name)||'application/octet-stream',path,digest,size:bytes.length,sender:'agent',messageFileSource:'attachment',profile:agent.profile,sourceNodeId:agent.nodeId,conversationId:message.conversationId,messageId:message.id,createdAt:Date.now()}
  99. this.store.atomic(()=>{this.store.put(owner,'file',file.id,file);this.store.event(owner,'files.changed',publicFile(file),message.conversationId)})
  100. return publicFile(file)
  101. }
  102. async archive(owner: string, message: WorkspaceMessage): Promise<void> {
  103. if (!message.agentId || message.role !== 'assistant' || message.visible === false) return
  104. const agent = this.store.require<WorkspaceAgent>(owner, 'agent', message.agentId)
  105. await this.archiveText(
  106. owner,
  107. message.content,
  108. agent.nodeId,
  109. agent.profile,
  110. message.conversationId,
  111. message.id,
  112. 'agent',
  113. agent.remoteAgentId,
  114. )
  115. if (this.stopped) return
  116. const attachments = this.store
  117. .visibleFiles(owner)
  118. .filter((f) => f.messageId === message.id)
  119. .map(publicFile)
  120. if (attachments.length || message.attachments.length) {
  121. message.attachments = attachments
  122. this.store.saveMessage(owner, message)
  123. }
  124. }
  125. async archiveText(
  126. owner: string,
  127. text: string,
  128. nodeId: string,
  129. profile: string,
  130. conversationId: string,
  131. messageId: string,
  132. sender: 'user' | 'agent' = 'agent',
  133. remoteAgentId?: string,
  134. ): Promise<void> {
  135. if (this.stopped) return
  136. const message=this.store.get<WorkspaceMessage>(owner,'message',messageId)
  137. if(message?.execution==='computer'||(!message?.execution&&message?.agentId&&this.store.get<WorkspaceAgent>(owner,'agent',message.agentId)?.execution==='computer'))return
  138. const paths = new Set([...messageFileReferences(text)].filter(path => /^(?:\/|~\/)/.test(path)))
  139. // User attachment staging is explicit upload metadata, not a tool result.
  140. if (sender === 'user') {
  141. for (const match of text.matchAll(/"(?:path|file_path|output_path)"\s*:\s*"((?:\/|~\/)[^"\n<>]+)"/g)) {
  142. paths.add(match[1]!)
  143. }
  144. }
  145. for (const path of [...paths].slice(0, 8)) {
  146. const key = createHash('sha256')
  147. .update(JSON.stringify([nodeId, profile, messageId, path]))
  148. .digest('hex')
  149. if (this.store.get(owner, 'archived-path', key) || this.pending.has(`${owner}:${key}`))
  150. continue
  151. this.pending.add(`${owner}:${key}`)
  152. try {
  153. const response = await (remoteAgentId ? this.nodes.targetForAgent(owner,{nodeId,remoteAgentId}) : this.nodes.target(owner,nodeId))
  154. .session.request('/api/files/download', {
  155. search: new URLSearchParams({ profile, path }),
  156. maxResponseBytes: 25 * 1024 * 1024,
  157. })
  158. if (response.status !== 200 || this.stopped) continue
  159. const dir = join(this.home, 'workspace-files', owner)
  160. mkdirSync(dir, { recursive: true, mode: 0o700 })
  161. const digest = createHash('sha256').update(response.body).digest('hex')
  162. const id = randomUUID(),
  163. saved = join(dir, digest),
  164. name = basename(path)
  165. try { writeFileSync(saved, response.body, { mode: 0o600, flag: 'wx' }) }
  166. catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error }
  167. const file: StoredWorkspaceFile = {
  168. id,
  169. sourcePath: path,
  170. messageFileSource: 'reference',
  171. path: saved,
  172. digest,
  173. name,
  174. mimeType: String(
  175. response.headers.get('content-type') || lookup(name) || 'application/octet-stream',
  176. ).split(';')[0]!,
  177. size: response.body.length,
  178. sender,
  179. profile,
  180. conversationId,
  181. messageId,
  182. createdAt: Date.now(),
  183. }
  184. this.store.atomic(() => {
  185. this.store.put(owner, 'file', id, file)
  186. this.store.put(owner, 'archived-path', key, true)
  187. this.store.event(owner, 'files.changed', publicFile(file), conversationId)
  188. })
  189. } catch {
  190. /* A missing upstream file does not fail an otherwise completed reply. */
  191. } finally {
  192. this.pending.delete(`${owner}:${key}`)
  193. }
  194. }
  195. }
  196. close(): void {
  197. this.stopped = true
  198. }
  199. }