diff --git a/inc/js/agents/system/asset-assistant.mjs b/inc/js/agents/system/asset-assistant.mjs index d07e664..d0f66ac 100644 --- a/inc/js/agents/system/asset-assistant.mjs +++ b/inc/js/agents/system/asset-assistant.mjs @@ -4,63 +4,104 @@ import mime from 'mime-types' import FormData from 'form-data' import axios from 'axios' // module constants +// module constants const { MYLIFE_EMBEDDING_SERVER_BEARER_TOKEN, MYLIFE_EMBEDDING_SERVER_FILESIZE_LIMIT, MYLIFE_EMBEDDING_SERVER_FILESIZE_LIMIT_ADMIN, MYLIFE_SERVER_MBR_ID: mylifeMbrId, } = process.env const bearerToken = MYLIFE_EMBEDDING_SERVER_BEARER_TOKEN const fileSizeLimit = parseInt(MYLIFE_EMBEDDING_SERVER_FILESIZE_LIMIT) || 1048576 const fileSizeLimitAdmin = parseInt(MYLIFE_EMBEDDING_SERVER_FILESIZE_LIMIT_ADMIN) || 10485760 -// module variables -let AgentFactory -let Globals // module class definition class oAIAssetAssistant { - // pseudo-constructor - #ctx // @todo: only useful if assistant only exists for duration of request - #file - #mbr_id - constructor(_ctx){ - // primary direct assignment - this.#ctx = _ctx - this.#mbr_id = this.#ctx.state.member.mbr_id - // module direct assignment - if(!AgentFactory) AgentFactory = this.#ctx.AgentFactory - if(!Globals) Globals = this.#ctx.Globals - // secondary direct assignment - this.#file = this.#extractFile(this.#ctx) - // validate asset construction - this.#validateFile() + #factory + #uploadedFileList=[] // uploaded versions + #globals + #includeMyLife=false + #llm + #response + #vectorstoreId + #vectorstoreFileList // openai vectorstore versions + constructor(factory, globals, llm){ + this.#factory = factory + this.#globals = globals + this.#llm = llm + this.#vectorstoreId = this.#factory.vectorstoreId } - async init(){ - await this.#embedFile() // critical step - await this.#enactFile() // critical step + /** + * Initializes the asset assistant by uploading the files to the vectorstore and optionally embedding and enacting the files. + * @param {string} vectorstoreId - The vectorstore id to upload the files into, if already exists (avatar would know). + * @param {boolean} includeMyLife - Whether to embed and enact the files. + * @returns {Promise} - The initialized asset assistant instance. + */ + async init(includeMyLife=false){ + await this.updateVectorstoreFileList() // sets `this.#vectorstoreFileList` + this.#includeMyLife = includeMyLife return this } - // getters - get ctx(){ - return this.#ctx + async updateVectorstoreFileList(){ + if(this.#vectorstoreId?.length){ + const updateList = (await this.#llm.files(this.#vectorstoreId)).data + .filter(file=>!(this.#vectorstoreFileList ?? []).find(vsFile=>vsFile.id===file.id)) + if(updateList?.length){ + this.#vectorstoreFileList = await Promise.all( + updateList.map(async file =>await this.#llm.file(file.id)) + ) + } + } } - get file(){ - return this.#file + async upload(files){ + if(!files || !files.length) + throw new Error('No files found in request.') + const newFiles = [] + files.forEach(file => { + const hasFile = this.#uploadedFileList.some(_file=>_file.originalName===file.originalName) + if(!hasFile) + newFiles.push(this.#extractFile(file)) + }) + if(newFiles.length){ // only upload new files + const vectorstoreId = this.#vectorstoreId + this.#uploadedFileList.push(...newFiles) + const fileStreams = newFiles.map(file=>fs.createReadStream(file.filepath)) + const dataRecord = await this.#llm.upload(vectorstoreId, fileStreams, this.mbr_id) + const { response, vectorstoreId: newVectorstoreId, success } = dataRecord + this.#response = response + this.#vectorstoreId = newVectorstoreId + if(!vectorstoreId && newVectorstoreId) + this.#factory.vectorstoreId = newVectorstoreId // saves to datacore + if(success && this.#vectorstoreId?.length) + await this.updateVectorstoreFileList() + } + files.forEach(file=>fs.unlinkSync(file.filepath)) /* delete .tmp files */ + } + // getters + get files(){ + return this.vectorstoreFileList } get mbr_id(){ - return this.#mbr_id + return this.#factory.mbr_id } - get session(){ - return this.#ctx.session?.MemberSession??null + get response(){ + return this.#response + } + get vectorstoreFileList(){ + return this.#vectorstoreFileList + } + get vectorstoreId(){ + return this.#vectorstoreId } // setters // private functions async #embedFile(){ - console.log(this.file) + const file = this.#uploadedFileList[0] + console.log(file) console.log('#embedFile() begin') const _metadata = { - source: 'corporate', // logickify this - source_id: this.file.originalFilename, + source: 'corporate', // logickify + source_id: file.originalFilename, url: 'testing-0001', // may or may not use url author: 'MAHT', // convert to session member (or agent) } const _token = bearerToken const _data = new FormData() - _data.append('file', fs.createReadStream(this.file.filepath), { contentType: this.file.mimetype }) + _data.append('file', fs.createReadStream(file.filepath), { contentType: file.mimetype }) _data.append('metadata', JSON.stringify(_metadata)) const _request = { method: 'post', @@ -77,30 +118,56 @@ class oAIAssetAssistant { console.log(`#embedFile() finished: ${response.data.ids}`) return response.data }) + .then(response=>{ + if(this.#includeMyLife) + return this.#enactFile(response.data) + return response.data + }) .catch((error) => { console.error(error.message) return {'#embedFile() finished error': error.message } }) } - async #enactFile(){ // vitalizes by saving to MyLife database + async #enactFile(file){ // vitalizes by saving to MyLife database console.log('#enactFile() begin') const _fileContent = { - ...this.file, + ...file, ...{ mbr_id: this.mbr_id } } const oFile = new (AgentFactory.file)(_fileContent) console.log('testing factory',oFile.inspect(true)) return oFile } - #extractFile(){ - if(!this.#ctx.request?.files?.file??false) throw new Error('No file found in request.') - const { lastModifiedDate, filepath, newFilename, originalFilename, mimetype, size } = this.#ctx.request.files.file + /** + * Takes an uploaded file object and extracts relevant file properties. + * @param {File} file - File object + * @returns {object} - Extracted file object. + */ + #extractFile(file){ + if(!file) + throw new Error('No file found in request.') + const { lastModifiedDate, filepath, newFilename, originalFilename, mimetype, size } = file + this.#validateFile(file) return { - ...{ lastModifiedDate, filepath, newFilename, originalFilename, mimetype, size, localFilename: `${Globals.newGuid}.${mime.extension(mimetype)}` }, - ...this.#ctx.request.body + ...{ lastModifiedDate, filepath, newFilename, originalFilename, mimetype, size, localFilename: `${this.#globals.newGuid}.${mime.extension(mimetype)}` }, } } - #validateFile(){ + /** + * Takes an array of uploaded file objects and extracts relevant file properties. + * @param {File[]} files - Array of file objects. + * @returns {File[]} - Array of extracted file objects. + */ + #extractFiles(files){ + if(!Array.isArray(files) || !files.length) + throw new Error('No files found in request.') + return files.map(file=>this.#extractFile(file)) + } + /** + * Validates a file object, _throws error_ if file is invalid. + * @param {File} file - File object + * @returns {void} + */ + #validateFile(file){ const allowedMimeTypes = [ 'application/json', 'application/msword', @@ -114,14 +181,14 @@ class oAIAssetAssistant { 'text/markdown', 'text/plain', ] - // reject size - let _mbr_id = this.#ctx.state.member.mbr_id - let _maxFileSize = _mbr_id === mylifeMbrId + const { size, mimetype } = file + const maxFileSize = this.mbr_id === mylifeMbrId ? fileSizeLimitAdmin : fileSizeLimit - if (this.#file.size > _maxFileSize) throw new Error(`File size too large: ${this.#file.size}. Maximum file size is 1MB.`) - // reject mime-type - if (!allowedMimeTypes.includes(this.#file.mimetype)) throw new Error(`Unsupported media type: ${this.#file.mimetype}. File type not allowed.`) + if((size ?? 0) > maxFileSize) + throw new Error(`File size too large: ${ size }. Maximum file size is 1MB.`) + if(!allowedMimeTypes.includes(mimetype)) + throw new Error(`Unsupported media type: ${ mimetype }. File type not allowed.`) } } // exports diff --git a/inc/js/api-functions.mjs b/inc/js/api-functions.mjs index 2b505da..c2bc950 100644 --- a/inc/js/api-functions.mjs +++ b/inc/js/api-functions.mjs @@ -287,6 +287,21 @@ async function tokenValidation(ctx, next) { return } } +async function upload(ctx){ + const { body, files: filesWrapper, } = ctx.request + const { type } = body + let files = filesWrapper['files[]'] // protocol for multiple files in `ctx.request` + if(!files) + ctx.throw(400, 'No files uploaded.') + if(!Array.isArray(files)) + files = [files] + await mAPIKeyValidation(ctx) + const { avatar, } = ctx.state + const upload = await avatar.upload(files) + upload.type = type + upload.message = `File(s) [type=${ type }] attempted upload, see "success".`, + ctx.body = upload +} /* "private" module functions */ /** * Validates key and sets `ctx.state` and `ctx.session` properties. `ctx.state`: [ assistantType, isValidated, mbr_id, ]. `ctx.session`: [ isAPIValidated, APIMemberKey, ]. @@ -353,4 +368,5 @@ export { story, storyLibrary, tokenValidation, + upload, } \ No newline at end of file diff --git a/inc/js/functions.mjs b/inc/js/functions.mjs index 92cb745..853e81e 100644 --- a/inc/js/functions.mjs +++ b/inc/js/functions.mjs @@ -1,5 +1,8 @@ /* imports */ import oAIAssetAssistant from './agents/system/asset-assistant.mjs' +import { + upload as apiUpload, +} from './api-functions.mjs' /* module export functions */ async function about(ctx){ ctx.state.title = `About MyLife` @@ -79,7 +82,9 @@ async function chat(ctx){ ctx.body = response } async function collections(ctx){ - ctx.body = await ctx.state.avatar.collections(ctx.params.type) + const { avatar, } = ctx.state + const { type, } = ctx.params + ctx.body = await avatar.collections(type) } /** * Manage delivery and receipt of contributions(s). @@ -247,16 +252,18 @@ async function signup(ctx) { message: 'Signup successful', } } -async function _upload(ctx){ // post file via post - // revive or create nascent AI-Asset Assistant, that will be used to process the file from validation => storage - // ultimately, this may want to move up in the chain, but perhaps not, considering the need to process binary file content - const _oAIAssetAssistant = await new oAIAssetAssistant(ctx).init() - ctx.body = _oAIAssetAssistant -} -async function upload(ctx){ // upload display widget/for list and/or action(s) - ctx.state.title = `Upload` - ctx.state.subtitle = `Upload your files to MyLife` - await ctx.render('upload') // upload +/** + * Proxy for uploading files to the API. + * @param {Koa} ctx - Koa Context object + * @returns {object} - The result of the upload as `ctx.body`. + */ +async function upload(ctx){ + const { avatar, } = ctx.state + if(avatar.isMyLife) + throw new Error('Only logged in members may upload files') + ctx.session.APIMemberKey = avatar.mbr_id + ctx.session.isAPIValidated = true + await apiUpload(ctx) } /* module private functions */ function mGetContributions(ctx){ @@ -315,5 +322,4 @@ export { privacyPolicy, signup, upload, - _upload, } \ No newline at end of file diff --git a/inc/js/mylife-agent-factory.mjs b/inc/js/mylife-agent-factory.mjs index c310be6..1b5e7ee 100644 --- a/inc/js/mylife-agent-factory.mjs +++ b/inc/js/mylife-agent-factory.mjs @@ -666,6 +666,16 @@ class AgentFactory extends BotFactory{ get urlEmbeddingServer(){ return process.env.MYLIFE_EMBEDDING_SERVER_URL+':'+process.env.MYLIFE_EMBEDDING_SERVER_PORT } + get vectorstoreId(){ + return this.core.vectorstoreId + } + set vectorstoreId(vectorstoreId){ + /* validate vectorstoreId */ + if(!vectorstoreId?.length) + throw new Error('vectorstoreId required') + this.dataservices.patch(this.core.id, { vectorstoreId, }) /* no await */ + this.core.vectorstoreId = vectorstoreId /* update local */ + } } // private module functions /** diff --git a/inc/js/mylife-avatar.mjs b/inc/js/mylife-avatar.mjs index 7b1fa9d..c0caa44 100644 --- a/inc/js/mylife-avatar.mjs +++ b/inc/js/mylife-avatar.mjs @@ -18,6 +18,7 @@ const mBotIdOverride = OPENAI_MAHT_GPT_OVERRIDE class Avatar extends EventEmitter { #activeBotId // id of active bot in this.#bots; empty or undefined, then this #activeChatCategory = mGetChatCategory() + #assetAgent #bots = [] #conversations = [] #evolver @@ -48,6 +49,7 @@ class Avatar extends EventEmitter { super() this.#factory = factory this.#llmServices = llmServices + this.#assetAgent = new oAIAssetAssistant(this.#factory, this.globals, this.#llmServices) } /* public functions */ /** @@ -156,6 +158,10 @@ class Avatar extends EventEmitter { * @returns {array} - The collection items with no wrapper. */ async collections(type){ + if(type==='file'){ + await this.#assetAgent.init() // bypass factory for files + return this.#assetAgent.files + } // bypass factory for files const { factory, } = this const collections = ( await factory.collections(type) ) .map(collection=>{ @@ -390,15 +396,58 @@ class Avatar extends EventEmitter { * @todo - implement MyLife file upload. * @param {File[]} files - The array of files to upload. * @param {boolean} includeMyLife - Whether to include MyLife in the upload, defaults to `false`. - * @returns + * @returns {boolean} - true if upload successful. */ async upload(files, includeMyLife=false){ if(this.isMyLife) throw new Error('MyLife avatar cannot upload files.') - const assetAgent = new oAIAssetAssistant(files, this.#factory, this.globals, this.#llmServices, includeMyLife) - await assetAgent.init() + const { vectorstoreId, } = this.#factory + await this.#assetAgent.init(this.#factory.vectorstoreId, includeMyLife) /* or "re-init" */ + await this.#assetAgent.upload(files) + const { response, vectorstoreId: newVectorstoreId, vectorstoreFileList, } = this.#assetAgent + if(!vectorstoreId && newVectorstoreId) + this.updateTools() + return { + uploads: files, + files: vectorstoreFileList, + success: true, + } + } + /** + * Update tools for bot-assistant based on type. + * @todo - manage issue that several bots require attachment upon creation. + * @todo - move to llm code. + * @todo - allow for multiple types simultaneously. + * @param {string} bot_id - The bot id to update tools for. + * @param {string} type - The type of tool to update. + * @param {any} data - The data required by the switch type. + * @returns {void} + */ + async updateTools(botId=this.avatarBot.bot_id, type='file_search', data){ + let tools + switch(type){ + case 'function': // @stub - function tools + tools = { + tools: [{ type: 'function' }], + function: {}, + } + break + case 'file_search': + default: + if(!this.#factory.vectorstoreId) + return + tools = { + tools: [{ type: 'file_search' }], + tool_resources: { + file_search: { + vector_store_ids: [this.#factory.vectorstoreId], + } + }, + } + } + if(tools) + this.#llmServices.updateTools(botId, tools) /* no await */ } - // upon dissolution, forced/squeezed by session presumably (dehydrate), present itself to factory.evolution agent (or emit?) for inspection and incorporation if appropriate into datacore /* getters/setters */ /** * Get the active bot. If no active bot, return this as default chat engine. diff --git a/inc/js/mylife-data-service.js b/inc/js/mylife-data-service.js index fab78d9..9af7eee 100644 --- a/inc/js/mylife-data-service.js +++ b/inc/js/mylife-data-service.js @@ -69,7 +69,7 @@ class Dataservices { this.#Datamanager = new Datamanager(this.#partitionId) await this.#Datamanager.init() // init datamanager const _excludeProperties = { '_none':true } // populate if exclusions are required - const _core = Object.entries(this.datamanager.core) // array of arrays + const core = Object.entries(this.datamanager.core) // array of arrays .filter((_prop)=>{ // filter out excluded properties const _charExlusions = ['_','@','$','%','!','*',' '] return !( @@ -80,7 +80,7 @@ class Dataservices { .map(_prop=>{ // map to object return { [_prop[0]]:_prop[1] } }) - this.#core = Object.assign({},..._core) // init core + this.#core = Object.assign({},...core) // init core return this } // getters/setters diff --git a/inc/js/mylife-llm-services.mjs b/inc/js/mylife-llm-services.mjs index 16e41a5..81fe679 100644 --- a/inc/js/mylife-llm-services.mjs +++ b/inc/js/mylife-llm-services.mjs @@ -40,6 +40,22 @@ class LLMServices { async createBot(assistant){ return await this.openai.beta.assistants.create(assistant) } + /** + * Returns openAI file object. + * @param {string} fileId - OpenAI file ID. + * @returns - OpenAI `file` object. + */ + async file(fileId){ + return await this.openai.files.retrieve(fileId) + } + /** + * Returns file list from indicated vector store. + * @param {string} vectorstoreId - OpenAI vector store ID. + * @returns {Promise} - Array of openai `file` objects. + */ + async files(vectorstoreId){ + return await this.openai.beta.vectorStores.files.list(vectorstoreId) + } /** * Given member input, get a response from the specified LLM service. * @todo - confirm that reason for **factory** is to run functions as responses from LLM; ergo in any case, find better way to stash/cache factory so it does not need to be passed through every such function @@ -78,6 +94,47 @@ class LLMServices { async thread(threadId){ return await mThread(this.openai, threadId) } + /** + * Updates assistand with specified tools. Tools object for openai: { tool_resources: { file_search: { vector_store_ids: [vectorStore.id] } }, }; https://platform.openai.com/docs/assistants/tools/file-search/quickstart?lang=node.js + * @param {string} assistantId - OpenAI assistant ID. + * @param {object} tools - Tools object. + */ + async updateTools(assistantId, tools){ + return await this.openai.beta.assistants.update(assistantId, tools) + } + /** + * Upload files to OpenAI, currently `2024-05-13`, using vector-store, which is a new refactored mechanic. + * @documentation [OpenAI API Reference: Vector Stores](https://platform.openai.com/docs/api-reference/vector-stores) + * @documentation [file_search Quickstart](https://platform.openai.com/docs/assistants/tools/file-search/quickstart) + * @param {string} vectorstoreId - Vector store ID from OpenAI. + * @param {object} files - as seems to be requested by api: { files, fileIds, }. + * @param {string} memberId - Member ID, will be `name` of vector-store. + * @returns {Promise} - The vector store ID. + */ + async upload(vectorstoreId, files, memberId){ + if(!files?.length) + throw new Error('No files to upload') + if(!vectorstoreId){ /* create vector-store */ + const vectorstore = await this.openai.beta.vectorStores.create({ + name: memberId, + }) + vectorstoreId = vectorstore.id + } + let response, + success = false + try{ + response = await this.openai.beta.vectorStores.fileBatches.uploadAndPoll(vectorstoreId, { files, }) + success = true + } catch(error) { + console.log('LLMServices::upload()::error', error.message) + response = error.message + } + return { + vectorstoreId, + response, + success, + } + } /* getters/setters */ get openai(){ return this.provider diff --git a/inc/js/routes.mjs b/inc/js/routes.mjs index 926b2e7..10bc886 100644 --- a/inc/js/routes.mjs +++ b/inc/js/routes.mjs @@ -22,7 +22,6 @@ import { privacyPolicy, signup, upload, - _upload } from './functions.mjs' import { availableExperiences, @@ -81,6 +80,8 @@ _apiRouter.post('/library/:mid', library) _apiRouter.post('/register', register) _apiRouter.post('/story/library/:mid', storyLibrary) /* ordered first for path rendering */ _apiRouter.post('/story/:mid', story) +_apiRouter.post('/upload', upload) +_apiRouter.post('/upload/:mid', upload) /* member routes */ _memberRouter.use(memberValidation) _memberRouter.delete('/items/:iid', deleteItem) @@ -94,7 +95,6 @@ _memberRouter.get('/contributions/:cid', contributions) _memberRouter.get('/experiences', experiences) _memberRouter.get('/experiencesLived', experiencesLived) _memberRouter.get('/mode', interfaceMode) -_memberRouter.get('/upload', upload) _memberRouter.patch('/experience/:eid', experience) _memberRouter.patch('/experience/:eid/end', experienceEnd) _memberRouter.patch('/experience/:eid/manifest', experienceManifest) @@ -105,7 +105,7 @@ _memberRouter.post('/category', category) _memberRouter.post('contributions/:cid', contributions) _memberRouter.post('/mode', interfaceMode) _memberRouter.post('/passphrase', passphraseReset) -_memberRouter.post('/upload', _upload) +_memberRouter.post('/upload', upload) _memberRouter.put('/bots/:bid', bots) // Mount the subordinate routers along respective paths _Router.use('/members', _memberRouter.routes(), _memberRouter.allowedMethods()) diff --git a/package-lock.json b/package-lock.json index d97b9c6..546daf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1852,9 +1852,9 @@ } }, "node_modules/openai": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.40.0.tgz", - "integrity": "sha512-ofh9qMxRPDSZTWYvifScusMfnyIwEQL3w+fv3ucQGn3cIn0W6Zw4vXSUod8DwYfcX/hkAx9/ZvWrdkFYnVXlmQ==", + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.47.1.tgz", + "integrity": "sha512-WWSxhC/69ZhYWxH/OBsLEirIjUcfpQ5+ihkXKp06hmeYXgBBIUCa9IptMzYx6NdkiOCsSGYCnTIsxaic3AjRCQ==", "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", diff --git a/server.js b/server.js index 0b00cd9..ddfd14e 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,5 @@ // *imports +import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' // server @@ -18,12 +19,60 @@ const app = new Koa() const port = JSON.parse(process.env.PORT ?? '3000') const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const MemoryStore = new session.MemoryStore() const _Maht = await MyLife // Mylife is the pre-instantiated exported version of organization with very unique properties. MyLife class can protect fields that others cannot, #factory as first refactor will request +const MemoryStore = new session.MemoryStore() +const mimeTypesToExtensions = { + // Text Formats + 'text/plain': ['.txt', '.markdown', '.md', '.csv', '.log',], // Including Markdown (.md) as plain text + 'text/html': ['.html', '.htm'], + 'text/css': ['.css'], + 'text/javascript': ['.js'], + 'text/xml': ['.xml'], + 'application/json': ['.json'], + 'application/javascript': ['.js'], + 'application/xml': ['.xml'], + // Image Formats + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/png': ['.png'], + 'image/gif': ['.gif'], + 'image/svg+xml': ['.svg'], + 'image/webp': ['.webp'], + 'image/tiff': ['.tiff', '.tif'], + 'image/bmp': ['.bmp'], + 'image/x-icon': ['.ico'], + // Document Formats + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-powerpoint': ['.ppt'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], + 'application/rtf': ['.rtf'], + 'application/vnd.oasis.opendocument.text': ['.odt'], + 'application/vnd.oasis.opendocument.spreadsheet': ['.ods'], + 'application/vnd.oasis.opendocument.presentation': ['.odp'], + // Audio Formats + 'audio/mpeg': ['.mp3'], + 'audio/vorbis': ['.ogg'], // Commonly .ogg can also be used for video + 'audio/x-wav': ['.wav'], + 'audio/webm': ['.weba'], + 'audio/aac': ['.aac'], + 'audio/flac': ['.flac'], + // Video Formats + 'video/mp4': ['.mp4'], + 'video/x-msvideo': ['.avi'], + 'video/x-ms-wmv': ['.wmv'], + 'video/mpeg': ['.mpeg', '.mpg'], + 'video/webm': ['.webm'], + 'video/ogg': ['.ogv'], + 'video/x-flv': ['.flv'], + 'video/quicktime': ['.mov'], + // Add more MIME categories, types, and extensions as needed +} const serverRouter = await _Maht.router console.log(chalk.bgBlue('created-core-entity:', chalk.bgRedBright('MAHT'))) // test harness region - // koa-ejs render(app, { root: path.join(__dirname, 'views'), @@ -35,18 +84,41 @@ render(app, { // Set an interval to check for alerts every minute (60000 milliseconds) setInterval(checkForLiveAlerts, JSON.parse(process.env.MYLIFE_SYSTEM_ALERT_CHECK_INTERVAL ?? '60000')) // app bootup +/* upload directory */ +const uploadDir = path.join(__dirname, '.tmp') +if(!fs.existsSync(uploadDir)){ + fs.mkdirSync(uploadDir, { recursive: true }) +} // app context (ctx) modification app.context.MyLife = _Maht app.context.Globals = _Maht.globals app.context.menu = _Maht.menu -app.context.hostedMembers = JSON.parse(process.env.MYLIFE_HOSTED_MBR_ID) // array of mbr_id -// does _Maht, as uber-sessioned, need to have ctx injected? +app.context.hostedMembers = JSON.parse(process.env.MYLIFE_HOSTED_MBR_ID) app.keys = [process.env.MYLIFE_SESSION_KEY ?? `mylife-session-failsafe|${_Maht.newGuid()}`] // Enable Koa body w/ configuration app.use(koaBody({ multipart: true, formidable: { + keepExtensions: true, // keep file extension maxFileSize: parseInt(process.env.MYLIFE_EMBEDDING_SERVER_FILESIZE_LIMIT_ADMIN) || 10485760, // 10MB in bytes + uploadDir: uploadDir, + onFileBegin: (name, file) => { + const { filepath, mimetype, newFilename, originalFilename, size, } = file + let extension = path.extname(originalFilename).toLowerCase() + if(!extension) + extension = mimeTypesToExtensions[mimetype]?.[0] + /* validate mimetypes */ + const validFileType = mimeTypesToExtensions[mimetype]?.includes(extension) + if(!validFileType) + throw new Error('Invalid mime type') + /* mutate newFilename && filepath */ + const { name: filename, } = path.parse(originalFilename) + const safeName = filename.replace(/[^a-z0-9.]/gi, '_').replace(/\s/g, '-').toLowerCase() + extension + /* @stub - create temp user sub-dir? */ + file.newFilename = safeName + file.filepath = path.join(uploadDir, safeName) + console.log(chalk.bgBlue('file-upload', chalk.yellowBright(file.filepath))) + } }, })) .use(serve(path.join(__dirname, 'views', 'assets'))) diff --git a/views/assets/html/_widget-bots.html b/views/assets/html/_widget-bots.html index cda6373..66f2af5 100644 --- a/views/assets/html/_widget-bots.html +++ b/views/assets/html/_widget-bots.html @@ -1,38 +1,37 @@ -
-
-
-
-
Intelligent Assistant
-
+
+
+
+
+
Intelligent Assistant
+
<%= avatar.name %>
-
+
- \ No newline at end of file diff --git a/views/assets/js/bots.mjs b/views/assets/js/bots.mjs index 6b87abb..ee69cc4 100644 --- a/views/assets/js/bots.mjs +++ b/views/assets/js/bots.mjs @@ -9,11 +9,14 @@ import { toggleVisibility, } from './members.mjs' import Globals from './globals.mjs' -/* constants */ -const botBar = document.getElementById('bot-bar'), +/* constants; DOM exists? */ +const mAvailableMimeTypes = [], + mAvailableUploaderTypes = ['library', 'personal-avatar', 'personal-biographer', 'resume',], + botBar = document.getElementById('bot-bar'), mGlobals = new Globals(), mLibraries = ['experience', 'file', 'story'], // ['chat', 'entry', 'experience', 'file', 'story'] - libraryCollections = document.getElementById('library-collections'), + mLibraryCollections = document.getElementById('library-collections'), + mLibraryUpload = document.getElementById('library-upload'), passphraseCancelButton = document.getElementById(`personal-avatar-passphrase-cancel`), passphraseInput = document.getElementById(`personal-avatar-passphrase`), passphraseInputContainer = document.getElementById(`personal-avatar-passphrase-container`), @@ -186,7 +189,7 @@ function mBotIcon(type){ */ function mCreateCollectionItem(collectionItem){ /* collection item container */ - const { assistantType, form, id, keywords, library_id, name, summary, title, type, } = collectionItem + const { assistantType, filename, form, id, keywords, library_id, name, summary, title, type, } = collectionItem const item = document.createElement('div') item.id = `collection-item_${ id }` item.name = `collection-item-${ type }` @@ -203,16 +206,32 @@ function mCreateCollectionItem(collectionItem){ itemName.id = `collection-item-name_${ id }` itemName.name = `collection-item-name-${ type }` itemName.classList.add('collection-item-name', `${ type }-collection-item-name`) - itemName.innerText = title ?? name + itemName.innerText = title + ?? name + ?? filename + ?? `unknown ${ type } item` item.appendChild(itemName) /* buttons */ - const itemDelete = mCreateCollectionItemDelete(type, id) - item.appendChild(itemDelete) + switch(type){ + case 'file': + /* file-summary button */ + break + default: + const itemDelete = mCreateCollectionItemDelete(type, id) + item.appendChild(itemDelete) + break + } /* popup */ - const itemPopup = mCreateCollectionPopup(collectionItem) - item.appendChild(itemPopup) - /* listeners */ - item.addEventListener('click', mViewItemPopup) + switch(type){ + case 'file': + /* file-summary button */ + break + default: + const itemPopup = mCreateCollectionPopup(collectionItem) + item.appendChild(itemPopup) + item.addEventListener('click', mViewItemPopup) + break + } return item } /** @@ -682,9 +701,9 @@ function mUpdateBotContainers(){ switch(type){ case 'library': /* attach library collection listeners */ - if(!libraryCollections || !libraryCollections.children.length) + if(!mLibraryCollections || !mLibraryCollections.children.length) return - for(let collection of libraryCollections.children){ + for(let collection of mLibraryCollections.children){ let { id, } = collection id = id.split('-').pop() if(!mLibraries.includes(id)){ @@ -700,6 +719,8 @@ function mUpdateBotContainers(){ if(collectionButton) collectionButton.addEventListener('click', mRefreshCollection, { once: true }) } + if(mLibraryUpload) + mLibraryUpload.addEventListener('click', mUploadFiles) break case 'personal-avatar': /* attach avatar listeners */ @@ -729,9 +750,18 @@ function mUpdateBotContainers(){ * @returns {void} */ function mUpdateCollection(type, collection, collectionList){ + console.log('mUpdateCollection()', type) collectionList.innerHTML = '' collection - .map(item=>({ ...item, type: item.type ?? item.being ?? type, })) + .map(item=>({ + ...item, + name: item.name + ?? item.filename + ?? type, + type: item.type + ?? item.being + ?? type, + })) .filter(item=>item.type===type) .sort((a, b)=>a.name.localeCompare(b.name)) .forEach(item=>{ @@ -832,6 +862,67 @@ function mUpdateTicker(type, botContainer){ function mUpdateTickerValue(ticker, value){ ticker.innerText = value } +/** + * Upload Files to server from any . + * @async + * @requires mAvailableMimeTypes + * @requires mAvailableUploaderTypes + * @requires mGlobals + * @requires mLibraryUpload + * @param {Event} event - The event object. + */ +async function mUploadFiles(event){ + const { id, parentNode: uploadParent, } = this + const type = mGlobals.HTMLIdToType(id) + if(!mAvailableUploaderTypes.includes(type)) + throw new Error(`Uploader type not found, upload function unavailable for this bot.`) + let fileInput + try{ + console.log('mUploadFiles()::uploader', document.activeElement) + mLibraryUpload.disabled = true + fileInput = document.createElement('input') + fileInput.id = `file-input-${ type }` + fileInput.multiple = true + fileInput.name = fileInput.id + fileInput.type = 'file' + uploadParent.appendChild(fileInput) + hide(fileInput) + fileInput.click() + window.addEventListener('focus', async event=>{ + await mUploadFilesInput(fileInput, uploadParent, mLibraryUpload) + }, { once: true }) + } catch(error) { + mUploadFilesInputRemove(fileInput, uploadParent, mLibraryUpload) + console.log('mUploadFiles()::ERROR uploading files:', error) + } +} +async function mUploadFilesInput(fileInput, uploadParent, uploadButton){ + console.log('mUploadFilesInput()::clicked', fileInput, uploadButton) + const fileTest = document.getElementById(`file-input-library`) + // try adding listener to fileInput onChange now + fileInput.addEventListener('change', async event=>{ + const { files, } = fileInput + if(files?.length){ + /* send to server */ + const formData = new FormData() + for(let file of files){ + formData.append('files[]', file) + } + formData.append('type', mGlobals.HTMLIdToType(uploadParent.id)) + const response = await fetch('/members/upload', { + method: 'POST', + body: formData + }) + const result = await response.json() + } + }, { once: true }) + mUploadFilesInputRemove(fileInput, uploadParent, uploadButton) +} +function mUploadFilesInputRemove(fileInput, uploadParent, uploadButton){ + if(fileInput && uploadParent.contains(fileInput)) + uploadParent.removeChild(fileInput) + uploadButton.disabled = false +} /** * View/toggles collection item popup. * @param {Event} event - The event object. diff --git a/views/assets/js/globals.mjs b/views/assets/js/globals.mjs index 89aeccb..3929f9b 100644 --- a/views/assets/js/globals.mjs +++ b/views/assets/js/globals.mjs @@ -147,6 +147,28 @@ class Globals { return false } } + /** + * Consumes an HTML id and returns the functionality name. Example: `library-upload` returns `upload`. + * @public + * @param {string} id - The HTML id to convert. + * @returns {string} - The functionality name. + */ + HTMLIdToFunction(id){ + if(id.includes('-')) + id = id.split('-').pop() + return id + } + /** + * Consumes an HTML id and returns the type. Example: `library-upload` returns `library`. + * @public + * @param {string} id - The HTML id to convert. + * @returns {string} - The type. + */ + HTMLIdToType(id){ + if(id.includes('-')) + id = id.split('-').slice(0, -1).join('-') + return id + } /** * Hides an element, pre-executing any included callback function. * @public