| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199 |
- 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<string>()
- 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<StoredWorkspaceFile>(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<void> {
- if (!message.agentId || message.role !== 'assistant' || message.visible === false) return
- const agent = this.store.require<WorkspaceAgent>(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<void> {
- if (this.stopped) return
- const message=this.store.get<WorkspaceMessage>(owner,'message',messageId)
- if(message?.execution==='computer'||(!message?.execution&&message?.agentId&&this.store.get<WorkspaceAgent>(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
- }
- }
|