@convo-lang/convo-lang
Version:
The language of AI
821 lines • 31 kB
JavaScript
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 `})`;
}).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