UNPKG

@convo-lang/convo-lang

Version:
821 lines 31 kB
import { aryDuplicateRemoveItem, findSceneAction, readBlobAsDataUrlAsync, shortUuid, wSetProp, zodTypeToJsonScheme } from "@iyio/common"; import { BehaviorSubject, Subject } from "rxjs"; import { z } from "zod"; import { Conversation } from "./Conversation.js"; import { getConvoPromptMediaUrl } from "./convo-lang-ui-lib.js"; import { convoRoles, convoTags, convoVars, removeDanglingConvoUserMessage } from "./convo-lib.js"; import { LocalStorageConvoDataStore } from "./LocalStorageConvoDataStore.js"; import { TtsCtrl } from "./TtsCtrl.js"; export class ConversationUiCtrl { get lastCompletionSubject() { return this._lastCompletion; } get lastCompletion() { return this._lastCompletion.value; } get onAppend() { return this._onAppend; } get onChunk() { return this._onChunk; } get streamingMessagesSubject() { return this._streamingMessages; } get streamingMessages() { return this._streamingMessages.value; } get enableStreamingSubject() { return this._enableStreaming; } get enableStreaming() { return this._enableStreaming.value; } set enableStreaming(value) { if (value == this._enableStreaming.value) { return; } this._enableStreaming.next(value); if (value !== null && this.convo) { this.convo.enableStreaming = value; } } get currentTaskSubject() { return this._currentTask; } get currentTask() { return this._currentTask.value; } get flatSubject() { return this._flat; } get flat() { return this._flat.value; } get templateSubject() { return this._template; } get template() { return this._template.value; } set template(value) { if (value == this._template.value) { return; } this._template.next(value); } ; get removeDanglingUserMessagesSubject() { return this._removeDanglingUserMessages; } get removeDanglingUserMessages() { return this._removeDanglingUserMessages.value; } set removeDanglingUserMessages(value) { if (value == this._removeDanglingUserMessages.value) { return; } this._removeDanglingUserMessages.next(value); } get convoSubject() { return this._convo; } get convo() { return this._convo.value; } get showSourceSubject() { return this._showSource; } /** * If true the source convo-lang syntax should be displayed to the user */ get showSource() { return this._showSource.value; } set showSource(value) { if (value == this._showSource.value) { return; } this._showSource.next(value); } get editorModeSubject() { return this._editorMode; } get editorMode() { return this._editorMode.value; } set editorMode(value) { if (value == this._editorMode.value) { return; } this._editorMode.next(value); } get showSystemMessagesSubject() { return this._showSystemMessages; } /** * If true the system messages should be displayed to the user */ get showSystemMessages() { return this._showSystemMessages.value; } set showSystemMessages(value) { if (value == this._showSystemMessages.value) { return; } this._showSystemMessages.next(value); } get showResultsSubject() { return this._showResults; } get showResults() { return this._showResults.value; } set showResults(value) { if (value == this._showResults.value) { return; } this._showResults.next(value); } get enabledInitMessageSubject() { return this._enabledInitMessage; } get enabledInitMessage() { return this._enabledInitMessage.value; } set enabledInitMessage(value) { if (value == this._enabledInitMessage.value) { return; } if (value && this.convo?.initMessageReady()) { this.convo.completeAsync(); } this._enabledInitMessage.next(value); } get showFunctionsSubject() { return this._showFunctions; } /** * If true function calls should be displayed to the user */ get showFunctions() { return this._showFunctions.value; } set showFunctions(value) { if (value == this._showFunctions.value) { return; } this._showFunctions.next(value); } get enableSlashCommandsSubject() { return this._enableSlashCommands; } /** * If messages appended to the conversation using the appendUiMessage will be checked for messages * starting with a forward slash and be interpreted as a command. */ get enableSlashCommands() { return this._enableSlashCommands.value; } set enableSlashCommands(value) { if (value == this._enableSlashCommands.value) { return; } this._enableSlashCommands.next(value); } /** @deprecated */ get themeOldSubject() { return this._themeOld; } /** @deprecated */ get themeOld() { return this._themeOld.value; } /** @deprecated */ set themeOld(value) { if (value == this._themeOld.value) { return; } this._themeOld.next(value); } get themeSubject() { return this._theme; } get theme() { return this._theme.value; } set theme(value) { if (value == this._theme.value) { return; } this._theme.next(value); } get mediaQueueSubject() { return this._mediaQueue; } get mediaQueue() { return this._mediaQueue.value; } get collapsedSubject() { return this._collapsed; } /** * Often used to indicate if the conversation is display in a collapsed state */ get collapsed() { return this._collapsed.value; } set collapsed(value) { if (value == this._collapsed.value) { return; } this._collapsed.next(value); } get onClear() { return this._onClear; } get onAppendUiMessage() { return this._onAppendUiMessage; } get expandOnUiMessageSubject() { return this._expandOnUiMessage; } get expandOnUiMessage() { return this._expandOnUiMessage.value; } set expandOnUiMessage(value) { if (value == this._expandOnUiMessage.value) { return; } this._expandOnUiMessage.next(value); } get beforeCreateExeCtxSubject() { return this._beforeCreateExeCtx; } get beforeCreateExeCtx() { return this._beforeCreateExeCtx.value; } set beforeCreateExeCtx(value) { if (value == this._beforeCreateExeCtx.value) { return; } if (this.convo && value !== undefined) { this.convo.beforeCreateExeCtx = value ?? undefined; } this._beforeCreateExeCtx.next(value); } get defaultVarsSubject() { return this._defaultVars; } get defaultVars() { return this._defaultVars.value; } set defaultVars(value) { if (value == this._defaultVars.value) { return; } if (this.convo) { const current = this._defaultVars.value; for (const e in current) { if (this.convo.defaultVars[e] === current[e]) { delete this.convo.defaultVars[e]; } } for (const e in value) { this.convo.defaultVars[e] = value[e]; } } this._defaultVars.next(value); } get externFunctionsSubject() { return this._externFunctions; } get externFunctions() { return this._externFunctions.value; } set externFunctions(value) { if (value == this._externFunctions.value) { return; } if (this.convo) { const current = this._externFunctions.value; for (const e in current) { if (this.convo.externFunctions[e] === current[e]) { delete this.convo.externFunctions[e]; } } for (const e in value) { const fn = value[e]; if (fn) { this.convo.implementExternFunction(e, fn); } } } this._externFunctions.next(value); } get getStartOfConversationSubject() { return this._getStartOfConversation; } get getStartOfConversation() { return this._getStartOfConversation.value; } set getStartOfConversation(value) { if (value == this._getStartOfConversation.value) { return; } if (this.convo && value !== undefined) { this.convo.getStartOfConversation = value?.cb; } this._getStartOfConversation.next(value); } get modelOverrideSubject() { return this._modelOverride; } /** * Will force the given LLM model to be used */ get modelOverride() { return this._modelOverride.value; } set modelOverride(value) { if (value == this._modelOverride.value) { return; } this._modelOverride.next(value); } constructor({ id, autoLoad, convo, initConvo, convoOptions, template, autoSave = false, removeDanglingUserMessages = false, store = 'localStorage', enableSlashCommand = false, sceneCtrl, defaultVars, externFunctions, imagePathConverter, imageRenderer, theme, transcriptionService, ttsService, embeddingsService, db, } = {}) { this._lastCompletion = new BehaviorSubject(null); this._onAppend = new Subject(); this._onChunk = new Subject(); this._streamingMessages = new BehaviorSubject(null); this._enableStreaming = new BehaviorSubject(null); this.tasks = []; this._currentTask = new BehaviorSubject(null); this._flat = new BehaviorSubject(null); this._convo = new BehaviorSubject(null); this._showSource = new BehaviorSubject(false); this._editorMode = new BehaviorSubject('code'); this._showSystemMessages = new BehaviorSubject(false); /** * If true function call results will be displayed to the user */ this._showResults = new BehaviorSubject(false); this._enabledInitMessage = new BehaviorSubject(false); this._showFunctions = new BehaviorSubject(false); this._themeOld = new BehaviorSubject({}); this._mediaQueue = new BehaviorSubject([]); this._collapsed = new BehaviorSubject(true); this._onClear = new Subject(); this._onAppendUiMessage = new Subject(); this._expandOnUiMessage = new BehaviorSubject(false); this._beforeCreateExeCtx = new BehaviorSubject(undefined); this._getStartOfConversation = new BehaviorSubject(undefined); this._modelOverride = new BehaviorSubject(null); this.componentRenderers = {}; this._isDisposed = false; this.convoCleanup = null; this.isAutoSaving = false; this.autoSaveRequested = false; this.messageRenderers = []; this._readResponses = new BehaviorSubject(false); this.requestResponseResponsesCount = 0; this.id = id ?? shortUuid(); this.sceneCtrl = sceneCtrl; this.tts = new TtsCtrl(ttsService); this.imagePathConverter = imagePathConverter; this.imageRenderer = imageRenderer; this.transcriptionService = transcriptionService; this.ttsService = ttsService; this.embeddingsService = embeddingsService; this.db = db; this._defaultVars = new BehaviorSubject(defaultVars ? { ...defaultVars } : {}); this._externFunctions = new BehaviorSubject(externFunctions ? { ...externFunctions } : {}); this._removeDanglingUserMessages = new BehaviorSubject(removeDanglingUserMessages); this._enableSlashCommands = new BehaviorSubject(enableSlashCommand); this._theme = new BehaviorSubject(theme ?? null); this.convoOptions = convoOptions; this.initConvoCallback = initConvo; this._template = new BehaviorSubject(template); this.autoSave = autoSave; this.store = store === 'localStorage' ? new LocalStorageConvoDataStore() : store; if (id !== undefined && autoLoad) { if (convo) { this.setConvo(convo); } this.loadAsync(); } else { this.setConvo(convo ?? this.createConvo(true)); } } get isDisposed() { return this._isDisposed; } dispose() { if (this._isDisposed) { return; } this._isDisposed = true; this._currentTask.next('disposed'); const cleanup = this.convoCleanup; this.convoCleanup = null; cleanup?.(); this.tts.dispose(); } initConvo(convo, options) { if (this.initConvoCallback) { this.initConvoCallback(convo); } if (this.enableStreaming !== null) { convo.enableStreaming = this.enableStreaming; } for (const e in this.defaultVars) { convo.defaultVars[e] = this.defaultVars[e]; } if (this.db) { convo.unregisteredVars[convoVars.__db] = this.db; } for (const e in this.externFunctions) { const f = this.externFunctions[e]; if (f) { convo.implementExternFunction(e, f); } } if (this.getStartOfConversation !== undefined) { convo.getStartOfConversation = this.getStartOfConversation?.cb; } if (this.beforeCreateExeCtx !== undefined) { convo.beforeCreateExeCtx = this.beforeCreateExeCtx ?? undefined; } if (this.sceneCtrl) { convo.defaultVars[convoVars.__sceneCtrl] = this.sceneCtrl; convo.define({ fns: { executeSceneAction: { description: 'Used to execute actions defined in the scenes', paramsType: z.object({ id: z.string().describe('Id of the action to execute'), data: z.any().optional().describe('Data to pass to the action. If the action defines a dataScheme the passed ' + 'data should conform to the scheme.') }), scopeCallback: (scope, ctx) => { const scene = ctx.getVar(convoVars.__lastDescribedScene); if (!scene) { return 'Unable to executed action. Last described scene not found'; } const id = scope.paramValues?.[0]; if (typeof id !== 'string') { return 'Invalid scene action id'; } const action = findSceneAction(id, scene); if (!action) { return `No action found by id ${id}`; } let data = scope.paramValues?.[1]; if (action.dataScheme) { const parsed = action.dataScheme.safeParse(data); if (!parsed.success) { return `Invalid action data received. The action's data should conform to the JSON scheme of <JSON_SCHEME>${zodTypeToJsonScheme(action.dataScheme)}</JSON_SCHEME>`; } else { data = parsed.data; } } return action.callback?.(data) ?? 'action completed'; }, } } }); } if (options.appendTemplate && this.template) { try { convo.append(this.template); } catch (ex) { console.error('Invalid convo template supplied to ConversationUiCtrl', ex); } } } createConvo(appendTemplate) { const convo = new Conversation(this.convoOptions); this.initConvo(convo, { appendTemplate }); return convo; } async loadAsync() { if (this.isDisposed || this.currentTask || !this.store?.loadConvo) { return false; } this.pushTask('loading'); try { const str = await this.store?.loadConvo?.(this.id); if (this.isDisposed) { return false; } const convo = this.createConvo(str ? false : true); if (str) { convo.append(str); } this.setConvo(convo); } finally { this.popTask('loading'); } return true; } clear() { if (this.isDisposed || this.currentTask) { return false; } this.setConvo(this.createConvo(true)); if (this.autoSave) { this.queueAutoSave(); } this._onClear.next(); return true; } pushTask(task) { this.tasks.push(task); if (this._currentTask.value !== task) { this._currentTask.next(task); } } popTask(task) { if (this.isDisposed) { return; } const i = this.tasks.lastIndexOf(task); if (i === -1) { console.error(`ConversationUiCtrl.popTask out of sync. (${task}) not in current list`); return; } this.tasks.splice(i, 1); const next = this.tasks[this.tasks.length - 1] ?? null; if (this._currentTask.value !== next) { this._currentTask.next(next); } } setConvo(convo) { const prevCleanup = this.convoCleanup; this.convoCleanup = null; prevCleanup?.(); while (this.tasks.includes('completing')) { this.popTask('completing'); } let m = true; this._flat.next(null); this._streamingMessages.next(null); const sub = convo.activeTaskCountSubject.subscribe(n => { if (n === 1) { if (!this.tasks.includes('completing')) { this.pushTask('completing'); } } else if (n === 0) { if (this.tasks.includes('completing')) { this.popTask('completing'); } } }); const sub2 = convo.onAppend.subscribe(v => { this._onAppend.next(v); for (const m of v.messages) { if (m.streamingId) { const match = this._streamingMessages.value?.find(m => m.streamingId === m.streamingId); setTimeout(() => { if (!m) { return; } const match = this._streamingMessages.value?.find(m => m.streamingId === m.streamingId); if (match && this._streamingMessages.value) { const update = aryDuplicateRemoveItem(this._streamingMessages.value, match); if (update.length) { this._streamingMessages.next(update); } else { this._streamingMessages.next(null); } } }, match?.streamingFunction ? 0 : 1000); } if (this.readResponses && m.role === convoRoles.assistant) { this.readMessage(m); } } }); const sub3 = convo.onChunk.subscribe(v => { if (v.chunk) { const mid = v.mid; let ary = this._streamingMessages.value ?? []; let current = ary.find(m => m.streamingId === mid); if (!current) { current = { isAssistant: true, role: v.type === 'function' ? 'result' : 'assistant', content: '', streamingFunction: v.functionName, streamingId: mid, streamingActive: true, }; ary = [...ary, current]; this._streamingMessages.next(ary); } wSetProp(current, 'content', (current.content ?? '') + v.chunk); } this._onChunk.next(v); }); const sub4 = convo.flatSubject.subscribe(v => { this._flat.next(v); }); this.convoCleanup = () => { sub.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); m = false; }; this._convo.next(convo); if (this.enabledInitMessage && convo.initMessageReady()) { convo.completeAsync(); } } replace(convo) { if (this.isDisposed || this.currentTask) { return false; } if (this.removeDanglingUserMessages) { convo = removeDanglingConvoUserMessage(convo); } const c = this.createConvo(false); try { c.append(convo); } catch { return false; } this.setConvo(c); if (this.autoSave) { this.queueAutoSave(); } return true; } async replaceAndCompleteAsync(convo) { if (!this.replace(convo)) { return false; } if (this.currentTask) { return false; } await this.convo?.completeAsync(); this._lastCompletion.next(this.convo?.convo ?? null); if (this.autoSave) { this.queueAutoSave(); } return true; } isSlashCommand(message) { return cmdReg.test(message); } appendDefineVars(vars) { return this.convo?.appendDefineVars(vars); } appendDefineVar(name, value) { return this.convo?.appendDefineVar(name, value); } async appendWithFunctionCallAsync(message, fn) { const options = {}; if (fn && !fn.includes('(')) { fn += '()'; } if (fn && fn.startsWith('!')) { fn = fn.substring(1); options.returnOnCalled = true; } return this._appendUiMessageAsync(message, fn ? `@${convoTags.toolId} call_${shortUuid()}\n> call ${fn}` : undefined, options); } appendUiMessageAsync(message) { return this._appendUiMessageAsync(message); } async _appendUiMessageAsync(message, raw, completionOptions) { if (this.isDisposed) { return false; } if (cmdReg.test(message)) { if (!this._enableSlashCommands.value) { return false; } message = message.trim(); switch (message) { case '/source': this.showSource = this.editorMode === 'code' ? !this.showSource : true; this.editorMode = 'code'; break; case '/imports ': this.showSource = this.editorMode === 'imports' ? !this.showSource : true; this.editorMode = 'imports'; break; case '/modules': this.showSource = this.editorMode === 'modules' ? !this.showSource : true; this.editorMode = 'modules'; break; case '/ui': this.showSource = false; break; case '/vars': this.showSource = this.editorMode === 'vars' ? !this.showSource : true; this.editorMode = 'vars'; break; case '/flat': this.showSource = this.editorMode === 'flat' ? !this.showSource : true; this.editorMode = 'flat'; break; case '/text': this.showSource = this.editorMode === 'text' ? !this.showSource : true; this.editorMode = 'text'; break; case '/models': this.showSource = this.editorMode === 'models' ? !this.showSource : true; this.editorMode = 'models'; break; case '/tree': this.showSource = this.editorMode === 'tree' ? !this.showSource : true; this.editorMode = 'tree'; break; case '/system': this.showSystemMessages = !this.showSystemMessages; break; case '/results': this.showResults = !this.showResults; break; case '/function': this.showFunctions = !this.showFunctions; break; case '/convert': this.showSource = this.editorMode === 'model' ? !this.showSource : true; this.editorMode = 'model'; break; case '/clear': this.clear(); break; case '/stream': this.enableStreaming = !this.enableStreaming; break; case '/help': this.printHelp(); break; } this.triggerAppendUiMessageEvt(message, true); return 'command'; } const convo = this.convo; if (!convo || convo.isCompleting || this.currentTask) { return false; } if (this.mediaQueue.length) { message += '\n\n' + this.mediaQueue.map(i => { const url = getConvoPromptMediaUrl(i, 'prompt'); if (!url) { return ''; } return `![](${encodeURI(url)})`; }).join('\n'); this._mediaQueue.next([]); } if (message.trim()) { convo.appendUserMessage(message); this.triggerAppendUiMessageEvt(message); } if (raw?.trim()) { convo.append(raw); await convo.flattenAsync(); } const r = await convo.completeAsync(this.modelOverride ? { modelOverride: this.modelOverride, ...completionOptions, } : completionOptions); this._lastCompletion.next(convo.convo ?? null); if (this.autoSave) { this.queueAutoSave(); } return true; } triggerAppendUiMessageEvt(message, isCommand = false) { if (this._onAppendUiMessage.observed) { this._onAppendUiMessage.next({ isCommand, message, }); } if (this.expandOnUiMessage) { this.collapsed = false; } } printHelp() { const t3 = '```'; this.convo?.appendAssistantMessage(/*convo*/ ` ${t3} text /source - Display convo script source /imports - Display convo script source and imported modules /modules - Display convo script source and registered modules /models - Display all registered LLM models /ui - Display convo chat conversation ui /flat - Display the convo as flat messages /text - Display as convo script with all text content evaluated /vars - Display all defined user variables /tree - Displays the syntax tree /system - Display system messages /function - Display function messages /results - Display function call results /convert - Display messages in the format of the current model /stream - Toggle streaming responses /clear - Clears all messages /help - Prints this help message ${t3} `); } async queueMediaAsync(media) { if (!media) { return false; } if ((media instanceof Blob) || (media instanceof File)) { const m = await readBlobAsDataUrlAsync(media); if (!m) { return false; } media = m; } this.queueMedia(media); return true; } queueMedia(media) { this._mediaQueue.next([...this._mediaQueue.value, (typeof media === 'string') ? { url: media } : media]); } dequeueMedia(media) { if (typeof media === 'string') { const match = this._mediaQueue.value.find(m => m.url === media); if (!match) { return false; } media = match; } if (!this._mediaQueue.value.includes(media)) { return false; } this._mediaQueue.next(aryDuplicateRemoveItem(this._mediaQueue.value, media)); return true; } queueAutoSave() { if (this.isDisposed || !this.store) { return; } if (this.isAutoSaving) { this.autoSaveRequested = true; return; } this._autoSaveAsync(); } async _autoSaveAsync() { if (this.isAutoSaving || !this.store) { return; } this.isAutoSaving = true; do { try { await this.store?.saveConvo?.(this.id, this.convo?.convo ?? ''); } catch (ex) { console.error('ConversationUiCtrl auto save failed', ex); } } while (this.autoSaveRequested && !this.isDisposed); } renderMessage(message, index) { for (const r of this.messageRenderers) { const v = r(message, index, this); if (v !== undefined && v !== null) { return v; } } return undefined; } get readResponsesSubject() { return this._readResponses; } get readResponses() { return this._readResponses.value; } set readResponses(value) { if (value == this._readResponses.value) { return; } this._readResponses.next(value); } requestResponseResponses() { this.requestResponseResponsesCount++; if (this.requestResponseResponsesCount) { this.readResponses = true; } return () => { this.requestResponseResponsesCount--; if (this.requestResponseResponsesCount === 0) { this.readResponses = false; } }; } readMessage(message, cancel) { if (typeof message !== 'string') { message = message?.content; } if (!message?.trim()) { return null; } return this.tts.textToSpeech({ text: message }); } } const cmdReg = /^\s*\//; //# sourceMappingURL=ConversationUiCtrl.js.map