import { Asset, Resource } from "@noya-app/noya-schemas"; import { uuid } from "@noya-app/noya-utils"; import { Observable, set } from "@noya-app/observable"; import { Static, Type } from "@sinclair/typebox"; import { ActivityEventsManager } from "./ActivityEventsManager"; import { AIManager } from "./AIManager"; import { AssetManager } from "./AssetManager"; import { createOrCastValue } from "./checkType"; import { ConnectionEventManager } from "./ConnectionEventManager"; import { diff } from "./diff"; import { EvalManager, SafeEval } from "./EvalManager"; import { ExtendedPatch } from "./extended"; import { FilePropertyManager } from "./FilePropertyManager"; import { HistoryEntries } from "./historyEntries"; import { IOManager } from "./IOManager"; import { LogManager } from "./LogManager"; import { MenuManager } from "./MenuManager"; import { MultiplayerPatchMetadata, MultiplayerStateManager, MultiplayerStateManagerError, MultiplayerStateManagerOptions, SharedConnectionDataManager, } from "./multiplayer"; import { PipelineManager } from "./PipelineManager"; import { PublishingManager } from "./PublishingManager"; import { GitManager } from "./GitManager"; import { ResourceManager } from "./ResourceManager"; import { SandboxManager } from "./SandboxManager"; import { RPCManager } from "./rpcManager"; import { ScriptEvaluatorManager } from "./ScriptEvaluatorManager"; import { Secret, SecretManager } from "./SecretManager"; import { HistoryEntry } from "./stateManager"; import type { AvoidRect } from "./sync/parentFrameMessages"; import { TaskManager } from "./TaskManager"; import { TranscriptionManager } from "./TranscriptionManager"; import { UserManager } from "./UserManager"; export type NoyaManagerOptions = MultiplayerStateManagerOptions & { initialAssets?: Asset[]; initialSecrets?: Secret[]; initialResources?: Resource[]; initialFileName?: string; registerDefaultAITools?: boolean; safeEval?: SafeEval; /** Whether git operations should include base64-encoded file contents in responses */ gitIncludeContent?: boolean; }; export function defaultMergeHistoryEntries< S, M extends MultiplayerPatchMetadata, >({ previous, next, }: { previous: HistoryEntry; next: HistoryEntry; }) { if ( previous.metadata.name !== undefined && previous.metadata.name === next.metadata.name && previous.metadata.timestamp + 500 > next.metadata.timestamp ) { return HistoryEntries.merge({ previous, next }); } } export class NoyaManager< S, M extends object = object, E extends object = object, MenuT extends string = string, I extends Record = Record, > { id = uuid(); multiplayerStateManager: MultiplayerStateManager; rpcManager: RPCManager; pipelineManager: PipelineManager; scriptEvaluatorManager: ScriptEvaluatorManager; secretManager: SecretManager; aiManager: AIManager; ioManager: IOManager; sharedConnectionDataManager = new SharedConnectionDataManager(); assetManager: AssetManager; userManager: UserManager; taskManager = new TaskManager(); connectionEventManager = new ConnectionEventManager(); menuManager = new MenuManager(); evalManager: EvalManager; publishingManager = new PublishingManager(); resourceManager: ResourceManager; activityEventsManager: ActivityEventsManager; filePropertyManager: FilePropertyManager; logManager: LogManager; transcriptionManager: TranscriptionManager; sandboxManager: SandboxManager; gitManager: GitManager; isProcessing$: Observable; // Rectangles to avoid when rendering UI in embedded mode (set by parent) avoidRects$ = new Observable([]); initialState: S | (() => S); options: NoyaManagerOptions; unrecoverableError = new Observable( undefined ); constructor( initialState: S | (() => S), options: NoyaManagerOptions = {} ) { options = { ...options, getSharedConnectionData: options.getSharedConnectionData ?? (() => this.sharedConnectionDataManager.data$.get() ?? {}), mergeHistoryEntries: options.mergeHistoryEntries === undefined ? defaultMergeHistoryEntries : options.mergeHistoryEntries, }; this.multiplayerStateManager = new MultiplayerStateManager( initialState, options ); this.initialState = initialState; this.options = options; this.assetManager = new AssetManager({ initialAssets: options.initialAssets, }); this.rpcManager = new RPCManager({ debug: this.options.debug }); this.userManager = new UserManager(this.rpcManager); this.pipelineManager = new PipelineManager(this.rpcManager); this.scriptEvaluatorManager = new ScriptEvaluatorManager(this.rpcManager); this.secretManager = new SecretManager(this.rpcManager, { initialSecrets: options.initialSecrets, }); this.aiManager = new AIManager(this.rpcManager); this.ioManager = new IOManager(this.rpcManager); this.evalManager = new EvalManager({ safeEval: this.options.safeEval, }); this.resourceManager = new ResourceManager(this.rpcManager, { initialResources: options.initialResources, sendMessage: (message) => this.multiplayerStateManager.sendMessage(message), subscribeConnectionEvents: (handler) => this.connectionEventManager.events$.subscribe((events) => { // Forward only receive events const last = events.at(-1); if (!last) return; handler(last); }), resolveAssetId: (idOrStableId) => this.assetManager._getServerAssetId(idOrStableId) ?? idOrStableId, }); this.sandboxManager = new SandboxManager(this.rpcManager); this.gitManager = new GitManager(this.rpcManager, this.assetManager, { sendMessage: (message) => this.multiplayerStateManager.sendMessage(message), subscribeConnectionEvents: (handler) => this.connectionEventManager.events$.subscribe((events) => { const last = events.at(-1); if (!last) return; handler(last); }), includeContent: options.gitIncludeContent, }); this.filePropertyManager = new FilePropertyManager(this.rpcManager, { initialName: options.initialFileName, }); this.logManager = new LogManager(); this.transcriptionManager = new TranscriptionManager( this.rpcManager, this.assetManager ); this.activityEventsManager = new ActivityEventsManager((message) => this.multiplayerStateManager.sendMessage(message as any) ); const resourceProcessing$ = this.resourceManager.patches$.map( (patches) => patches.length > 0 ); this.isProcessing$ = Observable.combine( [resourceProcessing$, this.filePropertyManager.isUpdating$], ([resourceProcessing, isRenaming]) => resourceProcessing || isRenaming ); this.multiplayerStateManager.errorEmitter.addListener((error) => { switch (error.reason) { case "invalidDataType": case "schemaMismatch": case "outdatedSchema": case "dataMigrationFailure": case "schemaMigration": this.unrecoverableError.set(error); break; } }); if (this.options.registerDefaultAITools !== false) { this.aiManager.systemMessage$.set( [ `Please act as an efficient, competent, conscientious, and industrious professional assistant. Help the user achieve their goals, and you do so in a way that is as efficient as possible, without unnecessary fluff, but also without sacrificing professionalism. Always be polite and respectful, and prefer brevity over verbosity. `, this.multiplayerStateManager.schema && `Their document state conforms to the following schema: \`\`\`\n${JSON.stringify(this.multiplayerStateManager.schema, null, 2)}\n\`\`\``, `They have also provided you with functions you can call to initiate actions on their behalf, or functions you can call to receive more information. Please assist them as best you can. You can ask them for clarifying questions if needed, but don't be annoying about it. If you can determine a single best course of action, update the user's document state automatically without further questions. If you can reasonably 'fill in the blanks' yourself, do so. If you would like to call a function, call it without saying anything else.`, ] .filter(Boolean) .join("\n") ); this.aiManager.registerTool({ functionName: "createMutator", description: `Create a JavaScript function called "updateState" that can be invoked to mutate the document state. The function must be a named function. The function must accept the current document state as an argument and mutate it directly. There should be no return value. E.g. \`\`\`js function updateState(state) { state.someProperty = "new value"; state.someArray.push({ id: crypto.randomUUID(), name: "new item", }); } \`\`\` You may use the \`crypto\` object to generate random UUIDs.`, parameters: createMutatorParametersSchema, onCall: async (parameters) => { const state = this.multiplayerStateManager.optimisticState$.get(); const sourceCode = parameters.sourceCode as string; const code = `const __state = ${JSON.stringify(state, null, 2)}; ${sourceCode}; updateState(__state); __state;`; let newState; try { newState = await this.evalManager.evaluate(code); } catch (error) { console.error(error); return internalErrorMessage; } let patches: ExtendedPatch[]; let inversePatches: ExtendedPatch[]; try { [patches, inversePatches] = diff( this.multiplayerStateManager.optimisticState$.get(), newState ); } catch (error) { console.error(error); return internalErrorMessage; } try { this.multiplayerStateManager.applyPatch({ patches, inversePatches, }); } catch (error) { console.error(error); return internalErrorMessage; } return typeof parameters.message === "string" ? parameters.message : ""; }, }); const setPropertyParametersSchema = Type.Object({ message: Type.String({ description: `A message that summarizes the changes made to the document state.`, }), property: Type.Array( Type.String({ description: `The path to the property to set. For example, to update the content of a property {"files": {"index.tsx": {"content": "..."}}}, you would use ["files", "index.tsx", "content"].`, }) ), value: Type.Any({ description: `The value to set the property to.`, }), }); this.aiManager.registerTool({ functionName: "setProperty", description: `Set a property on the document state`, parameters: setPropertyParametersSchema, onCall: async (parameters) => { try { const parsedParameters = createOrCastValue({ schema: setPropertyParametersSchema, value: parameters, defs: {}, }); this.multiplayerStateManager.editDraft((draft) => { set( draft, parsedParameters.property as any, parsedParameters.value ); }); return parsedParameters.message; } catch (error) { console.error(error); return internalErrorMessage; } }, }); } } enqueueInput = >( queue: K, payload: I[K] ) => { const connectionId = this.userManager.currentConnectionId$.get(); this.multiplayerStateManager.sendMessage({ type: "enqueueInput", queue, payload, ...(connectionId && { connectionId }), }); }; forceInit = () => { const initialState = this.initialState instanceof Function ? this.initialState() : this.initialState; const schema = this.options?.schema; this.multiplayerStateManager.sm.clearHistory(); this.multiplayerStateManager.sendInit({ force: true, state: initialState, schema, }); }; undo = () => this.multiplayerStateManager.undo(); redo = () => this.multiplayerStateManager.redo(); canUndo = () => this.multiplayerStateManager.canUndo(); canRedo = () => this.multiplayerStateManager.canRedo(); restoreSnapshot = async (options: { fileId: string; fileVersionId: string; }) => { const response = await this.rpcManager.requestRoute( "POST /api/snapshots/restore", { body: JSON.stringify(options), headers: { "Content-Type": "application/json", }, } ); return JSON.parse(response.body) as unknown; }; } export const createMutatorParametersSchema = Type.Object({ sourceCode: Type.String({ description: "The source code of the mutator function.", }), message: Type.String({ description: `A message that summarizes the changes made to the document state.`, }), }); export type CreateMutatorParameters = Static< typeof createMutatorParametersSchema >; const internalErrorMessage = "An error occurred internally, no changes were made.";