diff --git a/packages/patterns/chat-launcher.tsx b/packages/patterns/chat-launcher.tsx deleted file mode 100644 index 25fb1e1e8..000000000 --- a/packages/patterns/chat-launcher.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/// -import { - Cell, - cell, - createCell, - derive, - h, - handler, - ifElse, - lift, - NAME, - navigateTo, - recipe, - UI, -} from "commontools"; - -import Chat from "./chatbot.tsx"; - -interface ChatEntry { - charm: any; - timestamp: string; - label: string; -} - -const createChatsCell = lift( - { - type: "object", - properties: { - isInitialized: { type: "boolean", default: false, asCell: true }, - storedCellRef: { type: "object", asCell: true }, - }, - }, - undefined, - ({ isInitialized, storedCellRef }) => { - if (!isInitialized.get()) { - const newCellRef = createCell(undefined, "chatsList"); - newCellRef.set([]); - storedCellRef.set(newCellRef); - isInitialized.set(true); - return { - chatsCell: newCellRef, - }; - } - return { - chatsCell: storedCellRef, - }; - }, -); - -const addChatAndNavigate = lift( - { - type: "object", - properties: { - chatEntry: { type: "object" }, - chatsCell: { type: "array", asCell: true }, - isInitialized: { type: "boolean", asCell: true }, - }, - }, - undefined, - ({ chatEntry, chatsCell, isInitialized }) => { - if (!isInitialized.get()) { - if (chatsCell) { - chatsCell.push(chatEntry); - isInitialized.set(true); - return navigateTo(chatEntry.charm); - } - } - return undefined; - }, -); - -const newChat = handler }>( - (_, { chatsCell }) => { - const isInitialized = cell(false); - - const charm = Chat({ - messages: [], - tools: undefined, - }); - - const timestamp = new Date().toISOString(); - - const chatEntry: ChatEntry = { - charm, - timestamp, - label: timestamp, - }; - - return addChatAndNavigate({ chatEntry, chatsCell, isInitialized }); - }, -); - -const goToChat = handler( - (_, { charm }) => { - return navigateTo(charm); - }, -); - -export default recipe("Chat Launcher", () => { - const { chatsCell } = createChatsCell({ - isInitialized: cell(false), - storedCellRef: cell(), - }); - - return { - [NAME]: "Chat Launcher", - [UI]: ( -
-

Chats

- - - New chat - - - {ifElse( - !chatsCell?.length, -
No chats yet
, -
    - {chatsCell.map((entry: ChatEntry, index: number) => ( -
  • - - {entry.label} - -
  • - ))} -
, - )} -
- ), - chatsCell, - }; -}); diff --git a/packages/patterns/chatbot-list-view.tsx b/packages/patterns/chatbot-list-view.tsx new file mode 100644 index 000000000..06d9386ba --- /dev/null +++ b/packages/patterns/chatbot-list-view.tsx @@ -0,0 +1,176 @@ +/// +import { + Cell, + cell, + Default, + derive, + h, + handler, + ID, + ifElse, + lift, + NAME, + navigateTo, + recipe, + UI, +} from "commontools"; + +import Chat from "./chatbot.tsx"; + +type CharmEntry = { + [ID]: string; // randomId is a string + local_id: string; // same as ID but easier to access + charm: any; +}; + +type Input = { + selectedCharm: Default<{ charm: any }, { charm: undefined }>; + charmsList: Default; +}; + +type Output = Input; + +// this will be called whenever charm or selectedCharm changes +// pass isInitialized to make sure we dont call this each time +// we change selectedCharm, otherwise creates a loop +const storeCharm = lift( + { + type: "object", + properties: { + charm: { type: "object" }, + selectedCharm: { + type: "object", + properties: { + charm: { type: "object" }, + }, + asCell: true, + }, + charmsList: { + type: "array", + items: { + type: "object", + properties: { + local_id: { type: "string" }, // display ID for the charm + charm: { type: "object" }, + }, + }, + asCell: true, + }, + isInitialized: { type: "boolean", asCell: true }, + }, + }, + undefined, + ({ charm, selectedCharm, charmsList, isInitialized }) => { + if (!isInitialized.get()) { + console.log( + "storeCharm storing charm:", + JSON.stringify(charm), + ); + selectedCharm.set({ charm }); + + // create the chat charm with a custom name including a random suffix + const randomId = Math.random().toString(36).substring(2, 10); // Random 8-char string + charmsList.push({ [ID]: randomId, local_id: randomId, charm }); + + isInitialized.set(true); + return charm; + } else { + console.log("storeCharm: already initialized"); + } + return undefined; + }, +); + +const createChatRecipe = handler< + unknown, + { selectedCharm: Cell<{ charm: any }>; charmsList: Cell } +>( + (_, { selectedCharm, charmsList }) => { + const isInitialized = cell(false); + + const charm = Chat({ + messages: [], + tools: undefined, + }); + // store the charm ref in a cell (pass isInitialized to prevent recursive calls) + return storeCharm({ charm, selectedCharm, charmsList, isInitialized }); + }, +); + +const selectCharm = handler< + unknown, + { selectedCharm: Cell<{ charm: any }>; charm: any } +>( + (_, { selectedCharm, charm }) => { + console.log("selectCharm: updating selectedCharm to ", charm); + selectedCharm.set({ charm }); + return selectedCharm; + }, +); + +const logCharmsList = lift( + { + type: "object", + properties: { + charmsList: { + type: "array", + items: { + type: "object", + properties: { + local_id: { type: "string" }, // display ID for the charm + charm: { type: "object" }, + }, + }, + asCell: true, + }, + }, + }, + undefined, + ({ charmsList }) => { + console.log("logCharmsList: ", charmsList.get()); + return charmsList; + }, +); + +// create the named cell inside the recipe body, so we do it just once +export default recipe( + "Launcher", + ({ selectedCharm, charmsList }) => { + logCharmsList({ charmsList }); + + return { + [NAME]: "Launcher", + [UI]: ( +
+ + Create New Chat + + +
+

Chat List

+
+
+ {charmsList.map((charmEntry, i) => ( +
+ index={i} chat ID: {charmEntry.local_id} + + LOAD + +
+ ))} +
+ +
--- end chat list ---
+
{selectedCharm.charm}
+
+ ), + selectedCharm, + charmsList, + }; + }, +); diff --git a/packages/runner/src/builtins/if-else.ts b/packages/runner/src/builtins/if-else.ts index 1f8289ea8..03c35f2a5 100644 --- a/packages/runner/src/builtins/if-else.ts +++ b/packages/runner/src/builtins/if-else.ts @@ -27,6 +27,7 @@ export function ifElse( const ref = inputsWithLog.key(condition ? 1 : 2) .getAsLink({ base: result }); - resultWithLog.send(ref); + // When writing links, we need to use setRaw + resultWithLog.setRaw(ref); }; } diff --git a/packages/runner/src/builtins/map.ts b/packages/runner/src/builtins/map.ts index c4d5c5328..cfcef2d81 100644 --- a/packages/runner/src/builtins/map.ts +++ b/packages/runner/src/builtins/map.ts @@ -76,7 +76,7 @@ export function map( // .getRaw() because we want the recipe itself and avoid following the // aliases in the recipe - const opRecipe = op.getRaw() as any; + const opRecipe = op.getRaw(); // If the result's value is undefined, set it to the empty array. if (resultWithLog.get() === undefined) { @@ -98,6 +98,11 @@ export function map( } const newArrayValue = resultWithLog.get().slice(0, initializedUpTo); + // If we rollback a change to result cell, and that causes it to be + // shorter, we need to re-initialize some cells. + if (initializedUpTo > newArrayValue.length) { + initializedUpTo = newArrayValue.length; + } // Add values that have been appended while (initializedUpTo < list.length) { const resultCell = runtime.getCell( diff --git a/packages/runner/test/recipes.test.ts b/packages/runner/test/recipes.test.ts index 3b45277c2..d06035055 100644 --- a/packages/runner/test/recipes.test.ts +++ b/packages/runner/test/recipes.test.ts @@ -21,10 +21,12 @@ describe("Recipe Runner", () => { let runtime: Runtime; let tx: IExtendedStorageTransaction; let lift: ReturnType["commontools"]["lift"]; + let derive: ReturnType["commontools"]["derive"]; let recipe: ReturnType["commontools"]["recipe"]; let createCell: ReturnType["commontools"]["createCell"]; let handler: ReturnType["commontools"]["handler"]; let byRef: ReturnType["commontools"]["byRef"]; + let ifElse: ReturnType["commontools"]["ifElse"]; let TYPE: ReturnType["commontools"]["TYPE"]; beforeEach(() => { @@ -41,10 +43,12 @@ describe("Recipe Runner", () => { const { commontools } = createBuilder(runtime); ({ lift, + derive, recipe, createCell, handler, byRef, + ifElse, TYPE, } = commontools); }); @@ -1291,4 +1295,78 @@ describe("Recipe Runner", () => { expect(isCell(listCell.get()[0])).toBe(true); expect(listCell.get()[0].equals(testCell.get())).toBe(true); }); + + it("correctly handles the ifElse values with nested derives", async () => { + const InputSchema = { + "type": "object", + "properties": { + "expandChat": { "type": "boolean" }, + }, + } as const satisfies JSONSchema; + + const StateSchema = { + "type": "object", + "properties": { + "expandChat": { "type": "boolean" }, + "text": { "type": "string" }, + }, + "asCell": true, + } as const satisfies JSONSchema; + const expandHandler = handler( + InputSchema, + StateSchema, + ({ expandChat }, state) => { + state.key("expandChat").set(expandChat); + }, + ); + + const ifElseRecipe = recipe<{ expandChat: boolean }>( + "ifElse Recipe", + ({ expandChat }) => { + const optionA = derive(expandChat, (t) => t ? "A" : "a"); + const optionB = derive(expandChat, (t) => t ? "B" : "b"); + + return { + expandChat, + text: ifElse( + expandChat, + optionA, + optionB, + ), + stream: expandHandler({ expandChat }), + }; + }, + ); + + const charmCell = runtime.getCell< + { expandChat: boolean; text: string; stream: any } + >( + space, + "ifElse should work", + ifElseRecipe.resultSchema, + tx, + ); + + const charm = runtime.run( + tx, + ifElseRecipe, + { expandChat: true }, + charmCell, + ); + + tx.commit(); + + await runtime.idle(); + + // Toggle + charm.key("stream").send({ expandChat: true }); + await runtime.idle(); + + expect(charm.key("text").get()).toEqual("A"); + + charm.key("stream").send({ expandChat: false }); + await runtime.idle(); + + expect(charm.key("text").get()).toEqual("b"); + }); });