@convo-lang/convo-lang
Version:
The language of AI
639 lines • 24 kB
JavaScript
import { aryDuplicateRemoveItem, findSceneAction, shortUuid, zodTypeToJsonScheme } from "@iyio/common";
import { BehaviorSubject, Subject } from "rxjs";
import { z } from "zod";
import { Conversation } from "./Conversation.js";
import { LocalStorageConvoDataStore } from "./LocalStorageConvoDataStore.js";
import { getConvoPromptMediaUrl } from "./convo-lang-ui-lib.js";
import { convoVars, removeDanglingConvoUserMessage } from "./convo-lib.js";
export class ConversationUiCtrl {
get lastCompletionSubject() { return this._lastCompletion; }
get lastCompletion() { return this._lastCompletion.value; }
get onAppend() { return this._onAppend; }
get currentTaskSubject() { return this._currentTask; }
get currentTask() { return this._currentTask.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 enabledSlashCommandsSubject() { return this._enabledSlashCommands; }
/**
* 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 enabledSlashCommands() { return this._enabledSlashCommands.value; }
set enabledSlashCommands(value) {
if (value == this._enabledSlashCommands.value) {
return;
}
this._enabledSlashCommands.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);
}
constructor({ id, autoLoad, convo, initConvo, convoOptions, template, autoSave = false, removeDanglingUserMessages = false, store = 'localStorage', enableSlashCommand = false, sceneCtrl, defaultVars, externFunctions, imagePathConverter, imageRenderer, } = {}) {
this._lastCompletion = new BehaviorSubject(null);
this._onAppend = new Subject();
this.tasks = [];
this._currentTask = 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._theme = 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.componentRenderers = {};
this._isDisposed = false;
this.convoTaskCount = 0;
this.convoCleanup = null;
this.isAutoSaving = false;
this.autoSaveRequested = false;
this.messageRenderers = [];
this.id = id ?? shortUuid();
this.sceneCtrl = sceneCtrl;
this.imagePathConverter = imagePathConverter;
this.imageRenderer = imageRenderer;
this._defaultVars = new BehaviorSubject(defaultVars ? { ...defaultVars } : {});
this._externFunctions = new BehaviorSubject(externFunctions ? { ...externFunctions } : {});
this._removeDanglingUserMessages = new BehaviorSubject(removeDanglingUserMessages);
this._enabledSlashCommands = new BehaviorSubject(enableSlashCommand);
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?.();
}
initConvo(convo, options) {
if (this.initConvoCallback) {
this.initConvoCallback(convo);
}
for (const e in this.defaultVars) {
convo.defaultVars[e] = this.defaultVars[e];
}
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');
}
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);
});
this.convoCleanup = () => {
sub.unsubscribe();
sub2.unsubscribe();
};
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 appendUiMessageAsync(message) {
if (this.isDisposed) {
return false;
}
if (cmdReg.test(message)) {
if (!this._enabledSlashCommands.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 '/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 `})`;
}).join('\n');
this._mediaQueue.next([]);
}
if (message.trim()) {
convo.appendUserMessage(message);
this.triggerAppendUiMessageEvt(message);
}
await convo.completeAsync();
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() {
this.convo?.appendAssistantMessage(/*convo*/ `
/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
/clear - Clears all messages
/help - Prints this help message
`);
}
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;
}
}
const cmdReg = /^\s*\//;
//# sourceMappingURL=ConversationUiCtrl.js.map