From 536e511668668fc157c24b9ada9b5b9e1035ea5c Mon Sep 17 00:00:00 2001 From: Erik Jespersen <42016062+Mookse@users.noreply.github.com> Date: Tue, 30 Apr 2024 13:42:23 -0400 Subject: [PATCH 01/13] HOTFIX: 184 keyvalidation (#185) * 20240430 @Mookse - refactor mAPIKeyValidation - cosmetic * 20240420 @Mookse - frontend visual Signed-off-by: Erik Jespersen * 20240420 @Mookse - login logout functionality Signed-off-by: Erik Jespersen * 20240430 @Mookse - refactor mAPIKeyValidation - cosmetic Signed-off-by: Erik Jespersen --------- Signed-off-by: Erik Jespersen --- inc/js/api-functions.mjs | 136 ++++++++++++++++++--------------------- 1 file changed, 64 insertions(+), 72 deletions(-) diff --git a/inc/js/api-functions.mjs b/inc/js/api-functions.mjs index be0242f..ea53a0c 100644 --- a/inc/js/api-functions.mjs +++ b/inc/js/api-functions.mjs @@ -20,8 +20,8 @@ async function experienceBuilder(ctx){ * @property {array} cast - Experience cast array. * @property {object} navigation - Navigation object (optional - for interactive display purposes only). */ -function experienceCast(ctx){ - mAPIKeyValidation(ctx) +async function experienceCast(ctx){ + await mAPIKeyValidation(ctx) const { assistantType, avatar, mbr_id } = ctx.state const { eid } = ctx.params ctx.body = avatar.cast @@ -42,7 +42,7 @@ async function experience(ctx){ // @stub - if experience is locked, perhaps request to run an internal check to see if exp is bugged or timed out ctx.throw(500, 'Experience is locked. Wait for previous event to complete. If bugged, end experience and begin again.') } - mAPIKeyValidation(ctx) + await mAPIKeyValidation(ctx) const { assistantType, avatar, mbr_id, } = ctx.state const { eid, } = ctx.params let events = [] @@ -85,8 +85,8 @@ async function experience(ctx){ * @returns {Object} - Represents `ctx.body` object with following `experience` properties. * @property {boolean} success - Success status, true/false. */ -function experienceEnd(ctx){ - mAPIKeyValidation(ctx) +async function experienceEnd(ctx){ + await mAPIKeyValidation(ctx) const { assistantType, avatar, mbr_id } = ctx.state const { eid } = ctx.params let endSuccess = false @@ -109,8 +109,8 @@ function experienceEnd(ctx){ * @property {Array} cast - Experience cast array. * @property {Object} navigation - Navigation object (optional - for interactive display purposes only). */ -function experienceManifest(ctx){ - mAPIKeyValidation(ctx) +async function experienceManifest(ctx){ + await mAPIKeyValidation(ctx) const { assistantType, avatar, mbr_id } = ctx.state ctx.body = avatar.manifest return @@ -118,8 +118,8 @@ function experienceManifest(ctx){ /** * Navigation array of scenes for experience. */ -function experienceNavigation(ctx){ - mAPIKeyValidation(ctx) +async function experienceNavigation(ctx){ + await mAPIKeyValidation(ctx) const { assistantType, avatar, mbr_id } = ctx.state ctx.body = avatar.navigation return @@ -132,7 +132,7 @@ function experienceNavigation(ctx){ * @property {array} experiences - Array of Experience shorthand objects. */ async function experiences(ctx){ - mAPIKeyValidation(ctx) + await mAPIKeyValidation(ctx) const { assistantType, MemberSession } = ctx.state // limit one mandatory experience (others could be highlighted in alerts) per session const experiencesObject = await MemberSession.experiences() @@ -140,33 +140,34 @@ async function experiences(ctx){ return } async function keyValidation(ctx){ // from openAI - mAPIKeyValidation(ctx) + await mAPIKeyValidation(ctx) ctx.status = 200 // OK if(ctx.method === 'HEAD') return - // @todo: determine how to instantiate avatar via Maht Factory--session? In any case, perhaps relegate to session - const _memberCore = await ctx.MyLife.datacore(ctx.state.mbr_id) - const { updates, interests, birth, birthDate, fullName, names, nickname } = _memberCore - const _birth = (Array.isArray(birth) && birth.length) - ? birth[0] - : birth??{} - _birth.date = birthDate??_birth.date??'' - _birth.place = _birth.place??'' - const _memberCoreData = { - mbr_id: ctx.state.mbr_id, - updates: updates??'', - interests: interests??'', - birthDate: _birth.date, - birthPlace: _birth.place, - fullName: fullName??names?.[0]??'', - preferredName: nickname??names?.[0].split(' ')[0]??'', + const { mbr_id } = ctx.state + const memberCore = await ctx.MyLife.datacore(mbr_id) + const { updates, interests, birth: memberBirth, birthDate: memberBirthDate, fullName, names, nickname } = memberCore + const birth = (Array.isArray(memberBirth) && memberBirth.length) + ? memberBirth[0] + : memberBirth ?? {} + birth.date = memberBirthDate ?? birth.date + birth.place = birth.place + const memberCoreData = { + mbr_id, + updates, + interests, + birthDate: birth.date, + birthPlace: birth.place, + fullName: fullName ?? names?.[0] ?? 'unknown member', + preferredName: nickname + ?? names?.[0].split(' ')[0] + ?? '', } + console.log(chalk.yellowBright(`keyValidation():${memberCoreData.mbr_id}`), memberCoreData.fullName) ctx.body = { success: true, message: 'Valid member.', - data: _memberCoreData, + data: memberCoreData, } - console.log(chalk.yellowBright(`keyValidation():${_memberCoreData.mbr_id}`), _memberCoreData.fullName) - return } /** * All functionality related to a library. Note: Had to be consolidated, as openai GPT would only POST. @@ -176,7 +177,7 @@ async function keyValidation(ctx){ // from openAI * @returns {Koa} Koa Context object */ async function library(ctx){ - mAPIKeyValidation(ctx) + await mAPIKeyValidation(ctx) const { assistantType, mbr_id, @@ -260,7 +261,7 @@ async function register(ctx){ * @returns {Koa} Koa Context object */ async function story(ctx){ - mAPIKeyValidation(ctx) // sets ctx.state.mbr_id and more + await mAPIKeyValidation(ctx) // sets ctx.state.mbr_id and more const { assistantType, mbr_id } = ctx.state const { storySummary } = ctx.request?.body??{} if(!storySummary?.length) @@ -331,49 +332,40 @@ async function tokenValidation(ctx, next) { * Validates key and sets `ctx.state` and `ctx.session` properties. `ctx.state`: [ assistantType, isValidated, mbr_id, ]. `ctx.session`: [ isAPIValidated, APIMemberKey, ]. * @modular * @private + * @async * @param {Koa} ctx - Koa Context object. - * @returns {void} + * @returns {Promise} */ -function mAPIKeyValidation(ctx){ // transforms ctx.state - if(!ctx.state.locked) return - if(ctx.params.mid === ':mid') ctx.params.mid = undefined - // ctx session alternatives to hitting DB every time? can try... - const mbr_id = ctx.params.mid??ctx.request.body.memberKey - if(!mbr_id?.length) - ctx.throw(400, 'Missing member key.') - const serverHostedMembers = JSON.parse(process.env.MYLIFE_HOSTED_MBR_ID??'[]') - const localHostedMembers = [ - 'system-one|4e6e2f26-174b-43e4-851f-7cf9cdf056df', - ].filter(member=>serverHostedMembers.includes(member)) // none currently - serverHostedMembers.push(...localHostedMembers) - /* inline function definitions */ - function _keyValidation(ctx, mbr_id){ // returns Promise - return new Promise(async (resolve, reject) => { - if( // session validation - (ctx.session?.isAPIValidated ?? false) - && mbr_id === (ctx.session?.APIMemberKey ?? false) - ){ - resolve(true) - } - if(serverHostedMembers.includes(mbr_id)){ // initial full validation - resolve( await ctx.MyLife.testPartitionKey(mbr_id) ) - } - resolve(false) - }) +async function mAPIKeyValidation(ctx){ // transforms ctx.state + if(ctx.params.mid === ':mid') + ctx.params.mid = undefined + const memberId = ctx.params?.mid + ?? ctx.request.body?.memberKey + ?? ctx.session?.APIMemberKey + if(!memberId?.length) + if(ctx.state.locked) + ctx.throw(400, 'Missing member key.') + else // unlocked, providing passphrase + return + let isValidated + if(!ctx.state.locked || ctx.session?.isAPIValidated){ + isValidated = true + } else { + const serverHostedMembers = JSON.parse(process.env.MYLIFE_HOSTED_MBR_ID ?? '[]') + const localHostedMembers = [ + 'system-one|4e6e2f26-174b-43e4-851f-7cf9cdf056df', + ].filter(member=>serverHostedMembers.includes(member)) // none currently + serverHostedMembers.push(...localHostedMembers) + if(serverHostedMembers.includes(memberId)) + isValidated = await ctx.MyLife.testPartitionKey(memberId) + } + if(isValidated){ + ctx.state.isValidated = true + ctx.state.mbr_id = memberId + ctx.state.assistantType = mTokenType(ctx) + ctx.session.isAPIValidated = ctx.state.isValidated + ctx.session.APIMemberKey = ctx.state.mbr_id } - _keyValidation(ctx, mbr_id) - .then(isValidated=>{ - if(!isValidated) - ctx.throw(400, 'Member Key unknown', error) - ctx.state.isValidated = isValidated - ctx.state.mbr_id = mbr_id - ctx.state.assistantType = mTokenType(ctx) - ctx.session.isAPIValidated = ctx.state.isValidated - ctx.session.APIMemberKey = ctx.state.mbr_id - }) - .catch(error=>{ - ctx.throw(500, 'API Key Validation Error', error) - }) } function mTokenType(ctx){ const _token = ctx.state.token From d057f0598b59567b661f237a6cf6ce50974d1c3a Mon Sep 17 00:00:00 2001 From: Erik Jespersen Date: Thu, 2 May 2024 13:35:17 -0400 Subject: [PATCH 02/13] 20240501 @Mookse - GPT JSON and YAML definitions for storySummary functionality --- .../openai/functions/storySummary.json | 64 +++++++++++++++++++ inc/yaml/mylife_biographer-bot_openai.yaml | 56 ++++++++++++++-- 2 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 inc/json-schemas/openai/functions/storySummary.json diff --git a/inc/json-schemas/openai/functions/storySummary.json b/inc/json-schemas/openai/functions/storySummary.json new file mode 100644 index 0000000..23662b0 --- /dev/null +++ b/inc/json-schemas/openai/functions/storySummary.json @@ -0,0 +1,64 @@ +{ + "description": "Generate a STORY summary with keywords and other critical data elements.", + "name": "storySummary", + "parameters": { + "type": "object", + "properties": { + "keywords": { + "description": "Keywords most relevant to STORY.", + "items": { + "description": "Keyword (single word or short phrase) to be used in STORY summary.", + "maxLength": 64, + "type": "string" + }, + "maxItems": 12, + "minItems": 3, + "type": "array" + }, + "phaseOfLife": { + "description": "Phase of life indicated in STORY.", + "enum": [ + "birth", + "childhood", + "adolescence", + "teenage", + "young-adult", + "adulthood", + "middle-age", + "senior", + "end-of-life", + "past-life", + "unknown", + "other" + ], + "maxLength": 64, + "type": "string" + }, + "relationships": { + "description": "MyLife Biographer Bot does its best to record individuals (or pets) mentioned in this `story`.", + "type": "array", + "items": { + "description": "A name of relational individual/pet to the `story` content.", + "type": "string" + }, + "maxItems": 24 + }, + "summary": { + "description": "Generate a STORY summary from input.", + "maxLength": 20480, + "type": "string" + }, + "title": { + "description": "Generate display Title of the STORY.", + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "keywords", + "phaseOfLife", + "summary", + "title" + ] + } +} \ No newline at end of file diff --git a/inc/yaml/mylife_biographer-bot_openai.yaml b/inc/yaml/mylife_biographer-bot_openai.yaml index 0e3fc26..d899791 100644 --- a/inc/yaml/mylife_biographer-bot_openai.yaml +++ b/inc/yaml/mylife_biographer-bot_openai.yaml @@ -1,8 +1,11 @@ openapi: 3.0.0 info: title: MyLife GPT Webhook Receiver API - description: This API is for receiving webhooks from [MyLife's public Biographer Bot instance](https://chat.openai.com/g/g-QGzfgKj6I-mylife-biographer-bot). - version: 1.0.0 + description: | + This API is for receiving webhooks from [MyLife's public Biographer Bot instance](https://chat.openai.com/g/g-QGzfgKj6I-mylife-biographer-bot). + ## Updates + - added: keywords, phaseOfLife, relationships to bot package + version: 1.0.1 servers: - url: https://humanremembranceproject.org/api/v1 description: Endpoint for receiving stories from the MyLife Biographer Bot instance. @@ -80,15 +83,54 @@ paths: content: application/json: schema: - description: The `story` data sent by MyLife Biographer BOt. + description: The `story` data sent by MyLife Biographer Bot. type: object required: - - storySummary + - keywords + - phaseOfLife + - summary + - title properties: - storySummary: + keywords: + description: MyLife Biographer Bot interprets (or asks) keywords for this `story`. Should not include relations or names, but would tag content dynamically. Each should be a single word or short phrase. + items: + description: A single word or short phrase to tag `story` content. + maxLength: 64 + type: string + maxItems: 12 + minItems: 3 + type: array + phaseOfLife: + description: MyLife Biographer Bot interprets (or asks) phase of life for this `story`. Can be `unkown`. + enum: + - birth + - childhood + - adolescence + - teenage + - young-adult + - adulthood + - middle-age + - senior + - end-of-life + - past-life + - unknown + - other + type: string + relationships: + description: MyLife Biographer Bot does its best to record individuals (or pets) mentioned in this `story`. + items: + description: A name of relational individual/pet to the `story` content. + type: string + maxItems: 24 + type: array + summary: description: MyLife Biographer Bot summary of identified `story`. maxLength: 20480 type: string + title: + description: MyLife Biographer Bot determines title for this `story`. + maxLength: 256 + type: string responses: "200": description: Story submitted successfully. @@ -105,7 +147,7 @@ responses: type: string example: Story submitted successfully. "400": - description: No story summary provided. Use `storySummary` field. + description: No story summary provided. Use `summary` field. content: application/json: schema: @@ -117,4 +159,4 @@ responses: example: false message: type: string - example: No story summary provided. Use `storySummary` field. \ No newline at end of file + example: No story summary provided. Use `summary` field. \ No newline at end of file From 91ebbdc74d2fdc3666ddd20e1669fae5006dbb8b Mon Sep 17 00:00:00 2001 From: Erik Jespersen Date: Thu, 2 May 2024 13:37:37 -0400 Subject: [PATCH 03/13] 20240502 @Mookse - MyLife Biographer Bot performs storySummary function --- .../class-extenders.mjs | 2 +- inc/js/functions.mjs | 10 +- inc/js/mylife-agent-factory.mjs | 62 ++++++---- inc/js/mylife-avatar.mjs | 9 +- inc/js/mylife-data-service.js | 8 +- inc/js/mylife-llm-services.mjs | 112 ++++++++++++++++-- 6 files changed, 155 insertions(+), 48 deletions(-) diff --git a/inc/js/factory-class-extenders/class-extenders.mjs b/inc/js/factory-class-extenders/class-extenders.mjs index bc64b84..89eb089 100644 --- a/inc/js/factory-class-extenders/class-extenders.mjs +++ b/inc/js/factory-class-extenders/class-extenders.mjs @@ -405,7 +405,7 @@ function extendClass_message(originClass, referencesObject) { const { content, ..._obj } = obj super(_obj) try{ - this.#content = mAssignContent(content??obj) + this.#content = mAssignContent(content ?? obj) } catch(e){ console.log('Message::constructor::ERROR', e) this.#content = '' diff --git a/inc/js/functions.mjs b/inc/js/functions.mjs index 13fb983..013d275 100644 --- a/inc/js/functions.mjs +++ b/inc/js/functions.mjs @@ -72,11 +72,11 @@ async function challenge(ctx){ async function chat(ctx){ ctx.state.chatMessage = ctx.request.body ctx.state.thread = ctx.state.MemberSession.thread - const _message = ctx.request?.body?.message??false /* body has all the nodes sent by fe */ - if(!_message) ctx.throw(400, `invalid message: missing \`message\``) // currently only accepts single contributions via post with :cid - if(!_message?.length) ctx.throw(400, `empty message content`) - const _response = await ctx.state.avatar.chatRequest(ctx) - ctx.body = _response // return message_member_chat + const message = ctx.request.body?.message ?? false /* body has all the nodes sent by fe */ + if(!message?.length) + ctx.throw(400, 'missing `message` content') + const response = await ctx.state.avatar.chatRequest(ctx) + ctx.body = response } async function collections(ctx){ ctx.body = await ctx.state.avatar.collections(ctx.params.type) diff --git a/inc/js/mylife-agent-factory.mjs b/inc/js/mylife-agent-factory.mjs index 475912a..2508d2d 100644 --- a/inc/js/mylife-agent-factory.mjs +++ b/inc/js/mylife-agent-factory.mjs @@ -250,40 +250,40 @@ class BotFactory extends EventEmitter{ * @returns {object} - The library. */ async library(_library){ - const _updatedLibrary = await mLibrary(this, _library) + const updatedLibrary = await mLibrary(this, _library) // test the type/form of Library - switch(_updatedLibrary.type){ + switch(updatedLibrary.type){ case 'story': - if(_updatedLibrary.form==='biographer'){ + if(updatedLibrary.form==='biographer'){ // inflate and update library with stories - const _stories = ( await this.stories(_updatedLibrary.form) ) - .filter(_story=>!this.globals.isValidGuid(_story?.library_id)) - .map(_story=>{ - _story.id = _story.id??this.newGuid - _story.author = _story.author??this.mbr_name - _story.title = _story.title??_story.id - return mInflateLibraryItem(_story, _updatedLibrary.id, this.mbr_id) + const stories = ( await this.stories(updatedLibrary.form) ) + .filter(story=>!this.globals.isValidGuid(story?.library_id)) + .map(story=>{ + story.id = story.id ?? this.newGuid + story.author = story.author ?? this.mbr_name + story.title = story.title ?? story.id + return mInflateLibraryItem(story, updatedLibrary.id, this.mbr_id) }) - _updatedLibrary.items = [ - ..._updatedLibrary.items, - ..._stories, + updatedLibrary.items = [ + ...updatedLibrary.items, + ...stories, ] /* update stories (no await) */ - _stories.forEach(_story=>this.dataservices.patch( - _story.id, - { library_id: _updatedLibrary.id }, + stories.forEach(story=>this.dataservices.patch( + story.id, + { library_id: updatedLibrary.id }, )) /* update library (no await) */ this.dataservices.patch( - _updatedLibrary.id, - { items: _updatedLibrary.items }, + updatedLibrary.id, + { items: updatedLibrary.items }, ) } break default: break } - return _updatedLibrary + return updatedLibrary } /** * Allows member to reset passphrase. @@ -514,11 +514,29 @@ class AgentFactory extends BotFactory{ } /** * Submits a story to MyLife. Currently via API, but could be also work internally. - * @param {object} _story - Story object { assistantType, being, form, id, mbr_id, name, summary }. + * @param {object} story - Story object. * @returns {object} - The story document from Cosmos. */ - async story(_story){ - return await this.dataservices.story(_story) + async story(story){ + if(!story.summary?.length) + throw new Error('story summary required') + const id = this.newGuid + const title = story.title ?? 'New Memory Entry' + const finalStory = { + ...story, + ...{ + assistantType: story.assistantType ?? 'biographer-bot', + being: story.being ?? 'story', + form: story.form ?? 'biographer', + id, + keywords: story.keywords ?? ['memory', 'biographer', 'entry'], + mbr_id: this.mbr_id, + name: story.name ?? title ?? `story_${ this.mbr_id }_${ id }`, + phaseOfLife: story.phaseOfLife ?? 'unknown', + summary: story.summary, + title, + }} + return await this.dataservices.story(finalStory) } /** * Tests partition key for member diff --git a/inc/js/mylife-avatar.mjs b/inc/js/mylife-avatar.mjs index 94f9f02..b087d84 100644 --- a/inc/js/mylife-avatar.mjs +++ b/inc/js/mylife-avatar.mjs @@ -121,12 +121,12 @@ class Avatar extends EventEmitter { if(!conversation) conversation = await this.createConversation('chat') conversation.botId = this.activeBot.bot_id // pass in via quickly mutating conversation (or independently if preferred in end), versus llmServices which are global - const messages = await mCallLLM(this.#llmServices, conversation, prompt) + const messages = await mCallLLM(this.#llmServices, conversation, prompt, this.factory) conversation.addMessages(messages) if(mAllowSave) conversation.save() else - console.log('chatRequest::BYPASS-SAVE', conversation.message) + console.log('chatRequest::BYPASS-SAVE', conversation.message.content) /* frontend mutations */ const { activeBot: bot } = this // current fe will loop through messages in reverse chronological order @@ -821,15 +821,16 @@ async function mBot(factory, avatar, bot){ * @param {LLMServices} llmServices - OpenAI object currently * @param {Conversation} conversation - Conversation object * @param {string} prompt - dialog-prompt/message for llm + * @param {AgentFactory} factory - Agent Factory object required for function execution * @returns {Promise} - Array of Message instances in descending chronological order. */ -async function mCallLLM(llmServices, conversation, prompt){ +async function mCallLLM(llmServices, conversation, prompt, factory){ const { thread_id: threadId } = conversation if(!threadId) throw new Error('No `thread_id` found for conversation') if(!conversation.botId) throw new Error('No `botId` found for conversation') - const messages = await llmServices.getLLMResponse(threadId, conversation.botId, prompt) + const messages = await llmServices.getLLMResponse(threadId, conversation.botId, prompt, factory) messages.sort((mA, mB) => { return mB.created_at - mA.created_at }) diff --git a/inc/js/mylife-data-service.js b/inc/js/mylife-data-service.js index 694b5f3..77698c5 100644 --- a/inc/js/mylife-data-service.js +++ b/inc/js/mylife-data-service.js @@ -621,12 +621,12 @@ class Dataservices { } /** * Submits a story to MyLife. Currently via API, but could be also work internally. - * @param {object} _story - Story object { assistantType, being, form, id, mbr_id, name, summary }. + * @param {object} story - Story object. * @returns {object} - The story document from Cosmos. */ - async story(_story){ - if(!this.isMyLife) return - return await this.datamanager.pushItem(_story) + async story(story){ + const storyItem = await this.datamanager.pushItem(story) + return storyItem } /** * Tests partition key for member diff --git a/inc/js/mylife-llm-services.mjs b/inc/js/mylife-llm-services.mjs index 125c166..cda0d03 100644 --- a/inc/js/mylife-llm-services.mjs +++ b/inc/js/mylife-llm-services.mjs @@ -4,6 +4,17 @@ const { OPENAI_API_KEY: mOpenaiKey, OPENAI_BASE_URL: mBasePath, OPENAI_ORG_KEY: const mTimeoutMs = parseInt(OPENAI_API_CHAT_TIMEOUT) || 55000 const mPingIntervalMs = parseInt(OPENAI_API_CHAT_RESPONSE_PING_INTERVAL) || 890 /* class definition */ +/** + * LLM Services class. + * @todo - rather than passing factory in run, pass avatar + * @todo - convert run to streaming as defined in @documentation + * @class + * @classdesc LLM Services class. + * @documentation [OpenAI API Reference: Assistant Function Calling](https://platform.openai.com/docs/assistants/tools/function-calling/quickstart) + * @param {string} apiKey - openai api key + * @param {string} organizationKey - openai organization key + * @returns {LLMServices} - LLM Services object + */ class LLMServices { #llmProviders = [] /** @@ -34,11 +45,12 @@ class LLMServices { * @param {string} threadId - Thread id. * @param {string} botId - GPT-Assistant/Bot id. * @param {string} prompt - Member input. + * @param {AgentFactory} factory - Avatar Factory object to process request. * @returns {Promise} - Array of openai `message` objects. */ - async getLLMResponse(threadId, botId, prompt){ + async getLLMResponse(threadId, botId, prompt, factory){ await mAssignRequestToThread(this.openai, threadId, prompt) - const run = await mRunTrigger(this.openai, botId, threadId) + const run = await mRunTrigger(this.openai, botId, threadId, factory) const { assistant_id, id: run_id, model, provider='openai', required_action, status, usage } = run const llmMessageObject = await mMessages(this.provider, threadId) const { data: llmMessages} = llmMessageObject @@ -127,16 +139,18 @@ async function mMessages(openai, threadId){ * @async * @param {OpenAI} openai - openai object * @param {object} run - [OpenAI run object](https://platform.openai.com/docs/api-reference/runs/object) + * @param {AgentFactory} factory - Avatar Factory object to process request * @returns {object} - [OpenAI run object](https://platform.openai.com/docs/api-reference/runs/object) */ -async function mRunFinish(llmServices, run){ +async function mRunFinish(llmServices, run, factory){ return new Promise((resolve, reject) => { const checkInterval = setInterval(async () => { try { - const _run = await mRunStatus(llmServices, run) - if( _run?.status ?? _run ?? false){ + const functionRun = await mRunStatus(llmServices, run, factory) + console.log('mRunFinish::functionRun()', functionRun?.status) + if(functionRun?.status ?? functionRun ?? false){ clearInterval(checkInterval) - resolve(_run) + resolve(functionRun) } } catch (error) { clearInterval(checkInterval) @@ -150,6 +164,77 @@ async function mRunFinish(llmServices, run){ }, mTimeoutMs) }) } +/** + * Executes openAI run functions. See https://platform.openai.com/docs/assistants/tools/function-calling/quickstart. + * @todo - storysummary output action requires integration with factory/avatar data intersecting with story submission + * @modular + * @private + * @async + * @param {object} run - [OpenAI run object](https://platform.openai.com/docs/api-reference/runs/object) + * @param {AgentFactory} factory - Avatar Factory object to process request + * @returns {object} - [OpenAI run object](https://platform.openai.com/docs/api-reference/runs/object) + * @throws {Error} - If tool function not recognized + */ +async function mRunFunctions(openai, run, factory){ + if( + run.required_action?.type=='submit_tool_outputs' + && run.required_action?.submit_tool_outputs?.tool_calls + && run.required_action.submit_tool_outputs.tool_calls.length + ){ + const toolCallsOutput = await Promise.all( + run.required_action.submit_tool_outputs.tool_calls + .map(async tool=>{ + const { id, function: toolFunction, type, } = tool + let { arguments: toolArguments, name, } = toolFunction + switch(name.toLowerCase()){ + case 'story': // storySummary.json + case 'storysummary': + case 'story-summary': + case 'story_summary': + case 'story summary': + if(typeof toolArguments == 'string') + toolArguments = JSON.parse(toolArguments) + const story = await factory.story(toolArguments) + if(story){ + const { keywords, phaseOfLife='unknown', } = story + let { interests, updates, } = factory.core + // @stub - action integrates with story and interests/phase + let action + switch(true){ + case interests: + console.log('mRunFunctions()::story-summary::interests', interests) + if(typeof interests == 'array') + interests = interests.join(',') + action = `ask about a different interest from: ${ interests }` + break + case phaseOfLife!=='unknown': + console.log('mRunFunctions()::story-summary::phaseOfLife', phaseOfLife) + action = `ask about another encounter during this phase of life: ${story.phaseOfLife}` + break + default: + action = 'ask about another event in member\'s life' + break + } + const confirmation = { + tool_call_id: id, + output: JSON.stringify({ success: true, action, }), + } + return confirmation + } // error cascades + default: + throw new Error(`Tool function ${name} not recognized`) + } + })) + /* submit tool outputs */ + const finalOutput = await openai.beta.threads.runs.submitToolOutputsAndPoll( // note: must submit all tool outputs at once + run.thread_id, + run.id, + { tool_outputs: toolCallsOutput }, + ) + console.log('mRunFunctions::submitToolOutputs()::run=complete', finalOutput?.status) + return finalOutput /* undefined indicates to ping again */ + } +} /** * Returns all openai `run` objects for `thread`. * @modular @@ -168,16 +253,19 @@ async function mRuns(openai, threadId){ * @async * @param {OpenAI} openai - openai object * @param {object} run - Run id + * @param {AgentFactory} factory - Avatar Factory object to process request * @returns {boolean} - true if run completed, voids otherwise */ -async function mRunStatus(openai, run){ +async function mRunStatus(openai, run, factory){ run = await openai.beta.threads.runs .retrieve( run.thread_id, run.id, ) switch(run.status){ - // https://platform.openai.com/docs/assistants/how-it-works/runs-and-run-steps + case 'requires_action': + const completedRun = await mRunFunctions(openai, run, factory) + return completedRun /* if undefined, will ping again */ case 'completed': return run // run case 'failed': @@ -185,7 +273,6 @@ async function mRunStatus(openai, run){ case 'expired': return false case 'queued': - case 'requires_action': case 'in_progress': case 'cancelling': default: @@ -245,17 +332,18 @@ async function mRunStart(llmServices, assistantId, threadId){ * @param {OpenAI} openai - OpenAI object * @param {string} botId - Bot id * @param {string} threadId - Thread id + * @param {AgentFactory} factory - Avatar Factory object to process request * @returns {void} - All content generated by run is available in `avatar`. */ -async function mRunTrigger(openai, botId, threadId){ +async function mRunTrigger(openai, botId, threadId, factory){ const run = await mRunStart(openai, botId, threadId) if(!run) throw new Error('Run failed to start') // ping status; returns `completed` run - const _run = await mRunFinish(openai, run) + const finishRun = await mRunFinish(openai, run, factory) .then(response=>response) .catch(err=>err) - return _run + return finishRun } /** * Create or retrieve an OpenAI thread. From b11c15c18db49fb0d0886b1fb31d5016c4761b1a Mon Sep 17 00:00:00 2001 From: Erik Jespersen Date: Thu, 2 May 2024 22:21:58 -0400 Subject: [PATCH 04/13] 20240502 @Mookse - bug: no such variable any longer --- views/assets/js/members.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/js/members.mjs b/views/assets/js/members.mjs index d685e6b..29f1f95 100644 --- a/views/assets/js/members.mjs +++ b/views/assets/js/members.mjs @@ -57,7 +57,7 @@ document.addEventListener('DOMContentLoaded', async event=>{ /* determine mode, default = member bot interface */ const initialized = await mInitialize() if(!initialized) - throw new Error('CRITICAL::mInitialize::Error()', success) + throw new Error('CRITICAL::mInitialize::Error()') stageTransition() /* **note**: bots run independently upon conclusion */ }) From 71e00cb92546943d72ff5527927afcd7e8f0bcc1 Mon Sep 17 00:00:00 2001 From: Erik Jespersen Date: Thu, 2 May 2024 22:42:29 -0400 Subject: [PATCH 05/13] 20240502 @Mookse - Biographer Bot uses functions for population --- inc/js/globals.mjs | 84 ++++++++++++++++++++++++-- inc/js/mylife-agent-factory.mjs | 101 +++++++++++++++++++++----------- inc/js/mylife-avatar.mjs | 17 ++++-- 3 files changed, 156 insertions(+), 46 deletions(-) diff --git a/inc/js/globals.mjs b/inc/js/globals.mjs index ff9e884..21986c1 100644 --- a/inc/js/globals.mjs +++ b/inc/js/globals.mjs @@ -1,26 +1,95 @@ // imports import EventEmitter from 'events' import { Guid } from 'js-guid' // usage = Guid.newGuid().toString() -// define global constants -const guid_regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i // regex for GUID validation -const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ // regex for email validation +/* constants */ +const mAiJsFunctions = { + storySummary: { + description: "Generate a STORY summary with keywords and other critical data elements.", + name: "storySummary", + parameters: { + type: "object", + properties: { + keywords: { + description: "Keywords most relevant to STORY.", + items: { + description: "Keyword (single word or short phrase) to be used in STORY summary.", + maxLength: 64, + type: "string" + }, + maxItems: 12, + minItems: 3, + type: "array" + }, + phaseOfLife: { + description: "Phase of life indicated in STORY.", + enum: [ + "birth", + "childhood", + "adolescence", + "teenage", + "young-adult", + "adulthood", + "middle-age", + "senior", + "end-of-life", + "past-life", + "unknown", + "other" + ], + maxLength: 64, + type: "string" + }, + relationships: { + description: "MyLife Biographer Bot does its best to record individuals (or pets) mentioned in this `story`.", + type: "array", + items: { + description: "A name of relational individual/pet to the `story` content.", + type: "string" + }, + maxItems: 24 + }, + summary: { + description: "Generate a STORY summary from input.", + maxLength: 20480, + type: "string" + }, + title: { + description: "Generate display Title of the STORY.", + maxLength: 256, + type: "string" + } + }, + required: [ + "keywords", + "phaseOfLife", + "summary", + "title" + ] + } + } +} +const mEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ // regex for email validation +const mGuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i // regex for GUID validation // modular classes class Globals extends EventEmitter { constructor() { // essentially this is a coordinating class wrapper that holds all of the sensitive data and functionality; as such, it is a singleton, and should either _be_ the virtual server or instantiated on one at startup super() } - // public utility functions + /* public functions */ + getGPTJavascriptFunction(name){ + return this.GPTJavascriptFunctions[name] + } getRegExp(str, isGlobal = false) { if (typeof str !== 'string' || !str.length) throw new Error('Expected a string') return new RegExp(str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), isGlobal ? 'g' : '') } isValidEmail(_email){ - return emailRegex.test(_email) + return mEmailRegex.test(_email) } isValidGuid(_str='') { - return (typeof _str === 'string' && guid_regex.test(_str)) + return (typeof _str === 'string' && mGuidRegex.test(_str)) } stripCosmosFields(_obj){ return Object.fromEntries(Object.entries(_obj).filter(([k, v]) => !k.startsWith('_'))) @@ -43,6 +112,9 @@ class Globals extends EventEmitter { get newGuid(){ // this.newGuid return Guid.newGuid().toString() } + get GPTJavascriptFunctions(){ + return mAiJsFunctions + } } // exports export default Globals \ No newline at end of file diff --git a/inc/js/mylife-agent-factory.mjs b/inc/js/mylife-agent-factory.mjs index 2508d2d..7cf4b74 100644 --- a/inc/js/mylife-agent-factory.mjs +++ b/inc/js/mylife-agent-factory.mjs @@ -1,4 +1,4 @@ -// imports +/* imports */ import { promises as fs } from 'fs' import EventEmitter from 'events' import vm from 'vm' @@ -142,36 +142,40 @@ class BotFactory extends EventEmitter{ /** * Returns bot instruction set. * @public - * @param {string} _type + * @param {string} type - The bot type. + * @returns {object} - The bot instructions. */ - botInstructions(_type){ - if(!_type) throw new Error('bot type required') - if(!mBotInstructions[_type]){ - if(!_instructionSet) throw new Error(`no bot instructions found for ${_type}`) - mBotInstructions[_type] = _instructionSet + botInstructions(botType){ + if(!botType) + throw new Error('bot type required') + console.log(chalk.magenta(`botInstructions::${botType}`), mBotInstructions) + if(!mBotInstructions[botType]){ + if(!botInstructions) + throw new Error(`no bot instructions found for ${ botType }`) + mBotInstructions[botType] = botInstructions } - return mBotInstructions[_type] + return mBotInstructions[botType] } /** * Gets a member's bots. + * @todo - develop bot class and implement hydrated instance * @public - * @param {string} _object_id - The _object_id guid of avatar. - * @param {string} type - The bot type. + * @param {string} object_id - The object_id guid of avatar. + * @param {string} botType - The bot type. * @returns {array} - The member's hydrated bots. */ - async bots(_object_id, type){ - // @todo: develop bot class and implement instance - const _params = _object_id?.length - ? [{ name: '@object_id', value:_object_id }] - : type?.length - ? [{ name: '@bot_type', value: type }] + async bots(object_id, botType){ + const _params = object_id?.length + ? [{ name: '@object_id', value:object_id }] + : botType?.length + ? [{ name: '@bot_type', value: botType }] : undefined - const _bots = await this.dataservices.getItems( + const bots = await this.dataservices.getItems( 'bot', undefined, _params, ) - return _bots + return bots } /** * Get member collection items. @@ -603,12 +607,13 @@ class AgentFactory extends BotFactory{ * @returns {object} - [OpenAI assistant object](https://platform.openai.com/docs/api-reference/assistants/object) */ async function mAI_openai(llmServices, bot){ - const { bot_name, description, model, name, instructions} = bot + const { bot_name, description, model, name, instructions, tools=[], } = bot const assistant = { description, model, - name: bot.bot_name ?? bot.name, // take friendly name before Cosmos + name: bot_name ?? name, // take friendly name before Cosmos instructions, + tools, } return await llmServices.createBot(assistant) } @@ -735,29 +740,32 @@ async function mCreateBotLLM(llm, bot){ * @returns {object} - Bot object */ async function mCreateBot(llm, factory, bot){ + const { bot_name, description: botDescription, instructions: botInstructions, name: botDbName, type, } = bot const { avatarId, } = factory - const _description = bot.description - ?? `I am a ${bot.type} bot for ${factory.memberName}` - const _instructions = bot.instructions + const botName = bot_name + ?? botDbName + ?? type + const description = botDescription + ?? `I am a ${ type } bot for ${ factory.memberName }` + const instructions = botInstructions ?? mCreateBotInstructions(factory, bot) - const botName = bot.bot_name - ?? bot.name - ?? bot.type - const _cosmosName = bot.name - ?? `bot_${bot.type}_${avatarId}` + const name = botDbName + ?? `bot_${ type }_${ avatarId }` + const tools = mGetAIFunctions(type, factory.globals) if(!avatarId) throw new Error('avatar id required to create bot') const botData = { being: 'bot', bot_name: botName, - description: _description, - instructions: _instructions, + description, + instructions, model: process.env.OPENAI_MODEL_CORE_BOT, - name: _cosmosName, + name, object_id: avatarId, provider: 'openai', - purpose: _description, - type: bot.type, + purpose: description, + tools, + type, } botData.bot_id = await mCreateBotLLM(llm, botData) // create after as require model return botData @@ -776,7 +784,8 @@ function mCreateBotInstructions(factory, _bot){ const _type = _bot.type ?? mDefaultBotType let _botInstructionSet = factory.botInstructions(_type) // no need to wait, should be updated or refresh server _botInstructionSet = _botInstructionSet?.instructions - if(!_botInstructionSet) throw new Error(`bot instructions not found for type: ${_type}`) + if(!_botInstructionSet) + throw new Error(`bot instructions not found for type: ${_type}`) /* compile instructions */ let _botInstructions = '' switch(_type){ @@ -967,6 +976,30 @@ function mGenerateClassFromSchema(_schema) { const _class = mCompileClass(name, _classCode) return _class } +/** + * Retrieves any functions that need to be attached to ai. + * @param {string} type - Type of bot. + * @param {object} globals - Global functions for bot. + * @returns {array} - Array of AI functions for bot type. + */ +function mGetAIFunctions(type, globals){ + const functions = [] + switch(type){ + case 'personal-biographer': + const biographerTools = ['storySummary'] + biographerTools.forEach(toolName=>{ + const jsToolDescription = { + type: 'function', + function: globals.getGPTJavascriptFunction(toolName), + } + functions.push(jsToolDescription) + }) + break + default: + break + } + return functions +} /** * Inflates library item with required values and structure. Object structure expected from API, librayItemItem in JSON. * root\inc\json-schemas\bots\library-bot.json diff --git a/inc/js/mylife-avatar.mjs b/inc/js/mylife-avatar.mjs index b087d84..f55fb43 100644 --- a/inc/js/mylife-avatar.mjs +++ b/inc/js/mylife-avatar.mjs @@ -67,23 +67,28 @@ class Avatar extends EventEmitter { }) this.nickname = this.nickname ?? this.names?.[0] ?? `${this.memberFirstName ?? 'member'}'s avatar` /* create evolver (exclude MyLife) */ - // @todo: admin interface for modifying MyLife avatar and their bots this.#bots = await this.#factory.bots(this.id) let activeBot = this.avatarBot if(!this.isMyLife){ - if(!activeBot?.id){ // create: but do not want to call setBot() to activate - activeBot = await mBot(this.#factory, this, { type: 'personal-avatar' }) - this.#bots.unshift(activeBot) - } + /* bot checks */ + const requiredBotTypes = ['library', 'personal-avatar', 'personal-biographer',] + await Promise.all(requiredBotTypes.map(async botType =>{ + if(!this.#bots.some(bot => bot.type === botType)){ + const _bot = await mBot(this.#factory, this, { type: botType }) + this.#bots.push(_bot) + } + })) + activeBot = this.avatarBot // second time is a charm this.activeBotId = activeBot.id this.#llmServices.botId = activeBot.bot_id + /* experience variables */ this.#experienceGenericVariables = mAssignGenericExperienceVariables(this.#experienceGenericVariables, this) + /* evolver */ this.#evolver = new EvolutionAssistant(this) mAssignEvolverListeners(this.#factory, this.#evolver, this) /* init evolver */ await this.#evolver.init() } else { // Q-specific, leave as `else` as is near always false - // @todo - something doesn't smell right in how session would handle conversations - investigate logic; fine if new Avatar instance is spawned for each session, which might be true this.activeBotId = activeBot.id activeBot.bot_id = mBotIdOverride ?? activeBot.bot_id this.#llmServices.botId = activeBot.bot_id From 59344b862d0e765324c55bdf80d7b652bf6cc3ff Mon Sep 17 00:00:00 2001 From: Erik Jespersen <42016062+Mookse@users.noreply.github.com> Date: Fri, 3 May 2024 10:44:27 -0400 Subject: [PATCH 06/13] Biographer Updates `storySummary` Functionality (#188) * 20240501 @Mookse - GPT JSON and YAML definitions for storySummary functionality * 20240502 @Mookse - MyLife Biographer Bot performs storySummary function * 20240502 @Mookse - bug: no such variable any longer * 20240502 @Mookse - Biographer Bot uses functions for population --- .../class-extenders.mjs | 2 +- inc/js/functions.mjs | 10 +- inc/js/globals.mjs | 84 ++++++++- inc/js/mylife-agent-factory.mjs | 163 ++++++++++++------ inc/js/mylife-avatar.mjs | 26 +-- inc/js/mylife-data-service.js | 8 +- inc/js/mylife-llm-services.mjs | 112 ++++++++++-- .../openai/functions/storySummary.json | 64 +++++++ inc/yaml/mylife_biographer-bot_openai.yaml | 56 +++++- views/assets/js/members.mjs | 2 +- 10 files changed, 425 insertions(+), 102 deletions(-) create mode 100644 inc/json-schemas/openai/functions/storySummary.json diff --git a/inc/js/factory-class-extenders/class-extenders.mjs b/inc/js/factory-class-extenders/class-extenders.mjs index bc64b84..89eb089 100644 --- a/inc/js/factory-class-extenders/class-extenders.mjs +++ b/inc/js/factory-class-extenders/class-extenders.mjs @@ -405,7 +405,7 @@ function extendClass_message(originClass, referencesObject) { const { content, ..._obj } = obj super(_obj) try{ - this.#content = mAssignContent(content??obj) + this.#content = mAssignContent(content ?? obj) } catch(e){ console.log('Message::constructor::ERROR', e) this.#content = '' diff --git a/inc/js/functions.mjs b/inc/js/functions.mjs index 13fb983..013d275 100644 --- a/inc/js/functions.mjs +++ b/inc/js/functions.mjs @@ -72,11 +72,11 @@ async function challenge(ctx){ async function chat(ctx){ ctx.state.chatMessage = ctx.request.body ctx.state.thread = ctx.state.MemberSession.thread - const _message = ctx.request?.body?.message??false /* body has all the nodes sent by fe */ - if(!_message) ctx.throw(400, `invalid message: missing \`message\``) // currently only accepts single contributions via post with :cid - if(!_message?.length) ctx.throw(400, `empty message content`) - const _response = await ctx.state.avatar.chatRequest(ctx) - ctx.body = _response // return message_member_chat + const message = ctx.request.body?.message ?? false /* body has all the nodes sent by fe */ + if(!message?.length) + ctx.throw(400, 'missing `message` content') + const response = await ctx.state.avatar.chatRequest(ctx) + ctx.body = response } async function collections(ctx){ ctx.body = await ctx.state.avatar.collections(ctx.params.type) diff --git a/inc/js/globals.mjs b/inc/js/globals.mjs index ff9e884..21986c1 100644 --- a/inc/js/globals.mjs +++ b/inc/js/globals.mjs @@ -1,26 +1,95 @@ // imports import EventEmitter from 'events' import { Guid } from 'js-guid' // usage = Guid.newGuid().toString() -// define global constants -const guid_regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i // regex for GUID validation -const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ // regex for email validation +/* constants */ +const mAiJsFunctions = { + storySummary: { + description: "Generate a STORY summary with keywords and other critical data elements.", + name: "storySummary", + parameters: { + type: "object", + properties: { + keywords: { + description: "Keywords most relevant to STORY.", + items: { + description: "Keyword (single word or short phrase) to be used in STORY summary.", + maxLength: 64, + type: "string" + }, + maxItems: 12, + minItems: 3, + type: "array" + }, + phaseOfLife: { + description: "Phase of life indicated in STORY.", + enum: [ + "birth", + "childhood", + "adolescence", + "teenage", + "young-adult", + "adulthood", + "middle-age", + "senior", + "end-of-life", + "past-life", + "unknown", + "other" + ], + maxLength: 64, + type: "string" + }, + relationships: { + description: "MyLife Biographer Bot does its best to record individuals (or pets) mentioned in this `story`.", + type: "array", + items: { + description: "A name of relational individual/pet to the `story` content.", + type: "string" + }, + maxItems: 24 + }, + summary: { + description: "Generate a STORY summary from input.", + maxLength: 20480, + type: "string" + }, + title: { + description: "Generate display Title of the STORY.", + maxLength: 256, + type: "string" + } + }, + required: [ + "keywords", + "phaseOfLife", + "summary", + "title" + ] + } + } +} +const mEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ // regex for email validation +const mGuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i // regex for GUID validation // modular classes class Globals extends EventEmitter { constructor() { // essentially this is a coordinating class wrapper that holds all of the sensitive data and functionality; as such, it is a singleton, and should either _be_ the virtual server or instantiated on one at startup super() } - // public utility functions + /* public functions */ + getGPTJavascriptFunction(name){ + return this.GPTJavascriptFunctions[name] + } getRegExp(str, isGlobal = false) { if (typeof str !== 'string' || !str.length) throw new Error('Expected a string') return new RegExp(str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), isGlobal ? 'g' : '') } isValidEmail(_email){ - return emailRegex.test(_email) + return mEmailRegex.test(_email) } isValidGuid(_str='') { - return (typeof _str === 'string' && guid_regex.test(_str)) + return (typeof _str === 'string' && mGuidRegex.test(_str)) } stripCosmosFields(_obj){ return Object.fromEntries(Object.entries(_obj).filter(([k, v]) => !k.startsWith('_'))) @@ -43,6 +112,9 @@ class Globals extends EventEmitter { get newGuid(){ // this.newGuid return Guid.newGuid().toString() } + get GPTJavascriptFunctions(){ + return mAiJsFunctions + } } // exports export default Globals \ No newline at end of file diff --git a/inc/js/mylife-agent-factory.mjs b/inc/js/mylife-agent-factory.mjs index 475912a..7cf4b74 100644 --- a/inc/js/mylife-agent-factory.mjs +++ b/inc/js/mylife-agent-factory.mjs @@ -1,4 +1,4 @@ -// imports +/* imports */ import { promises as fs } from 'fs' import EventEmitter from 'events' import vm from 'vm' @@ -142,36 +142,40 @@ class BotFactory extends EventEmitter{ /** * Returns bot instruction set. * @public - * @param {string} _type + * @param {string} type - The bot type. + * @returns {object} - The bot instructions. */ - botInstructions(_type){ - if(!_type) throw new Error('bot type required') - if(!mBotInstructions[_type]){ - if(!_instructionSet) throw new Error(`no bot instructions found for ${_type}`) - mBotInstructions[_type] = _instructionSet + botInstructions(botType){ + if(!botType) + throw new Error('bot type required') + console.log(chalk.magenta(`botInstructions::${botType}`), mBotInstructions) + if(!mBotInstructions[botType]){ + if(!botInstructions) + throw new Error(`no bot instructions found for ${ botType }`) + mBotInstructions[botType] = botInstructions } - return mBotInstructions[_type] + return mBotInstructions[botType] } /** * Gets a member's bots. + * @todo - develop bot class and implement hydrated instance * @public - * @param {string} _object_id - The _object_id guid of avatar. - * @param {string} type - The bot type. + * @param {string} object_id - The object_id guid of avatar. + * @param {string} botType - The bot type. * @returns {array} - The member's hydrated bots. */ - async bots(_object_id, type){ - // @todo: develop bot class and implement instance - const _params = _object_id?.length - ? [{ name: '@object_id', value:_object_id }] - : type?.length - ? [{ name: '@bot_type', value: type }] + async bots(object_id, botType){ + const _params = object_id?.length + ? [{ name: '@object_id', value:object_id }] + : botType?.length + ? [{ name: '@bot_type', value: botType }] : undefined - const _bots = await this.dataservices.getItems( + const bots = await this.dataservices.getItems( 'bot', undefined, _params, ) - return _bots + return bots } /** * Get member collection items. @@ -250,40 +254,40 @@ class BotFactory extends EventEmitter{ * @returns {object} - The library. */ async library(_library){ - const _updatedLibrary = await mLibrary(this, _library) + const updatedLibrary = await mLibrary(this, _library) // test the type/form of Library - switch(_updatedLibrary.type){ + switch(updatedLibrary.type){ case 'story': - if(_updatedLibrary.form==='biographer'){ + if(updatedLibrary.form==='biographer'){ // inflate and update library with stories - const _stories = ( await this.stories(_updatedLibrary.form) ) - .filter(_story=>!this.globals.isValidGuid(_story?.library_id)) - .map(_story=>{ - _story.id = _story.id??this.newGuid - _story.author = _story.author??this.mbr_name - _story.title = _story.title??_story.id - return mInflateLibraryItem(_story, _updatedLibrary.id, this.mbr_id) + const stories = ( await this.stories(updatedLibrary.form) ) + .filter(story=>!this.globals.isValidGuid(story?.library_id)) + .map(story=>{ + story.id = story.id ?? this.newGuid + story.author = story.author ?? this.mbr_name + story.title = story.title ?? story.id + return mInflateLibraryItem(story, updatedLibrary.id, this.mbr_id) }) - _updatedLibrary.items = [ - ..._updatedLibrary.items, - ..._stories, + updatedLibrary.items = [ + ...updatedLibrary.items, + ...stories, ] /* update stories (no await) */ - _stories.forEach(_story=>this.dataservices.patch( - _story.id, - { library_id: _updatedLibrary.id }, + stories.forEach(story=>this.dataservices.patch( + story.id, + { library_id: updatedLibrary.id }, )) /* update library (no await) */ this.dataservices.patch( - _updatedLibrary.id, - { items: _updatedLibrary.items }, + updatedLibrary.id, + { items: updatedLibrary.items }, ) } break default: break } - return _updatedLibrary + return updatedLibrary } /** * Allows member to reset passphrase. @@ -514,11 +518,29 @@ class AgentFactory extends BotFactory{ } /** * Submits a story to MyLife. Currently via API, but could be also work internally. - * @param {object} _story - Story object { assistantType, being, form, id, mbr_id, name, summary }. + * @param {object} story - Story object. * @returns {object} - The story document from Cosmos. */ - async story(_story){ - return await this.dataservices.story(_story) + async story(story){ + if(!story.summary?.length) + throw new Error('story summary required') + const id = this.newGuid + const title = story.title ?? 'New Memory Entry' + const finalStory = { + ...story, + ...{ + assistantType: story.assistantType ?? 'biographer-bot', + being: story.being ?? 'story', + form: story.form ?? 'biographer', + id, + keywords: story.keywords ?? ['memory', 'biographer', 'entry'], + mbr_id: this.mbr_id, + name: story.name ?? title ?? `story_${ this.mbr_id }_${ id }`, + phaseOfLife: story.phaseOfLife ?? 'unknown', + summary: story.summary, + title, + }} + return await this.dataservices.story(finalStory) } /** * Tests partition key for member @@ -585,12 +607,13 @@ class AgentFactory extends BotFactory{ * @returns {object} - [OpenAI assistant object](https://platform.openai.com/docs/api-reference/assistants/object) */ async function mAI_openai(llmServices, bot){ - const { bot_name, description, model, name, instructions} = bot + const { bot_name, description, model, name, instructions, tools=[], } = bot const assistant = { description, model, - name: bot.bot_name ?? bot.name, // take friendly name before Cosmos + name: bot_name ?? name, // take friendly name before Cosmos instructions, + tools, } return await llmServices.createBot(assistant) } @@ -717,29 +740,32 @@ async function mCreateBotLLM(llm, bot){ * @returns {object} - Bot object */ async function mCreateBot(llm, factory, bot){ + const { bot_name, description: botDescription, instructions: botInstructions, name: botDbName, type, } = bot const { avatarId, } = factory - const _description = bot.description - ?? `I am a ${bot.type} bot for ${factory.memberName}` - const _instructions = bot.instructions + const botName = bot_name + ?? botDbName + ?? type + const description = botDescription + ?? `I am a ${ type } bot for ${ factory.memberName }` + const instructions = botInstructions ?? mCreateBotInstructions(factory, bot) - const botName = bot.bot_name - ?? bot.name - ?? bot.type - const _cosmosName = bot.name - ?? `bot_${bot.type}_${avatarId}` + const name = botDbName + ?? `bot_${ type }_${ avatarId }` + const tools = mGetAIFunctions(type, factory.globals) if(!avatarId) throw new Error('avatar id required to create bot') const botData = { being: 'bot', bot_name: botName, - description: _description, - instructions: _instructions, + description, + instructions, model: process.env.OPENAI_MODEL_CORE_BOT, - name: _cosmosName, + name, object_id: avatarId, provider: 'openai', - purpose: _description, - type: bot.type, + purpose: description, + tools, + type, } botData.bot_id = await mCreateBotLLM(llm, botData) // create after as require model return botData @@ -758,7 +784,8 @@ function mCreateBotInstructions(factory, _bot){ const _type = _bot.type ?? mDefaultBotType let _botInstructionSet = factory.botInstructions(_type) // no need to wait, should be updated or refresh server _botInstructionSet = _botInstructionSet?.instructions - if(!_botInstructionSet) throw new Error(`bot instructions not found for type: ${_type}`) + if(!_botInstructionSet) + throw new Error(`bot instructions not found for type: ${_type}`) /* compile instructions */ let _botInstructions = '' switch(_type){ @@ -949,6 +976,30 @@ function mGenerateClassFromSchema(_schema) { const _class = mCompileClass(name, _classCode) return _class } +/** + * Retrieves any functions that need to be attached to ai. + * @param {string} type - Type of bot. + * @param {object} globals - Global functions for bot. + * @returns {array} - Array of AI functions for bot type. + */ +function mGetAIFunctions(type, globals){ + const functions = [] + switch(type){ + case 'personal-biographer': + const biographerTools = ['storySummary'] + biographerTools.forEach(toolName=>{ + const jsToolDescription = { + type: 'function', + function: globals.getGPTJavascriptFunction(toolName), + } + functions.push(jsToolDescription) + }) + break + default: + break + } + return functions +} /** * Inflates library item with required values and structure. Object structure expected from API, librayItemItem in JSON. * root\inc\json-schemas\bots\library-bot.json diff --git a/inc/js/mylife-avatar.mjs b/inc/js/mylife-avatar.mjs index 94f9f02..f55fb43 100644 --- a/inc/js/mylife-avatar.mjs +++ b/inc/js/mylife-avatar.mjs @@ -67,23 +67,28 @@ class Avatar extends EventEmitter { }) this.nickname = this.nickname ?? this.names?.[0] ?? `${this.memberFirstName ?? 'member'}'s avatar` /* create evolver (exclude MyLife) */ - // @todo: admin interface for modifying MyLife avatar and their bots this.#bots = await this.#factory.bots(this.id) let activeBot = this.avatarBot if(!this.isMyLife){ - if(!activeBot?.id){ // create: but do not want to call setBot() to activate - activeBot = await mBot(this.#factory, this, { type: 'personal-avatar' }) - this.#bots.unshift(activeBot) - } + /* bot checks */ + const requiredBotTypes = ['library', 'personal-avatar', 'personal-biographer',] + await Promise.all(requiredBotTypes.map(async botType =>{ + if(!this.#bots.some(bot => bot.type === botType)){ + const _bot = await mBot(this.#factory, this, { type: botType }) + this.#bots.push(_bot) + } + })) + activeBot = this.avatarBot // second time is a charm this.activeBotId = activeBot.id this.#llmServices.botId = activeBot.bot_id + /* experience variables */ this.#experienceGenericVariables = mAssignGenericExperienceVariables(this.#experienceGenericVariables, this) + /* evolver */ this.#evolver = new EvolutionAssistant(this) mAssignEvolverListeners(this.#factory, this.#evolver, this) /* init evolver */ await this.#evolver.init() } else { // Q-specific, leave as `else` as is near always false - // @todo - something doesn't smell right in how session would handle conversations - investigate logic; fine if new Avatar instance is spawned for each session, which might be true this.activeBotId = activeBot.id activeBot.bot_id = mBotIdOverride ?? activeBot.bot_id this.#llmServices.botId = activeBot.bot_id @@ -121,12 +126,12 @@ class Avatar extends EventEmitter { if(!conversation) conversation = await this.createConversation('chat') conversation.botId = this.activeBot.bot_id // pass in via quickly mutating conversation (or independently if preferred in end), versus llmServices which are global - const messages = await mCallLLM(this.#llmServices, conversation, prompt) + const messages = await mCallLLM(this.#llmServices, conversation, prompt, this.factory) conversation.addMessages(messages) if(mAllowSave) conversation.save() else - console.log('chatRequest::BYPASS-SAVE', conversation.message) + console.log('chatRequest::BYPASS-SAVE', conversation.message.content) /* frontend mutations */ const { activeBot: bot } = this // current fe will loop through messages in reverse chronological order @@ -821,15 +826,16 @@ async function mBot(factory, avatar, bot){ * @param {LLMServices} llmServices - OpenAI object currently * @param {Conversation} conversation - Conversation object * @param {string} prompt - dialog-prompt/message for llm + * @param {AgentFactory} factory - Agent Factory object required for function execution * @returns {Promise} - Array of Message instances in descending chronological order. */ -async function mCallLLM(llmServices, conversation, prompt){ +async function mCallLLM(llmServices, conversation, prompt, factory){ const { thread_id: threadId } = conversation if(!threadId) throw new Error('No `thread_id` found for conversation') if(!conversation.botId) throw new Error('No `botId` found for conversation') - const messages = await llmServices.getLLMResponse(threadId, conversation.botId, prompt) + const messages = await llmServices.getLLMResponse(threadId, conversation.botId, prompt, factory) messages.sort((mA, mB) => { return mB.created_at - mA.created_at }) diff --git a/inc/js/mylife-data-service.js b/inc/js/mylife-data-service.js index 694b5f3..77698c5 100644 --- a/inc/js/mylife-data-service.js +++ b/inc/js/mylife-data-service.js @@ -621,12 +621,12 @@ class Dataservices { } /** * Submits a story to MyLife. Currently via API, but could be also work internally. - * @param {object} _story - Story object { assistantType, being, form, id, mbr_id, name, summary }. + * @param {object} story - Story object. * @returns {object} - The story document from Cosmos. */ - async story(_story){ - if(!this.isMyLife) return - return await this.datamanager.pushItem(_story) + async story(story){ + const storyItem = await this.datamanager.pushItem(story) + return storyItem } /** * Tests partition key for member diff --git a/inc/js/mylife-llm-services.mjs b/inc/js/mylife-llm-services.mjs index 125c166..cda0d03 100644 --- a/inc/js/mylife-llm-services.mjs +++ b/inc/js/mylife-llm-services.mjs @@ -4,6 +4,17 @@ const { OPENAI_API_KEY: mOpenaiKey, OPENAI_BASE_URL: mBasePath, OPENAI_ORG_KEY: const mTimeoutMs = parseInt(OPENAI_API_CHAT_TIMEOUT) || 55000 const mPingIntervalMs = parseInt(OPENAI_API_CHAT_RESPONSE_PING_INTERVAL) || 890 /* class definition */ +/** + * LLM Services class. + * @todo - rather than passing factory in run, pass avatar + * @todo - convert run to streaming as defined in @documentation + * @class + * @classdesc LLM Services class. + * @documentation [OpenAI API Reference: Assistant Function Calling](https://platform.openai.com/docs/assistants/tools/function-calling/quickstart) + * @param {string} apiKey - openai api key + * @param {string} organizationKey - openai organization key + * @returns {LLMServices} - LLM Services object + */ class LLMServices { #llmProviders = [] /** @@ -34,11 +45,12 @@ class LLMServices { * @param {string} threadId - Thread id. * @param {string} botId - GPT-Assistant/Bot id. * @param {string} prompt - Member input. + * @param {AgentFactory} factory - Avatar Factory object to process request. * @returns {Promise} - Array of openai `message` objects. */ - async getLLMResponse(threadId, botId, prompt){ + async getLLMResponse(threadId, botId, prompt, factory){ await mAssignRequestToThread(this.openai, threadId, prompt) - const run = await mRunTrigger(this.openai, botId, threadId) + const run = await mRunTrigger(this.openai, botId, threadId, factory) const { assistant_id, id: run_id, model, provider='openai', required_action, status, usage } = run const llmMessageObject = await mMessages(this.provider, threadId) const { data: llmMessages} = llmMessageObject @@ -127,16 +139,18 @@ async function mMessages(openai, threadId){ * @async * @param {OpenAI} openai - openai object * @param {object} run - [OpenAI run object](https://platform.openai.com/docs/api-reference/runs/object) + * @param {AgentFactory} factory - Avatar Factory object to process request * @returns {object} - [OpenAI run object](https://platform.openai.com/docs/api-reference/runs/object) */ -async function mRunFinish(llmServices, run){ +async function mRunFinish(llmServices, run, factory){ return new Promise((resolve, reject) => { const checkInterval = setInterval(async () => { try { - const _run = await mRunStatus(llmServices, run) - if( _run?.status ?? _run ?? false){ + const functionRun = await mRunStatus(llmServices, run, factory) + console.log('mRunFinish::functionRun()', functionRun?.status) + if(functionRun?.status ?? functionRun ?? false){ clearInterval(checkInterval) - resolve(_run) + resolve(functionRun) } } catch (error) { clearInterval(checkInterval) @@ -150,6 +164,77 @@ async function mRunFinish(llmServices, run){ }, mTimeoutMs) }) } +/** + * Executes openAI run functions. See https://platform.openai.com/docs/assistants/tools/function-calling/quickstart. + * @todo - storysummary output action requires integration with factory/avatar data intersecting with story submission + * @modular + * @private + * @async + * @param {object} run - [OpenAI run object](https://platform.openai.com/docs/api-reference/runs/object) + * @param {AgentFactory} factory - Avatar Factory object to process request + * @returns {object} - [OpenAI run object](https://platform.openai.com/docs/api-reference/runs/object) + * @throws {Error} - If tool function not recognized + */ +async function mRunFunctions(openai, run, factory){ + if( + run.required_action?.type=='submit_tool_outputs' + && run.required_action?.submit_tool_outputs?.tool_calls + && run.required_action.submit_tool_outputs.tool_calls.length + ){ + const toolCallsOutput = await Promise.all( + run.required_action.submit_tool_outputs.tool_calls + .map(async tool=>{ + const { id, function: toolFunction, type, } = tool + let { arguments: toolArguments, name, } = toolFunction + switch(name.toLowerCase()){ + case 'story': // storySummary.json + case 'storysummary': + case 'story-summary': + case 'story_summary': + case 'story summary': + if(typeof toolArguments == 'string') + toolArguments = JSON.parse(toolArguments) + const story = await factory.story(toolArguments) + if(story){ + const { keywords, phaseOfLife='unknown', } = story + let { interests, updates, } = factory.core + // @stub - action integrates with story and interests/phase + let action + switch(true){ + case interests: + console.log('mRunFunctions()::story-summary::interests', interests) + if(typeof interests == 'array') + interests = interests.join(',') + action = `ask about a different interest from: ${ interests }` + break + case phaseOfLife!=='unknown': + console.log('mRunFunctions()::story-summary::phaseOfLife', phaseOfLife) + action = `ask about another encounter during this phase of life: ${story.phaseOfLife}` + break + default: + action = 'ask about another event in member\'s life' + break + } + const confirmation = { + tool_call_id: id, + output: JSON.stringify({ success: true, action, }), + } + return confirmation + } // error cascades + default: + throw new Error(`Tool function ${name} not recognized`) + } + })) + /* submit tool outputs */ + const finalOutput = await openai.beta.threads.runs.submitToolOutputsAndPoll( // note: must submit all tool outputs at once + run.thread_id, + run.id, + { tool_outputs: toolCallsOutput }, + ) + console.log('mRunFunctions::submitToolOutputs()::run=complete', finalOutput?.status) + return finalOutput /* undefined indicates to ping again */ + } +} /** * Returns all openai `run` objects for `thread`. * @modular @@ -168,16 +253,19 @@ async function mRuns(openai, threadId){ * @async * @param {OpenAI} openai - openai object * @param {object} run - Run id + * @param {AgentFactory} factory - Avatar Factory object to process request * @returns {boolean} - true if run completed, voids otherwise */ -async function mRunStatus(openai, run){ +async function mRunStatus(openai, run, factory){ run = await openai.beta.threads.runs .retrieve( run.thread_id, run.id, ) switch(run.status){ - // https://platform.openai.com/docs/assistants/how-it-works/runs-and-run-steps + case 'requires_action': + const completedRun = await mRunFunctions(openai, run, factory) + return completedRun /* if undefined, will ping again */ case 'completed': return run // run case 'failed': @@ -185,7 +273,6 @@ async function mRunStatus(openai, run){ case 'expired': return false case 'queued': - case 'requires_action': case 'in_progress': case 'cancelling': default: @@ -245,17 +332,18 @@ async function mRunStart(llmServices, assistantId, threadId){ * @param {OpenAI} openai - OpenAI object * @param {string} botId - Bot id * @param {string} threadId - Thread id + * @param {AgentFactory} factory - Avatar Factory object to process request * @returns {void} - All content generated by run is available in `avatar`. */ -async function mRunTrigger(openai, botId, threadId){ +async function mRunTrigger(openai, botId, threadId, factory){ const run = await mRunStart(openai, botId, threadId) if(!run) throw new Error('Run failed to start') // ping status; returns `completed` run - const _run = await mRunFinish(openai, run) + const finishRun = await mRunFinish(openai, run, factory) .then(response=>response) .catch(err=>err) - return _run + return finishRun } /** * Create or retrieve an OpenAI thread. diff --git a/inc/json-schemas/openai/functions/storySummary.json b/inc/json-schemas/openai/functions/storySummary.json new file mode 100644 index 0000000..23662b0 --- /dev/null +++ b/inc/json-schemas/openai/functions/storySummary.json @@ -0,0 +1,64 @@ +{ + "description": "Generate a STORY summary with keywords and other critical data elements.", + "name": "storySummary", + "parameters": { + "type": "object", + "properties": { + "keywords": { + "description": "Keywords most relevant to STORY.", + "items": { + "description": "Keyword (single word or short phrase) to be used in STORY summary.", + "maxLength": 64, + "type": "string" + }, + "maxItems": 12, + "minItems": 3, + "type": "array" + }, + "phaseOfLife": { + "description": "Phase of life indicated in STORY.", + "enum": [ + "birth", + "childhood", + "adolescence", + "teenage", + "young-adult", + "adulthood", + "middle-age", + "senior", + "end-of-life", + "past-life", + "unknown", + "other" + ], + "maxLength": 64, + "type": "string" + }, + "relationships": { + "description": "MyLife Biographer Bot does its best to record individuals (or pets) mentioned in this `story`.", + "type": "array", + "items": { + "description": "A name of relational individual/pet to the `story` content.", + "type": "string" + }, + "maxItems": 24 + }, + "summary": { + "description": "Generate a STORY summary from input.", + "maxLength": 20480, + "type": "string" + }, + "title": { + "description": "Generate display Title of the STORY.", + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "keywords", + "phaseOfLife", + "summary", + "title" + ] + } +} \ No newline at end of file diff --git a/inc/yaml/mylife_biographer-bot_openai.yaml b/inc/yaml/mylife_biographer-bot_openai.yaml index 0e3fc26..d899791 100644 --- a/inc/yaml/mylife_biographer-bot_openai.yaml +++ b/inc/yaml/mylife_biographer-bot_openai.yaml @@ -1,8 +1,11 @@ openapi: 3.0.0 info: title: MyLife GPT Webhook Receiver API - description: This API is for receiving webhooks from [MyLife's public Biographer Bot instance](https://chat.openai.com/g/g-QGzfgKj6I-mylife-biographer-bot). - version: 1.0.0 + description: | + This API is for receiving webhooks from [MyLife's public Biographer Bot instance](https://chat.openai.com/g/g-QGzfgKj6I-mylife-biographer-bot). + ## Updates + - added: keywords, phaseOfLife, relationships to bot package + version: 1.0.1 servers: - url: https://humanremembranceproject.org/api/v1 description: Endpoint for receiving stories from the MyLife Biographer Bot instance. @@ -80,15 +83,54 @@ paths: content: application/json: schema: - description: The `story` data sent by MyLife Biographer BOt. + description: The `story` data sent by MyLife Biographer Bot. type: object required: - - storySummary + - keywords + - phaseOfLife + - summary + - title properties: - storySummary: + keywords: + description: MyLife Biographer Bot interprets (or asks) keywords for this `story`. Should not include relations or names, but would tag content dynamically. Each should be a single word or short phrase. + items: + description: A single word or short phrase to tag `story` content. + maxLength: 64 + type: string + maxItems: 12 + minItems: 3 + type: array + phaseOfLife: + description: MyLife Biographer Bot interprets (or asks) phase of life for this `story`. Can be `unkown`. + enum: + - birth + - childhood + - adolescence + - teenage + - young-adult + - adulthood + - middle-age + - senior + - end-of-life + - past-life + - unknown + - other + type: string + relationships: + description: MyLife Biographer Bot does its best to record individuals (or pets) mentioned in this `story`. + items: + description: A name of relational individual/pet to the `story` content. + type: string + maxItems: 24 + type: array + summary: description: MyLife Biographer Bot summary of identified `story`. maxLength: 20480 type: string + title: + description: MyLife Biographer Bot determines title for this `story`. + maxLength: 256 + type: string responses: "200": description: Story submitted successfully. @@ -105,7 +147,7 @@ responses: type: string example: Story submitted successfully. "400": - description: No story summary provided. Use `storySummary` field. + description: No story summary provided. Use `summary` field. content: application/json: schema: @@ -117,4 +159,4 @@ responses: example: false message: type: string - example: No story summary provided. Use `storySummary` field. \ No newline at end of file + example: No story summary provided. Use `summary` field. \ No newline at end of file diff --git a/views/assets/js/members.mjs b/views/assets/js/members.mjs index d685e6b..29f1f95 100644 --- a/views/assets/js/members.mjs +++ b/views/assets/js/members.mjs @@ -57,7 +57,7 @@ document.addEventListener('DOMContentLoaded', async event=>{ /* determine mode, default = member bot interface */ const initialized = await mInitialize() if(!initialized) - throw new Error('CRITICAL::mInitialize::Error()', success) + throw new Error('CRITICAL::mInitialize::Error()') stageTransition() /* **note**: bots run independently upon conclusion */ }) From 8ba0af6f446a75ff0c3e1c24a556d125363c45d3 Mon Sep 17 00:00:00 2001 From: Erik Jespersen <42016062+Mookse@users.noreply.github.com> Date: Mon, 6 May 2024 16:14:57 -0400 Subject: [PATCH 07/13] MERGE 189 experience collection (#190) * 20240503 @Mookse - allow recycle button for experience collection to function * 20240503 @Mookse - string.includes() * 20240503 @Mookse - collections open and close * 20240504 @Mookse - rudimentary help container * 20240504 @Mookse - trigger welcome experience from help - note: members-only * 20240504 @Mookse - `experience` trigger fixes * 20240504 @Mookse - cosmetic * 20240504 @Mookse - start Experience, non-autoplay * 20240506 @Mookse - experienceEnd() [pipeline - saveExperience() * 20240506 @Mookse - `experiencesLived` pipeline * 20240505 @Mookse - backend adjustments * 20240506 @Mookse - experience collection item displays object JSON --- inc/js/api-functions.mjs | 83 +++++------------------- inc/js/mylife-agent-factory.mjs | 69 +++++++++++++++++--- inc/js/mylife-avatar.mjs | 84 ++++++++++++++++++------ inc/js/mylife-data-service.js | 18 ++++-- inc/js/routes.mjs | 3 + inc/js/session.mjs | 99 ++++++++++++++++++++++++++--- views/assets/css/bot-bar.css | 2 +- views/assets/css/main.css | 16 ++++- views/assets/html/_navbar.html | 10 ++- views/assets/html/_widget-bots.html | 3 + views/assets/js/bots.mjs | 26 +++++++- views/assets/js/experience.mjs | 10 ++- views/assets/js/globals.mjs | 30 +++++++++ views/assets/js/members.mjs | 37 ++++++++++- 14 files changed, 371 insertions(+), 119 deletions(-) diff --git a/inc/js/api-functions.mjs b/inc/js/api-functions.mjs index be0242f..89dcae3 100644 --- a/inc/js/api-functions.mjs +++ b/inc/js/api-functions.mjs @@ -36,48 +36,10 @@ function experienceCast(ctx){ * @property {object} scene - Scene data, regardless if "current" or new. */ async function experience(ctx){ - /* reject if experience is locked */ - /* lock could extend to gating requests as well */ - if(ctx.state.MemberSession.experienceLock){ - // @stub - if experience is locked, perhaps request to run an internal check to see if exp is bugged or timed out - ctx.throw(500, 'Experience is locked. Wait for previous event to complete. If bugged, end experience and begin again.') - } mAPIKeyValidation(ctx) - const { assistantType, avatar, mbr_id, } = ctx.state + const { MemberSession, } = ctx.state const { eid, } = ctx.params - let events = [] - ctx.state.MemberSession.experienceLock = true - try{ // requires try, as locks would otherwise not release on unidentified errors - if(!avatar.isInExperience){ - await avatar.experienceStart(eid) - } - else { - const eventSequence = await avatar.experiencePlay(eid, ctx.request.body) - events = eventSequence - console.log(chalk.yellowBright('experience() events'), events?.length) - } - } catch (error){ - console.log(chalk.redBright('experience() error'), error, avatar.experience) - const { experience } = avatar - if(experience){ // embed error in experience - experience.errors = experience.errors ?? [] - experience.errors.push(error) - } - } - const { experience } = avatar - const { autoplay, location, title, } = experience - ctx.body = { - autoplay, - events, - location, - title, - } - ctx.state.MemberSession.experienceLock = false - if(events.find(event=>{ return event.action==='end' && event.type==='experience' })){ - if(!avatar.experienceEnd(eid)) // attempt to end experience - throw new Error('Experience failed to end.') - } - return + ctx.body = await MemberSession.experience(eid, ctx.request.body) } /** * Request to end an active Living-Experience for member. @@ -87,19 +49,9 @@ async function experience(ctx){ */ function experienceEnd(ctx){ mAPIKeyValidation(ctx) - const { assistantType, avatar, mbr_id } = ctx.state - const { eid } = ctx.params - let endSuccess = false - try { - endSuccess = avatar.experienceEnd(eid) - } catch(err) { - console.log(chalk.redBright('experienceEnd() error'), err) - // can determine if error is critical or not, currently implies there is no running experience - endSuccess = true - } - ctx.body = endSuccess - ctx.state.MemberSession.experienceLock = !ctx.body - return + const { MemberSession, } = ctx.state + const { eid, } = ctx.params + ctx.body = MemberSession.experienceEnd(eid) } /** * Delivers the manifest of an experience. Manifests are the data structures that define the experience, including scenes, events, and other data. Experience must be "started" in order to request. @@ -111,7 +63,7 @@ function experienceEnd(ctx){ */ function experienceManifest(ctx){ mAPIKeyValidation(ctx) - const { assistantType, avatar, mbr_id } = ctx.state + const { avatar, } = ctx.state ctx.body = avatar.manifest return } @@ -120,7 +72,7 @@ function experienceManifest(ctx){ */ function experienceNavigation(ctx){ mAPIKeyValidation(ctx) - const { assistantType, avatar, mbr_id } = ctx.state + const { avatar, } = ctx.state ctx.body = avatar.navigation return } @@ -133,11 +85,15 @@ function experienceNavigation(ctx){ */ async function experiences(ctx){ mAPIKeyValidation(ctx) - const { assistantType, MemberSession } = ctx.state + const { MemberSession, } = ctx.state // limit one mandatory experience (others could be highlighted in alerts) per session const experiencesObject = await MemberSession.experiences() ctx.body = experiencesObject - return +} +async function experiencesLived(ctx){ + mAPIKeyValidation(ctx) + const { MemberSession, } = ctx.state + ctx.body = MemberSession.experiencesLived } async function keyValidation(ctx){ // from openAI mAPIKeyValidation(ctx) @@ -166,7 +122,6 @@ async function keyValidation(ctx){ // from openAI data: _memberCoreData, } console.log(chalk.yellowBright(`keyValidation():${_memberCoreData.mbr_id}`), _memberCoreData.fullName) - return } /** * All functionality related to a library. Note: Had to be consolidated, as openai GPT would only POST. @@ -191,7 +146,6 @@ async function library(ctx){ message: `library function(s) completed successfully.`, success: true, } - return } /** * Login function for member. Requires mid in params. @@ -206,7 +160,6 @@ async function login(ctx){ ctx.throw(400, `missing member id`) // currently only accepts single contributions via post with :cid ctx.session.MemberSession.challenge_id = decodeURIComponent(ctx.params.mid) ctx.body = { challengeId: ctx.session.MemberSession.challenge_id } - return } /** * Logout function for member. @@ -217,7 +170,6 @@ async function logout(ctx){ ctx.session = null ctx.status = 200 ctx.body = { success: true } - return } async function register(ctx){ const _registrationData = ctx.request.body @@ -252,7 +204,6 @@ async function register(ctx){ message: 'Registration completed successfully.', data: _return, } - return } /** * Functionality around story contributions. @@ -273,7 +224,6 @@ async function story(ctx){ success: true, message: 'Story submitted successfully.', } - return } /** * Management of Member Story Libraries. Note: Key validation is performed in library(). Story library may have additional functionality inside of core/MyLife @@ -376,9 +326,9 @@ function mAPIKeyValidation(ctx){ // transforms ctx.state }) } function mTokenType(ctx){ - const _token = ctx.state.token - const _assistantType = mBotSecrets?.[_token]??'personal-avatar' - return _assistantType + const { token, } = ctx.state + const assistantType = mBotSecrets?.[token] ?? 'personal-avatar' + return assistantType } function mTokenValidation(_token){ return mBotSecrets?.[_token]?.length??false @@ -391,6 +341,7 @@ export { experienceManifest, experienceNavigation, experiences, + experiencesLived, keyValidation, library, login, diff --git a/inc/js/mylife-agent-factory.mjs b/inc/js/mylife-agent-factory.mjs index 7cf4b74..c002b91 100644 --- a/inc/js/mylife-agent-factory.mjs +++ b/inc/js/mylife-agent-factory.mjs @@ -219,20 +219,30 @@ class BotFactory extends EventEmitter{ ) ?? [] if(!includeLived){ const livedExperiences = await this.experiencesLived() ?? [] - experiences = experiences.filter( - // filter out those not found (by id in lived experiences) - _experience=>!livedExperiences.find( - _livedExperience=>_livedExperience.experienceId===_experience.id + experiences = experiences.filter( // filter out `lived-experience.id`) + experience=>!livedExperiences.find( + livedExperience=>livedExperience.experience_id===experience.id ) ) } return experiences } + /** + * Returns array of member `lived-experiences` from database. + * @returns {Promise} - Array of lived experience objects. + */ async experiencesLived(){ + const experienceFields = [ + 'experience_date', + 'experience_id', + 'title', + 'variables', + ] const livedExperiences = await this.dataservices.getItems( - 'experience-lived', - ['experienceId'], // default includes being, id, mbr_id, object_id + 'lived-experience', + experienceFields, // default includes being, id, mbr_id, object_id ) + return livedExperiences } /** * Gets a specified `experience` from database. @@ -511,10 +521,51 @@ class AgentFactory extends BotFactory{ /** * Registers a new candidate to MyLife membership * @public - * @param {object} _candidate { 'email': string, 'humanName': string, 'avatarNickname': string } + * @param {object} candidate { 'email': string, 'humanName': string, 'avatarNickname': string } + */ + async registerCandidate(candidate){ + return await this.dataservices.registerCandidate(candidate) + } + /** + * Saves a completed lived experience to MyLife. + * @param {Object} experience - The Lived Experience Object to save. + * @returns */ - async registerCandidate(_candidate){ - return await this.dataservices.registerCandidate(_candidate) + async saveExperience(experience){ + /* validate structure */ + if(!experience?.id?.length) + throw new Error('experience id invalid') + if(!experience?.location?.completed) + throw new Error('experience not completed') + /* clean experience */ + const { cast: _cast, events, id, title, variables, } = experience + const cast = _cast.map(({ id, role }) => ({ id, role })) + const _experience = { + being: 'lived-experience', + events: events + .filter(event=>event?.dialog?.dialog?.length || event.character?.characterId?.length) + .map(event=>{ + const { character: _character, dialog, id, input, } = event + const character = cast.find(_cast=>_cast.id===_character?.characterId) + if(_character?.role?.length) + character.role = _character.role + return { + character: character?.role, + dialog: dialog?.dialog, + id, + // input, // currently am not storing memberInput event correctly + } + }), + experience_date: Date.now(), + experience_id: experience.id, + id: this.newGuid, + mbr_id: this.mbr_id, + name: (`lived-experience_${ title }_${ id }`).substring(0, 256), + title, + variables, + } + const savedExperience = await this.dataservices.saveExperience(_experience) + return savedExperience } /** * Submits a story to MyLife. Currently via API, but could be also work internally. diff --git a/inc/js/mylife-avatar.mjs b/inc/js/mylife-avatar.mjs index f55fb43..5efef00 100644 --- a/inc/js/mylife-avatar.mjs +++ b/inc/js/mylife-avatar.mjs @@ -83,6 +83,8 @@ class Avatar extends EventEmitter { this.#llmServices.botId = activeBot.bot_id /* experience variables */ this.#experienceGenericVariables = mAssignGenericExperienceVariables(this.#experienceGenericVariables, this) + /* lived-experiences */ + this.#livedExperiences = await this.#factory.experiencesLived(false) /* evolver */ this.#evolver = new EvolutionAssistant(this) mAssignEvolverListeners(this.#factory, this.#evolver, this) @@ -161,11 +163,31 @@ class Avatar extends EventEmitter { } /** * Get member collection items. + * @todo - trim return objects based on type * @param {string} type - The type of collection to retrieve, `false`-y = all. * @returns {array} - The collection items with no wrapper. */ async collections(type){ - const collections = await this.factory.collections(type) + const { factory, } = this + const collections = ( await factory.collections(type) ) + .map(collection=>{ + switch(type){ + case 'experience': + case 'lived-experience': + const { completed=true, description, experience_date=Date.now(), experience_id, id, title, variables, } = collection + return { + completed, + description, + experience_date, + experience_id, + id, + title, + variables, + } + default: + return collection + } + }) return collections } async createConversation(type='chat'){ @@ -188,25 +210,43 @@ class Avatar extends EventEmitter { } /** * Ends an experience. - * @todo - save living experience to cosmos, no need to await + * @todo - allow guest experiences + * @todo - create case for member ending with enough interaction to _consider_ complete + * @todo - determine whether modes are appropriate; while not interested in multiple session experiences, no reason couldn't chat with bot * @todo - relived experiences? If only saving by experience id then maybe create array? - * @param {Guid} experienceId - * @returns {boolean} + * @public + * @param {Guid} experienceId - The experience id. + * @returns {void} - Throws error if experience cannot be ended. */ experienceEnd(experienceId){ - if(this.isMyLife) - throw new Error('MyLife avatar cannot conduct nor end experiences.') - if(this.mode!=='experience') - throw new Error('Avatar is not currently in an experience.') - if(this.experience.id!==experienceId) - throw new Error('Avatar is not currently in the requested experience.') - if(!this.experience.skippable) // @todo - even if skippable, won't this function still process? - throw new Error('Avatar cannot end this experience at this time, not yet implmented.') + const { experience, factory, mode, } = this + if(this.isMyLife) // @stub - allow guest experiences + throw new Error('FAILURE::experienceEnd()::MyLife avatar cannot conduct nor end experiences at this time.') + if(mode!=='experience') + throw new Error('FAILURE::experienceEnd()::Avatar is not currently in an experience.') + if(this.experience?.id!==experienceId) + throw new Error('FAILURE::experienceEnd()::Avatar is not currently in the requested experience.') this.mode = 'standard' - // @stub - save living experience to cosmos - this.#livedExperiences.push(this.experience.id) + const { id, location, title, variables, } = experience + const { mbr_id, newGuid, } = factory + const completed = location?.completed + this.#livedExperiences.push({ // experience considered concluded for session regardless of origin, sniffed below + completed, + experience_date: Date.now(), + experience_id: id, + id: newGuid, + mbr_id, + title, + variables, + }) + if(completed){ // ended "naturally," by event completion, internal initiation + /* validate and cure `experience` */ + /* save experience to cosmos (no await) */ + factory.saveExperience(experience) + } else { // incomplete, force-ended by member, external initiation + // @stub - create case for member ending with enough interaction to _consider_ complete, or for that matter, to consider _started_ in some cases + } this.experience = undefined - return true } /** * Processes and executes incoming experience request. @@ -243,10 +283,6 @@ class Avatar extends EventEmitter { const experiences = mExperiences(await this.#factory.experiences(includeLived)) return experiences } - async experiencesLived(){ - // get experiences-lived [stub] - const livedExperiences = await this.#factory.experiencesLived() - } /** * Starts an avatar experience. * @todo - scene and event id start points @@ -536,6 +572,14 @@ class Avatar extends EventEmitter { get experienceLocation(){ return this.experience.location } + /** + * Returns List of Member's Lived Experiences. + * @getter + * @returns {Object[]} - List of Member's Lived Experiences. + */ + get experiencesLived(){ + return this.#livedExperiences + } /** * Get the Avatar's Factory. * @todo - deprecate if possible, return to private @@ -1245,6 +1289,7 @@ async function mExperiencePlay(factory, llm, experience, memberInput){ title: title ?? name, type: 'experience', }) // provide marker for front-end [end of event sequence] + experience.location.completed = true } } experience.events.push(...eventSequence) @@ -1311,6 +1356,7 @@ async function mExperienceStart(avatar, factory, experienceId, avatarExperienceV if(id!==experienceId) throw new Error('Experience failure, unexpected id mismatch.') experience.cast = await mCast(factory, experience.cast) // hydrates cast data + console.log('mExperienceStart::experience', experience.cast[0].inspect(true)) experience.events = [] experience.location = { experienceId: experience.id, diff --git a/inc/js/mylife-data-service.js b/inc/js/mylife-data-service.js index 77698c5..80e324e 100644 --- a/inc/js/mylife-data-service.js +++ b/inc/js/mylife-data-service.js @@ -16,7 +16,7 @@ class Dataservices { * Identifies currently available selection sub-types (i.e., `being`=@var) for the data service. * @private */ - #collectionTypes = ['chat', 'conversation', 'entry', 'experience', 'file', 'library', 'story'] + #collectionTypes = ['chat', 'conversation', 'entry', 'lived-experience', 'file', 'library', 'story'] /** * Represents the core functionality of the data service. This property * objectifies core data to make it more manageable and structured, @@ -188,8 +188,8 @@ class Dataservices { * Proxy to retrieve lived experiences. * @returns {array} - The lived experiences. */ - async collectionExperiences(){ - return await this.getItems('experience') + async collectionLivedExperiences(){ + return await this.getItems('lived-experience') } /** * Proxy to retrieve files. @@ -215,26 +215,30 @@ class Dataservices { } /** * Get member collection items. + * @todo - eliminate corrections * @public * @async * @param {string} type - The type of collection to retrieve, `false`-y = all. * @returns {array} - The collection items with no wrapper. */ async collections(type){ + if(type==='experience') // corrections + type = 'lived-experience' if(type?.length && this.#collectionTypes.includes(type)) return await this.getItems(type) else return Promise.all([ this.collectionConversations(), this.collectionEntries(), - this.collectionExperiences(), + this.collectionLivedExperiences(), this.collectionFiles(), this.collectionStories(), ]) - .then(([conversations, entries, experiences, stories])=>[ + .then(([conversations, entries, experiences, files, stories])=>[ ...conversations, ...entries, ...experiences, + ...files, ...stories, ]) .catch(err=>{ @@ -597,6 +601,10 @@ class Dataservices { return false } } + async saveExperience(experience){ + const savedExperience = await this.pushItem(experience) + return savedExperience + } /** * Sets a bot in the database. Performs logic to reduce the bot to the minimum required data, as Mongo/Cosmos has a limitation of 10 patch items in one batch array. * @param {object} bot - The bot object to set. diff --git a/inc/js/routes.mjs b/inc/js/routes.mjs index 2e36e8f..916be45 100644 --- a/inc/js/routes.mjs +++ b/inc/js/routes.mjs @@ -30,6 +30,7 @@ import { experienceManifest, experienceNavigation, experiences, + experiencesLived, keyValidation, library, login as apiLogin, @@ -61,6 +62,7 @@ _apiRouter.use(tokenValidation) _apiRouter.get('/alerts', alerts) _apiRouter.get('/alerts/:aid', alerts) _apiRouter.get('/experiences/:mid', experiences) // **note**: currently triggers autoplay experience +_apiRouter.get('/experiencesLived/:mid', experiencesLived) _apiRouter.get('/login/:mid', apiLogin) _apiRouter.get('/logout', apiLogout) _apiRouter.head('/keyValidation/:mid', keyValidation) @@ -86,6 +88,7 @@ _memberRouter.get('/collections/:type', collections) _memberRouter.get('/contributions', contributions) _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) diff --git a/inc/js/session.mjs b/inc/js/session.mjs index 33d3fbb..3c00a47 100644 --- a/inc/js/session.mjs +++ b/inc/js/session.mjs @@ -5,19 +5,20 @@ class MylifeMemberSession extends EventEmitter { #autoplayed = false // flag for autoplayed experience, one per session #consents = [] // consents are stored in the session #contributions = [] // intended to hold all relevant contribution questions for session + #experienceLocked = false #experiences = [] // holds id for experiences conducted in this session #factory - #locked = true // locked by default #mbr_id = false #Member name - constructor(_factory){ + #sessionLocked = true // locked by default + constructor(factory){ super() - this.#factory = _factory + this.#factory = factory this.#mbr_id = this.isMyLife ? this.factory.mbr_id : false mAssignFactoryListeners(this.#factory) console.log( - chalk.bgGray('MylifeMemberSession:constructor(_factory):generic-mbr_id::end'), + chalk.bgGray('MylifeMemberSession:constructor(factory):generic-mbr_id::end'), chalk.bgYellowBright(this.factory.mbr_id), ) } @@ -31,7 +32,7 @@ class MylifeMemberSession extends EventEmitter { mAssignFactoryListeners(this.#factory) await this.#factory.init(this.mbr_id, ) // needs only `init()` with different `mbr_id` to reset this.#Member = await this.factory.getMyLifeMember() - this.#autoplayed = false // resets autoplayed flag, although should be impossible as only other "variant" requires guest status, as one day experiences can be run for guests also [for pay] + this.#autoplayed = false // resets autoplayed flag, although should be impossible as only other "variant" requires guest status, as one-day experiences can be run for guests also [for pay] this.emit('onInit-member-initialize', this.#Member.memberName) console.log( chalk.bgBlue('created-member:'), @@ -60,12 +61,78 @@ class MylifeMemberSession extends EventEmitter { if(!this.challenge_id) return false // this.challenge_id imposed by :mid from route if(!await this.factory.challengeAccess(this.challenge_id, _passphrase)) return false // invalid passphrase, no access [converted in this build to local factory as it now has access to global datamanager to which it can pass the challenge request] // init member - this.#locked = false + this.#sessionLocked = false this.emit('member-unlocked', this.challenge_id) await this.init(this.challenge_id) } return !this.locked } + /** + * Conducts an experience for the member session. If the experience is not already in progress, it will be started. If the experience is in progress, it will be played. If the experience ends, it will be ended. The experience will be returned to the member session, and the session will be unlocked for further experiences, and be the prime tally-keeper (understandably) of what member has undergone. + * @todo - send events in initial start package, currently frontend has to ask twice on NON-autoplay entities, which will be vast majority + * @param {Guid} experienceId - Experience id to conduct. + * @param {any} memberInput - Input from member, presumed to be object, but should be courteous, especially regarding `eperience` conduct. + * @returns {Promise} - Experience frontend shorthand: { autoplay: guid ?? false, events: [], location: {string}, title: {string} } + */ + async experience(experienceId, memberInput){ + const { avatar } = this + let events = [] + /* check locks and set lock status */ + this.#experienceLocked = true + try{ // requires try, as locks would otherwise not release on unidentified errors + if(!avatar.isInExperience){ + // @stub - check that events are being sent + await avatar.experienceStart(experienceId) + } else { + const eventSequence = await avatar.experiencePlay(experienceId, memberInput) + events = eventSequence + } + } catch (error){ + console.log(chalk.redBright('experience() error'), error, avatar.experience) + const { experience } = avatar + if(experience){ // embed error in experience + experience.errors = experience.errors ?? [] + experience.errors.push(error) + } + } + const { experience } = avatar + const { autoplay, location, title, } = experience + const frontendExperience = { + autoplay, + events, + location, + title, + } + this.#experienceLocked = false + if(events.find(event=>{ return event.action==='end' && event.type==='experience' })){ + if(!this.experienceEnd(experienceId)) + console.log(chalk.redBright('experienceEnd() failed')) + } + return frontendExperience + } + /** + * Ends the experience for the member session. If the experience is in progress, it will be ended. Compares experienceId as handshake confirmation. + * @param {Guid} experienceId - Experience id to end. + * @returns {Promise} - Experience end status. + */ + async experienceEnd(experienceId){ + const { avatar } = this + let success = false + this.#experienceLocked = true + try{ + await avatar.experienceEnd(experienceId) + success = true + } catch (error){ /* avatar throws errors when antagonized */ + const { experience } = avatar + console.log(chalk.redBright('experienceEnd() error'), error, avatar.experience) + if(experience){ // embed error in experience + experience.errors = experience.errors ?? [] + experience.errors.push(error) + } + } + this.#experienceLocked = false + return success + } /** * Gets experiences for the member session. Identifies if autoplay is required, and if so, begins avatar experience via this.#Member. Ergo, Session can request avatar directly from Member, which was generated on `.init()`. * @todo - get and compare list of lived-experiences @@ -76,14 +143,15 @@ class MylifeMemberSession extends EventEmitter { * @property {Object[]} experiences - Array of experiences. */ async experiences(includeLived=false){ - if(this.locked) return {} + if(this.sessionLocked) + throw new Error('Session locked; `experience`(s) list(s) does not exist for guests.') const { avatar } = this.#Member const experiences = await avatar.experiences(includeLived) let autoplay = experiences.find(experience=>experience.autoplay)?.id ?? false /* trigger auto-play from session */ if(!this.#autoplayed && this.globals.isValidGuid(autoplay)){ - const _start = await avatar.experienceStart(autoplay) + await avatar.experienceStart(autoplay) this.#autoplayed = true } else @@ -129,6 +197,10 @@ class MylifeMemberSession extends EventEmitter { await (this.ctx.session.MemberSession.consents = _consent) // will add consent to session list return _consent } + /* getters and setters */ + get avatar(){ + return this.#Member.avatar + } get consent(){ return this.factory.consent // **caution**: returns <> } @@ -144,6 +216,12 @@ class MylifeMemberSession extends EventEmitter { get core(){ return this.factory.core } + get experiencesLived(){ + if(this.sessionLocked) + throw new Error('Session locked; `experience`(s) list(s) does not exist for guests.') + const { avatar } = this.#Member + return avatar.experiencesLived + } get factory(){ return this.#factory } @@ -157,7 +235,7 @@ class MylifeMemberSession extends EventEmitter { return this.factory.isMyLife } get locked(){ - return this.#locked + return this.sessionLocked } get mbr_id(){ return this.#mbr_id @@ -168,6 +246,9 @@ class MylifeMemberSession extends EventEmitter { get member(){ // @todo - deprecate and funnel through any requests return this.#Member } + get sessionLocked(){ + return this.#sessionLocked + } get subtitle(){ return this.#Member?.agentName } diff --git a/views/assets/css/bot-bar.css b/views/assets/css/bot-bar.css index c2462fd..9a9c14b 100644 --- a/views/assets/css/bot-bar.css +++ b/views/assets/css/bot-bar.css @@ -257,7 +257,7 @@ color: red; } .collection-list { - display: flex; + display: none; flex-direction: column; flex-wrap: wrap; justify-content: flex-start; diff --git a/views/assets/css/main.css b/views/assets/css/main.css index 46c3b48..fc9e70b 100644 --- a/views/assets/css/main.css +++ b/views/assets/css/main.css @@ -62,6 +62,7 @@ body { .navigation-help { align-items: center; color: #007bff; + cursor: pointer; display: flex; font-size: 2em; height: auto; @@ -69,7 +70,20 @@ body { } .navigation-help:hover { color: purple; /* Your existing styles for active link */ - cursor: pointer; /* Set cursor to pointer on hover */ +} +.help-container { + background: #f9f9f9; + color: navy; + cursor: default; + display: none; + flex-direction: column; + font-size: 1em; + padding: 10px; + position: absolute; + top: 2.2em; /* Position it right below the parent */ + right: 0; + width: 50%; + z-index: 100; /* Make sure it's above other elements */ } .navigation-login-logout { align-self: flex-end; diff --git a/views/assets/html/_navbar.html b/views/assets/html/_navbar.html index cd72c83..12fb190 100644 --- a/views/assets/html/_navbar.html +++ b/views/assets/html/_navbar.html @@ -8,7 +8,15 @@ - +
+
None
@@ -277,6 +278,7 @@
+
None
@@ -288,6 +290,7 @@
+
None
diff --git a/views/assets/js/bots.mjs b/views/assets/js/bots.mjs index 8aa93fe..f0e3341 100644 --- a/views/assets/js/bots.mjs +++ b/views/assets/js/bots.mjs @@ -2,15 +2,17 @@ /* imports */ import { addMessageToColumn, + availableExperiences, hide, inExperience, show, + toggleVisibility, } from './members.mjs' import Globals from './globals.mjs' /* constants */ const botBar = document.getElementById('bot-bar'), mGlobals = new Globals(), - mLibraries = ['file', 'story'], // ['chat', 'entry', 'experience', 'file', 'story'] + mLibraries = ['experience', 'file', 'story'], // ['chat', 'entry', 'experience', 'file', 'story'] libraryCollections = document.getElementById('library-collections'), passphraseCancelButton = document.getElementById(`personal-avatar-passphrase-cancel`), passphraseInput = document.getElementById(`personal-avatar-passphrase`), @@ -240,7 +242,7 @@ function mCreateCollectionPopup(collectionItem){ /* create popup content */ const popupContent = document.createElement('div') popupContent.classList.add('collection-popup-content') - popupContent.innerText = summary + popupContent.innerText = summary ?? JSON.stringify(collectionItem) /* create popup close button */ const popupClose = document.createElement('button') popupClose.classList.add('fa-solid', 'fa-close', 'collection-popup-close') @@ -540,6 +542,21 @@ function mToggleBotContainers(event){ break } } +/** + * Toggles collection item visibility. + * @param {Event} event - The event object. + * @returns {void} + */ +function mToggleCollectionItems(event){ + event.stopPropagation() + const { currentTarget: element, target, } = event /* currentTarget=collection-bar, target=interior divs */ + if(target.id.includes('collection-refresh')) /* exempt refresh */ + return + const collectionId = element.id.split('-').pop() + const collectionList = document.getElementById(`collection-list-${ collectionId }`) + if(collectionList) + toggleVisibility(collectionList) +} /** * Toggles passphrase input visibility. * @param {Event} event - The event object. @@ -671,7 +688,12 @@ function mUpdateBotContainers(){ console.log('Library collection not found.', id) continue } + const collectionBar = document.getElementById(`collection-bar-${ id }`) const collectionButton = document.getElementById(`collection-refresh-${ id }`) + /* add listeners */ + if(collectionBar) + collectionBar.addEventListener('click', mToggleCollectionItems) + /* collection.click() to run on load */ if(collectionButton) collectionButton.addEventListener('click', mRefreshCollection, { once: true }) } diff --git a/views/assets/js/experience.mjs b/views/assets/js/experience.mjs index 0248d65..3af8d1f 100644 --- a/views/assets/js/experience.mjs +++ b/views/assets/js/experience.mjs @@ -176,8 +176,9 @@ async function experienceStart(experience){ mExperience = experience /* present stage */ mStageWelcome() - const { description, events: _events=[], id, name, purpose, title, skippable=false } = mExperience - mExperience.events = (_events.length) ? _events : await mEvents() + const { description, events, id, name, purpose, title, skippable=false } = mExperience + if(!events?.length) + mExperience.events = await mEvents() /* experience manifest */ const manifest = await mManifest(id) if(!manifest) @@ -694,7 +695,9 @@ async function mEvents(memberInput){ }) if(!response.ok) throw new Error(`HTTP error! Status: ${response.status}`) - const { autoplay, events, id, location, name, purpose, skippable, } = await response.json() + let { autoplay, events, id, location, name, purpose, skippable, } = await response.json() + if(!events?.length) + events = await mEvents() mExperience.location = location return events } @@ -916,6 +919,7 @@ function mStageWelcome(){ }) }, { once: true }) screen.classList.add('modal-screen') + show(screen) } /** * Toggles the input lane for the moderator. diff --git a/views/assets/js/globals.mjs b/views/assets/js/globals.mjs index 7563479..da44623 100644 --- a/views/assets/js/globals.mjs +++ b/views/assets/js/globals.mjs @@ -7,6 +7,7 @@ let mLoginButton, mLoginSelect, mMainContent, mNavigation, + mNavigationHelp, mSidebar /* class definitions */ class Globals { @@ -19,8 +20,10 @@ class Globals { mChallengeSubmit = document.getElementById('member-challenge-submit') mLoginSelect = document.getElementById('member-select') mNavigation = document.getElementById('navigation-container') + mNavigationHelp = document.getElementById('navigation-help') mSidebar = document.getElementById('sidebar') /* assign event listeners */ + mNavigationHelp.addEventListener('click', mToggleHelp) mLoginButton.addEventListener('click', this.loginLogout, { once: true }) if(mChallengeInput){ mChallengeInput.addEventListener('input', mToggleChallengeSubmit) @@ -104,6 +107,15 @@ class Globals { show(element, listenerFunction){ mShow(element, listenerFunction) } + /** + * Toggles the visibility of an element. + * @param {HTMLElement} element - The element to toggle. + * @returns {void} + */ + toggleVisibility(element){ + const { classList, } = element + mIsVisible(classList) ? mHide(element) : mShow(element) + } /** * Variable-izes (for js) a given string. * @param {string} undashedString - String to variable-ize. @@ -165,6 +177,16 @@ function mHide(element, callbackFunction){ callbackFunction() element.classList.add('hide') } +/** + * Determines whether an element is visible. Does not allow for any callbackFunctions + * @private + * @param {Object[]} classList - list of classes to check: `element.classList`. + * @returns {boolean} - Whether the element is visible. + */ +function mIsVisible(classList){ + console.log('mIsVisible', classList) + return classList.contains('show') +} function mLogin(){ console.log('login') window.location.href = '/select' @@ -273,6 +295,14 @@ function mToggleChallengeSubmit(event){ mChallengeSubmit.style.cursor = 'not-allowed' } } +function mToggleHelp(event){ + console.log('mToggleHelp', event.target) + const help = document.getElementById('navigation-help-container') + if(!help) + return + const { classList, } = help + mIsVisible(classList) ? mHide(help) : mShow(help) +} /* export */ export default Globals /* diff --git a/views/assets/js/members.mjs b/views/assets/js/members.mjs index 29f1f95..5167dcf 100644 --- a/views/assets/js/members.mjs +++ b/views/assets/js/members.mjs @@ -59,6 +59,9 @@ document.addEventListener('DOMContentLoaded', async event=>{ if(!initialized) throw new Error('CRITICAL::mInitialize::Error()') stageTransition() + /* temporary shortcut to experience */ + const helpShortcut = document.getElementById('navigation-help-input-container') + helpShortcut.addEventListener('click', mExperienceStart) /* **note**: bots run independently upon conclusion */ }) /* public functions */ @@ -101,6 +104,14 @@ function assignElements(parent=chatInput, elements, clear=true){ parent.removeChild(parent.firstChild) elements.forEach(element=>parent.appendChild(element)) } +/** + * Get experiences available to the member. + * @returns {object[]} - The available experiences. + */ +function availableExperiences(){ + // repull from server? prefer separate function + return mExperiences +} /** * Clears the system chat by removing all chat bubbles instances. * @todo - store chat locally for retrieval? @@ -144,7 +155,7 @@ function getSystemChat(){ * @returns {void} */ function hide(){ - return mGlobals.hide(...arguments) + mGlobals.hide(...arguments) } function hideMemberChat(){ hide(navigation) @@ -246,7 +257,7 @@ async function setActiveCategory(category, contributionId, question) { * @returns {void} */ function show(){ - return mGlobals.show(...arguments) + mGlobals.show(...arguments) } /** * Shows the member chat system. @@ -284,6 +295,13 @@ function stageTransition(endExperience=false){ updatePageBots(true) } } +/** + * Toggle visibility functionality. + * @returns {void} + */ +function toggleVisibility(){ + mGlobals.toggleVisibility(...arguments) +} /** * Waits for user action. * @public @@ -329,6 +347,18 @@ async function mAddMemberDialog(event){ function bot(_id){ return mPageBots.find(bot => bot.id === _id) } +/** + * Proxy to start first experience. + * @param {Event} event - The event object. + * @returns {void} + */ +function mExperienceStart(event){ + event.preventDefault() + event.stopImmediatePropagation() + console.log('mExperienceStart()', mExperiences) + mExperience = mExperiences[0] + stageTransition() +} function mFetchExperiences(){ return fetch('/members/experiences/') .then(response=>{ @@ -374,7 +404,6 @@ function mInitialize(){ return autoplay }) .then(autoplay=>{ - console.log('autoplay', autoplay) if(autoplay) mExperience = mExperiences.find(experience => experience.id===autoplay) return true @@ -580,6 +609,7 @@ function mTypewriteMessage(chatBubble, message, delay=10, i=0){ export { addMessageToColumn, assignElements, + availableExperiences, clearSystemChat, getInputValue, getSystemChat, @@ -597,5 +627,6 @@ export { submit, toggleMemberInput, toggleInputTextarea, + toggleVisibility, waitForUserAction, } \ No newline at end of file From 04647dc747bca8200d29baa73ca65bb9be5033a3 Mon Sep 17 00:00:00 2001 From: Erik Jespersen <42016062+Mookse@users.noreply.github.com> Date: Tue, 7 May 2024 01:00:57 -0400 Subject: [PATCH 08/13] 189 experience collection (#192) * 20240503 @Mookse - allow recycle button for experience collection to function * 20240503 @Mookse - string.includes() * 20240503 @Mookse - collections open and close * 20240504 @Mookse - rudimentary help container * 20240504 @Mookse - trigger welcome experience from help - note: members-only * 20240504 @Mookse - `experience` trigger fixes * 20240504 @Mookse - cosmetic * 20240504 @Mookse - start Experience, non-autoplay * 20240506 @Mookse - experienceEnd() [pipeline - saveExperience() * 20240506 @Mookse - `experiencesLived` pipeline * 20240505 @Mookse - backend adjustments * 20240506 @Mookse - experience collection item displays object JSON * 20240506 @Mookse - text box expansion * 20240506 @Mookse - HTML executed in dialog - escapeHTML upgrade - reposition refresh * 20240506 @Mookse - main content adjustments - Q-popup closes --- views/assets/css/chat.css | 55 +++++++++++++------------------- views/assets/css/main.css | 10 +++--- views/assets/css/members.css | 21 ------------ views/assets/js/bots.mjs | 12 ++++--- views/assets/js/experience.mjs | 3 +- views/assets/js/globals.mjs | 17 +++++++++- views/assets/js/guests.mjs | 25 ++------------- views/assets/js/members.mjs | 58 +++++++++------------------------- views/members.html | 2 +- 9 files changed, 69 insertions(+), 134 deletions(-) diff --git a/views/assets/css/chat.css b/views/assets/css/chat.css index 85e8973..6a40573 100644 --- a/views/assets/css/chat.css +++ b/views/assets/css/chat.css @@ -35,7 +35,7 @@ word-wrap: break-word; /* Ensure long words don't overflow */ } .chat-container { - align-items: center; + align-items: flex-start; background-image: linear-gradient( to bottom, @@ -50,9 +50,9 @@ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); box-sizing: border-box; display: none; + flex: 1 0 auto; flex-direction: column; height: auto; - justify-content: space-between; overflow: hidden; padding: 0 0.5em; position: relative; @@ -61,21 +61,13 @@ .chat-member, .chat-user { align-items: center; - align-self: stretch; display: flex; + flex: 1 1 auto; flex-direction: row; flex-wrap: wrap; - justify-content: right; margin: 0.5em 0; - min-width: max-content; - overflow: visible; -} -.chat-member input, -.chat-user input { - background-color: #ffffff; /* White background color */ - border-radius: 12px; /* Rounded corners */ - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Optional: adds a subtle shadow for depth */ - flex: 1; + max-height: 60%; + width: 100%; } .chat-member textarea, .chat-user textarea { @@ -83,16 +75,14 @@ border: 1px solid #ccc; /* Border color */ border-radius: 12px; /* Rounded corners */ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Subtle shadow for depth */ - flex: 1; - flex-shrink: 0; - font-size: 1rem; /* Adjust font size */ - line-height: 1.5; /* Adjust line height for better readability */ - min-height: 100%; - /* max-height: 200px; /* Adjust this value as needed */ - margin: 6px 12px; /* Margin for space outside the container */ - overflow-y: auto; /* Adds a scrollbar when content exceeds max-height */ - padding: 8px; /* Padding for space inside the container */ - resize: vertical; /* Allows vertical resizing, none to disable */ + display: flex; + flex: 1 1 auto; + font-size: 1em; /* Adjust font size */ + line-height: 1.25; /* Adjust line height for better readability */ + margin: 0 1em; /* Margin for space outside the container */ + overflow: hidden; /* Hides the scrollbar */ + padding: 0.3em; /* Padding for space inside the container */ + resize: none; /* Allows vertical resizing, none to disable */ } .chat-refresh { position: absolute; /* Position relative to parent */ @@ -105,28 +95,28 @@ cursor: pointer; /* Pointer cursor on hover */ z-index: 2; /* Ensure it's above other content */ } -.chat-submit { +button.chat-submit { /* need specificity to overwrite button class */ flex: 0 0 auto; color: rgba(65, 84, 104, 0.85); background-color: rgba(183, 203, 225, 0.85); - margin: 0px; + margin: 0 0.5em; white-space: nowrap; /* Prevents text wrapping in the button */ } -.chat-submit:hover { +button.chat-submit:hover { background-color: rgba(195, 203, 52, 0.85); color: #061320; } -.chat-submit:focus { +button.chat-submit:focus { outline: none; /* Remove the default focus outline */ box-shadow: 0 0 8px 2px rgba(255, 255, 0, 0.6); /* Yellow glow */ } -.chat-submit:disabled { +button.chat-submit:disabled { background-color: rgba(63, 75, 83, 0.2); border-color: rgba(146, 157, 163, .7); color: rgba(146, 157, 163, .9); } .chat-system { - flex: 1 1 100%; + flex: 1 1 auto; flex-direction: column; height: 100%; justify-content: flex-start; @@ -168,11 +158,10 @@ opacity: 0; /* Max opacity of 50% */ } .label-spinner-container { - position: relative; display: flex; - align-items: center; - justify-content: center; /* Center contents horizontally */ - min-height: 50px; /* Ensure enough height for spinner and label */ + flex: 0 0 auto; + margin: 0 0.5em; + justify-content: flex-start; } .member-bubble, .user-bubble { background-color: rgba(225, 245, 254, 1); /* A light shade of blue for the user */ diff --git a/views/assets/css/main.css b/views/assets/css/main.css index fc9e70b..d99e8a8 100644 --- a/views/assets/css/main.css +++ b/views/assets/css/main.css @@ -196,11 +196,9 @@ body { border-radius: 22px; box-sizing: border-box; display: none; - flex: 1 1 66%; /* Use flex to assign 66% of the space */ + flex: 0 65%; height: auto; - max-width: 100%; - min-height: 50vh; - min-width: 66%; + min-height: 70vh; overflow: hidden; padding: 0px; } @@ -358,12 +356,12 @@ body { border: rgb(0, 25, 51, .3) 2px dotted; border-radius: 22px; display: none; - flex: 0 0 33%; /* Use flex to assign 33% of the space */ + flex: 0 auto; flex-direction: column; font-family: "Optima", "Segoe UI", "Candara", "Calibri", "Segoe", "Optima", Arial, sans-serif; font-size: 0.8em; height: fit-content; /* 100% will stretch to bottom */ - max-width: 33%; + max-width: 35%; padding: 0px; } .sidebar-widget { diff --git a/views/assets/css/members.css b/views/assets/css/members.css index 47ce18b..0eef32b 100644 --- a/views/assets/css/members.css +++ b/views/assets/css/members.css @@ -119,25 +119,4 @@ .checkbox-group label { padding-left: 0.5em; margin: 0; -} -/* media queries */ -@media screen and (min-width: 1024px) { - body { - margin-right: 3em; /* Increased margin for larger screens */ - max-width: 1024px; /* Set a max-width to center the content */ - margin-left: auto; /* Center the body horizontally */ - margin-right: auto; - } -} -@media screen and (max-width: 768px) { /* Adjust the max-width as needed */ - .page-column-collection { - flex-direction: column; - } - .main-content { - max-width: 100%; - } - .sidebar { - flex-basis: 100%; - max-width: 100%; - } } \ No newline at end of file diff --git a/views/assets/js/bots.mjs b/views/assets/js/bots.mjs index f0e3341..ba4ec5f 100644 --- a/views/assets/js/bots.mjs +++ b/views/assets/js/bots.mjs @@ -347,27 +347,29 @@ function mOpenStatusDropdown(element){ } }) var content = element.querySelector('.bot-options') - console.log('mOpenStatusDropdown', content, element) if(content) content.classList.toggle('open') var dropdown = element.querySelector('.bot-options-dropdown') if(dropdown) dropdown.classList.toggle('open') } +/** + * Refresh collection on click. + * @param {Event} event - The event object. + * @returns {void} + */ async function mRefreshCollection(event){ const { id, } = event.target const type = id.split('-').pop() if(!mLibraries.includes(type)) throw new Error(`Library collection not implemented.`) const collection = await fetchCollections(type) + const collectionList = document.getElementById(`collection-list-${ type }`) + show(collectionList) /* no toggle, just show */ if(!collection.length) /* no items in collection */ return - const collectionList = document.getElementById(`collection-list-${ type }`) - if(!collectionList) - throw new Error(`Library collection list not found! Attempting element by id: "collection-list-${ type }".`) mUpdateCollection(type, collection, collectionList) event.target.addEventListener('click', mRefreshCollection, { once: true }) - return collection } /** * Set Bot data on server. diff --git a/views/assets/js/experience.mjs b/views/assets/js/experience.mjs index 3af8d1f..2ae999d 100644 --- a/views/assets/js/experience.mjs +++ b/views/assets/js/experience.mjs @@ -3,6 +3,7 @@ import { addMessageToColumn, assignElements, clearSystemChat, + escapeHtml, getInputValue, getSystemChat, hide, @@ -398,7 +399,7 @@ function mCreateCharacterDialog(){ dialogDiv.name = `dialog-${mEvent.id}` // set random type (max 3) const dialogType = Math.floor(Math.random() * 3) + 1 - dialogDiv.textContent = mEvent.dialog.dialog + dialogDiv.innerHTML = `

${ mEvent.dialog.dialog }

` dialogDiv.classList.add('char-dialog-box') dialogDiv.classList.add(`dialog-type-${dialogType}`) return dialogDiv diff --git a/views/assets/js/globals.mjs b/views/assets/js/globals.mjs index da44623..909eea5 100644 --- a/views/assets/js/globals.mjs +++ b/views/assets/js/globals.mjs @@ -33,6 +33,22 @@ class Globals { mLoginSelect.addEventListener('change', mSelectLoginId, { once: true }) } /* public functions */ + /** + * Escapes HTML characters in a string. + * @param {string} text - The text to escape. + * @returns {string} - The escaped text. + */ + escapeHtml(text){ + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } + const escapedText = text.replace(/[&<>"']/g, m=>(map[m]) ) + return escapedText + } /** * Returns the avatar object if poplated by on-page EJS script. * @todo - refactor to api call @@ -184,7 +200,6 @@ function mHide(element, callbackFunction){ * @returns {boolean} - Whether the element is visible. */ function mIsVisible(classList){ - console.log('mIsVisible', classList) return classList.contains('show') } function mLogin(){ diff --git a/views/assets/js/guests.mjs b/views/assets/js/guests.mjs index 0dbbbef..03995fb 100644 --- a/views/assets/js/guests.mjs +++ b/views/assets/js/guests.mjs @@ -47,23 +47,6 @@ document.addEventListener('DOMContentLoaded', ()=>{ /* display page */ mShowPage() }) -/* public functions */ -/** - * Escapes HTML characters in a string. - * @param {string} text - The text to escape. - * @returns {string} - The escaped text. - */ -function escapeHtml(text) { - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - } - const escapedText = text.replace(/[&<>"']/g, m=>(map[m]) ) - return escapedText -} /* private functions */ /** * Adds a message to the chat column. @@ -89,7 +72,7 @@ function mAddMessage(message, options={ chatBubble.className = `chat-bubble ${bubbleClass}` mChatBubbleCount++ chatSystem.appendChild(chatBubble) - messageContent = escapeHtml(messageContent) + messageContent = mGlobals.escapeHtml(messageContent) if(typewrite){ let i = 0 let tempMessage = '' @@ -121,7 +104,7 @@ function mAddUserMessage(event){ // Dynamically get the current message element (input or textarea) let userMessage = chatInput.value.trim() if (!userMessage.length) return - userMessage = escapeHtml(userMessage) // Escape the user message + userMessage = mGlobals.escapeHtml(userMessage) // Escape the user message mSubmitInput(event, userMessage) mAddMessage({ message: userMessage }, { bubbleClass: 'user-bubble', @@ -313,8 +296,4 @@ function mToggleButton(event){ chatSubmit.disabled = true chatSubmit.style.cursor = 'not-allowed' } -} -/* exports */ -export { - escapeHtml, } \ No newline at end of file diff --git a/views/assets/js/members.mjs b/views/assets/js/members.mjs index 5167dcf..3c0a53e 100644 --- a/views/assets/js/members.mjs +++ b/views/assets/js/members.mjs @@ -76,7 +76,7 @@ function addMessageToColumn(message, options={ _delay: 10, _typewrite: true, }){ - let messageContent = message.message ?? message + const messageContent = message.message ?? message const { bubbleClass, _delay, @@ -85,7 +85,7 @@ function addMessageToColumn(message, options={ const chatBubble = document.createElement('div') chatBubble.id = `chat-bubble-${mChatBubbleCount}` chatBubble.classList.add('chat-bubble', bubbleClass) - chatBubble.innerHTML = messageContent + chatBubble.innerHTML = escapeHtml(messageContent) mChatBubbleCount++ systemChat.appendChild(chatBubble) } @@ -122,21 +122,8 @@ function clearSystemChat(){ // Remove all chat bubbles and experience chat-lanes under chat-system systemChat.innerHTML = '' } -/** - * Escapes HTML text. - * @public - * @param {string} text - The text to escape. - * @returns {string} - The escaped HTML text. - */ -function escapeHtml(text){ - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - return text.replace(/[&<>"']/g, function(m) { return map[m]; }); +function escapeHtml(text) { + return mGlobals.escapeHtml(text) } function getInputValue(){ return chatInputField.value.trim() @@ -353,11 +340,9 @@ function bot(_id){ * @returns {void} */ function mExperienceStart(event){ - event.preventDefault() - event.stopImmediatePropagation() - console.log('mExperienceStart()', mExperiences) mExperience = mExperiences[0] - stageTransition() + if(mExperience) + stageTransition() } function mFetchExperiences(){ return fetch('/members/experiences/') @@ -562,28 +547,14 @@ function toggleMemberInput(display=true, hidden=false){ if(hidden) hide(chatInput) } -// Function to toggle between textarea and input based on character count -function toggleInputTextarea(){ - const inputStyle = window.getComputedStyle(chatInput) - const inputFont = inputStyle.font - const textWidth = getTextWidth(chatInputField.value, inputFont) // no trim required - const inputWidth = chatInput.offsetWidth - /* pulse */ - clearTimeout(typingTimer); - spinner.style.display = 'none'; - mResetAnimation(spinner); // Reset animation - typingTimer = setTimeout(() => { - spinner.style.display = 'block'; - mResetAnimation(spinner); // Restart animation - }, 2000) - const listenerFunction = toggleInputTextarea - if (textWidth>inputWidth && chatInputField.tagName!=='TEXTAREA') { // Expand to textarea - chatInputField = replaceElement(chatInputField, 'textarea', true, 'input', listenerFunction) - focusAndSetCursor(chatInputField); - } else if (textWidth<=inputWidth && chatInputField.tagName==='TEXTAREA' ) { // Revert to input - chatInputField = replaceElement(chatInputField, 'input', true, 'input', listenerFunction) - focusAndSetCursor(chatInputField) - } +/** + * Toggles the input textarea. + * @param {Event} event - The event object. + * @returns {void} - The return is void. + */ +function toggleInputTextarea(event){ + chatInputField.style.height = 'auto' // Reset height to shrink if text is removed + chatInputField.style.height = chatInputField.scrollHeight + 'px' // Set height based on content toggleSubmitButtonState() } function toggleSubmitButtonState() { @@ -611,6 +582,7 @@ export { assignElements, availableExperiences, clearSystemChat, + escapeHtml, getInputValue, getSystemChat, hide, diff --git a/views/members.html b/views/members.html index d326ed6..008495b 100644 --- a/views/members.html +++ b/views/members.html @@ -11,7 +11,7 @@
chat
- +