import { randomUUID, createHash } from 'node:crypto' import { mkdirSync, writeFileSync } from 'node:fs' import { join, basename } from 'node:path' import {HttpError} from './errors.js' import { lookup } from 'mime-types' import { messageFileReferences, type MessageFileOrigin } from '../shared/messageFiles.js' import { WorkspaceStore } from './workspaceStore.js' import { WorkspaceNodes } from './workspaceGateway.js' import type { WorkspaceAgent, WorkspaceFile, WorkspaceMessage } from '../shared/workspace.js' export interface StoredWorkspaceFile extends WorkspaceFile, MessageFileOrigin { path: string digest?: string sourceNodeId?:string } export function publicFile(f: StoredWorkspaceFile): WorkspaceFile { const { path: _path, digest: _digest, messageFileSource: _source, ...result } = f return result } export function libraryFile( f: WorkspaceFile, id: number, conversation?: { id: string; name: string }, ) { const path = `/api/app/files/${f.id}/download` const origin = { id, sourceKind: f.sender === 'user' ? 'upload' : 'agent_generated', eventKind: f.sender === 'user' ? 'uploaded' : 'generated', username: '', profile: f.profile ?? '', agent: f.profile ?? '', contextType: 'session', sessionId: f.conversationId ?? '', sessionTitle: conversation?.name ?? '', workspaceConversationId: conversation?.id, messageId: f.messageId ?? '', runId: '', workspace: '', originalPath: path, referencePath: path, observedAt: f.createdAt, messageTimestamp: f.createdAt, authorKind: f.sender, } return { ...f, id, itemId: id, path, extension: f.name.split('.').at(-1) ?? '', modifiedAt: f.createdAt, exists: true, firstSeenAt: f.createdAt, lastSeenAt: f.createdAt, messageTimestamp: f.createdAt, origins: [origin], archiveStatus: 'ready', availability: 'archived', archivedAt: f.createdAt, messageId: f.messageId ?? null, sessionId: f.conversationId ?? null, displayName: f.name, contentType: f.mimeType, byteCount: f.size, sizeBytes: f.size, kind: f.mimeType.startsWith('image/') ? 'image' : f.mimeType.startsWith('video/') ? 'video' : f.mimeType.startsWith('text/') ? 'text' : 'file', createdAt: new Date(f.createdAt).toISOString(), downloadUrl: path, previewUrl: `/api/app/files/${f.id}/preview`, } } export class WorkspaceAssets { private stopped = false private pending = new Set() constructor( readonly store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly home: string, ) {} publish(owner:string,message:WorkspaceMessage,agent:WorkspaceAgent,name:string,bytes:Buffer):WorkspaceFile { const digest=createHash('sha256').update(bytes).digest('hex') name=basename(name).replace(/[\\/\u0000-\u001f\u007f]/g,'_').slice(0,240)||'artifact' const files=this.store.list(owner,'file') const existing=files.find(file=>file.messageId===message.id&&file.name===name&&file.digest===digest) if(existing)return publicFile(existing) if(bytes.length>25*1024*1024||files.filter(file=>file.messageId===message.id).length>=8)throw new HttpError(413,'每轮最多 8 个产物,每个不超过 25 MiB','artifact_limit') 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) if(used+bytes.length>2*1024*1024*1024)throw new HttpError(413,'产物存储空间已达上限','artifact_quota') const dir=join(this.home,'workspace-files',owner);mkdirSync(dir,{recursive:true,mode:0o700}) const path=join(dir,digest) try{writeFileSync(path,bytes,{mode:0o600,flag:'wx'})}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error} 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()} this.store.atomic(()=>{this.store.put(owner,'file',file.id,file);this.store.event(owner,'files.changed',publicFile(file),message.conversationId)}) return publicFile(file) } async archive(owner: string, message: WorkspaceMessage): Promise { if (!message.agentId || message.role !== 'assistant' || message.visible === false) return const agent = this.store.require(owner, 'agent', message.agentId) await this.archiveText( owner, message.content, agent.nodeId, agent.profile, message.conversationId, message.id, 'agent', agent.remoteAgentId, ) if (this.stopped) return const attachments = this.store .visibleFiles(owner) .filter((f) => f.messageId === message.id) .map(publicFile) if (attachments.length || message.attachments.length) { message.attachments = attachments this.store.saveMessage(owner, message) } } async archiveText( owner: string, text: string, nodeId: string, profile: string, conversationId: string, messageId: string, sender: 'user' | 'agent' = 'agent', remoteAgentId?: string, ): Promise { if (this.stopped) return const message=this.store.get(owner,'message',messageId) if(message?.execution==='computer'||(!message?.execution&&message?.agentId&&this.store.get(owner,'agent',message.agentId)?.execution==='computer'))return const paths = new Set([...messageFileReferences(text)].filter(path => /^(?:\/|~\/)/.test(path))) // User attachment staging is explicit upload metadata, not a tool result. if (sender === 'user') { for (const match of text.matchAll(/"(?:path|file_path|output_path)"\s*:\s*"((?:\/|~\/)[^"\n<>]+)"/g)) { paths.add(match[1]!) } } for (const path of [...paths].slice(0, 8)) { const key = createHash('sha256') .update(JSON.stringify([nodeId, profile, messageId, path])) .digest('hex') if (this.store.get(owner, 'archived-path', key) || this.pending.has(`${owner}:${key}`)) continue this.pending.add(`${owner}:${key}`) try { const response = await (remoteAgentId ? this.nodes.targetForAgent(owner,{nodeId,remoteAgentId}) : this.nodes.target(owner,nodeId)) .session.request('/api/files/download', { search: new URLSearchParams({ profile, path }), maxResponseBytes: 25 * 1024 * 1024, }) if (response.status !== 200 || this.stopped) continue const dir = join(this.home, 'workspace-files', owner) mkdirSync(dir, { recursive: true, mode: 0o700 }) const digest = createHash('sha256').update(response.body).digest('hex') const id = randomUUID(), saved = join(dir, digest), name = basename(path) try { writeFileSync(saved, response.body, { mode: 0o600, flag: 'wx' }) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error } const file: StoredWorkspaceFile = { id, sourcePath: path, messageFileSource: 'reference', path: saved, digest, name, mimeType: String( response.headers.get('content-type') || lookup(name) || 'application/octet-stream', ).split(';')[0]!, size: response.body.length, sender, profile, conversationId, messageId, createdAt: Date.now(), } this.store.atomic(() => { this.store.put(owner, 'file', id, file) this.store.put(owner, 'archived-path', key, true) this.store.event(owner, 'files.changed', publicFile(file), conversationId) }) } catch { /* A missing upstream file does not fail an otherwise completed reply. */ } finally { this.pending.delete(`${owner}:${key}`) } } } close(): void { this.stopped = true } }