Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 52 additions & 38 deletions charm/src/iframe/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,20 @@ Create an interactive React component that fulfills the user's request. Focus on
</code_structure>

<charm_api>
- **useReactiveCell(key, defaultValue)** - Persistent data storage with reactive updates
- **useReactiveCell(keyPath: string[])** - Persistent data storage with reactive updates
- **generateText({ system, messages })** - Generate text via Large Language Model
- **generateObject({ system, messages })** - Generate JSON object via Large Language Model
- **readWebpage(url)** - Fetch and parse external web content
- **generateImage(prompt)** - Create AI-generated images

<use_doc>
## Important Note About useReactiveCell
<use_reactive_cell>
## Important Note About useReactiveCell(keyPath: string[])
- **useReactiveCell is a React Hook** and must follow all React hook rules
- It should only be used for persistent state and must draw from the provided schema
- For any ephemeral state, use \`React.useState\`
- Only call useReactiveCell at the top level of your function components or custom hooks
- Do not call useReactiveCell inside loops, conditions, or nested functions
- useReactiveCell cannot be used outside of \`onReady\` components - it must be called during rendering
</use_doc>
</use_reactive_cell>
</charm_api>

<importing_libraries>
Expand All @@ -149,9 +148,10 @@ ${security()}
<guide>
# SDK Usage Guide

<persistent-reactive-state>
## 1. \`useReactiveCell\` Hook

The \`useReactiveCell\` hook binds to a reactive cell given key and returns a tuple \`[doc, setDoc]\`:
The \`useReactiveCell\` hook binds to a reactive cell given a key path and returns a tuple \`[doc, setDoc]\`:

Any keys from the view-model-schema are valid for useReactiveCell, any other keys will fail. Provide a default as the second argument, **do not set an initial value explicitly**.

Expand All @@ -175,12 +175,8 @@ For this schema:
\`\`\`jsx
function CounterComponent() {
// Correct: useReactiveCell called at top level of component
const [counter, setCounter] = useReactiveCell("counter", -1); // default

// Incorrect: would cause errors
// if(something) {
// const [data, setData] = useReactiveCell("data", {}); // Never do this!
// }
const [counter, setCounter] = useReactiveCell("counter");
const [maxValue, setMaxValue] = useReactiveCell(['settings', 'maxValue']);

const onIncrement = useCallback(() => {
// writing to the cell automatically triggers a re-render
Expand All @@ -194,8 +190,13 @@ function CounterComponent() {
);
}
\`\`\`
</persistent-reactive-state>

## 2. \`generateText\` Function
<generating_content>
Several APIs exist for generating text, JSON or image urls.

<text>
\`generateText({ system, messages}): Promise<string>\`

\`\`\`jsx
async function fetchLLMResponse() {
Expand All @@ -206,17 +207,21 @@ async function fetchLLMResponse() {
console.log('LLM responded:', result);
}
\`\`\`
</text>

## 3. \`generateObject\` (JSON) Function
<json>

Important: ensure you explain the intended schema of the response in the prompt.
\`window.generateObject({ system, messages }): Promise<object>\`

You must give the exact schema of the response in the prompt.

For example: "Generate a traditional Vietnamese recipe in JSON format, with the
following properties: name (string), ingredients (array of strings),
instructions (array of strings)"

\`generateObject\` returns a parsed object already, or \`undefined\`.
\`generateObject\` returns a parsed object already, or \`undefined\`. Be defensive working with the response, the LLM may make mistakes.

<example>
\`\`\`jsx
const promptPayload = ;
const result = await generateObject({
Expand All @@ -240,14 +245,14 @@ console.log('JSON response from llm:', result);
// }
// ]
\`\`\`
</example>

ANOTHER NOTE: Language model requests are globally cached based on your prompt.
<example>
NOTE: Language model requests are globally cached based on your prompt.
This means that identical requests will return the same result. If your llm use
requires unique results on every request, make sure to introduce a cache-breaking
string such as a timestamp or incrementing number/id.

Another example:

\`\`\`jsx
// To avoid the cache we'll use a cache-busting string.
const cacheBreaker = Date.now();
Expand Down Expand Up @@ -293,26 +298,38 @@ console.log('JSON response from llm:', result);
// "cookTime": 30
// }
\`\`\`
</example>

## 4. \`readWebpage\` Function
</json>


<images>

Synchronous, generates a URL that will load the image.

\`\`\`jsx
async function fetchFromUrl() {
const url = 'https://twopm.studio';
const result = await readWebpage(url);
console.log('Markdown:', result.content);
function ImageComponent() {
return <img src={generateImageUrl("A beautiful sunset over mountains")} alt="Generated landscape" />;
}
\`\`\`

## 5. generateImage Function
</images>
</generating_content>

<fetching_content>
\`readWebpage(url: string): Promise<string>\` Returns markdown format.

\`\`\`jsx
function ImageComponent() {
return <img src={generateImage("A beautiful sunset over mountains")} alt="Generated landscape" />;
async function fetchFromUrl() {
const url = 'https://twopm.studio';
const result = await readWebpage(url);
console.log('Markdown:', result.content);
}

\`\`\`
## 6. Using the Interface Functions
</fetching_content>

<code_structure>
All generated code must follow this pattern:

\`\`\`javascript
// Import from modern ESM libraries:
Expand Down Expand Up @@ -428,11 +445,9 @@ function onReady(mount, sourceData, libs) {
const { useSpring, animated } = libs['@react-spring/web']; // Access imported module

function MyApp() {
const [count, setCount] = useReactiveCell('count', 0);
const [todos, setTodos] = useReactiveCell('todos', [
{ id: 1, text: 'Learn React', completed: false },
{ id: 2, text: 'Build a Todo App', completed: false }
]);
const [count, setCount] = useReactiveCell('count');
const [todos, setTodos] = useReactiveCell('todos');

const props = useSpring({
from: { opacity: 0 },
to: { opacity: 1 }
Expand Down Expand Up @@ -486,19 +501,18 @@ Create an interactive React component that fulfills the user's request. Focus on
4. For form handling, use \`onClick\` handlers instead of \`onSubmit\`

## Available APIs
- **useReactiveCell(key, defaultValue)** - Persistent data storage with reactive updates
- **useReactiveCell(keyPath: string[])** - Persistent data storage with reactive updates
- **generateText({ system, messages })** - Generate text via Large Language Model
- **generateObject({ system, messages })** - Generate JSON object via Large Language Model
- **readWebpage(url)** - Fetch and parse external web content
- **generateImage(prompt)** - Create AI-generated images

## Important Note About useReactiveCell
## Important Note About useReactiveCell(keyPath: string[])
- **useReactiveCell is a React Hook** and must follow all React hook rules
- It should only be used for persistent state and must draw from the provided schema
- For any ephemeral state, use \`React.useState\`
- Only call useReactiveCell at the top level of your function components or custom hooks
- Do not call useReactiveCell inside loops, conditions, or nested functions
- useReactiveCell cannot be used outside of \`onReady\` components - it must be called during rendering

## Library Usage
- Request additional libraries in \`onLoad\` by returning an array of module names
Expand All @@ -513,7 +527,7 @@ ${security()}

## 1. \`useReactiveCell\` Hook

The \`useReactiveCell\` hook binds to a reactive cell given key and returns a tuple \`[doc, setDoc]\`:
The \`useReactiveCell\` hook binds to a reactive cell given a key path and returns a tuple \`[doc, setDoc]\`:

Any keys from the schema are valid for useReactiveCell, any other keys will fail. Provide a default as the second argument, **do not set an initial value explicitly**.

Expand Down
2 changes: 1 addition & 1 deletion charm/src/iterate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export async function iterate(
const { iframe } = getIframeRecipe(charm);

const prevSpec = iframe?.spec;
if (!plan?.description) {
if (plan?.description === undefined) {
throw new Error("No specification provided");
}
const newSpec = plan.description;
Expand Down
122 changes: 75 additions & 47 deletions jumble/public/module/charm/sandbox/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,61 +7,89 @@ import * as Babel from "https://esm.sh/@babel/standalone"
window.React = React
window.ReactDOM = ReactDOM
window.Babel = Babel

window.useReactiveCell = function (key) {
// Track if we've received a response from the parent
const [received, setReceived] = React.useState(false)
// Initialize state with defaultValue
const [doc, setDocState] = React.useState(undefined)

window.useState = window.React.useState
window.useEffect = window.React.useEffect
window.useCallback = window.React.useCallback

window.useReactiveCell = function useReactiveCell(pathOrKey) {
const pathArr = Array.isArray(pathOrKey) ? pathOrKey : [pathOrKey];
const rootKey = pathArr[0]; // the key used for IPC
const nestedPath = pathArr.slice(1); // [] when we stay at the root

/* ------------------------------------------------------------------ utils */
const getNested = (obj, p = []) =>
p.reduce((acc, k) => (acc != null ? acc[k] : undefined), obj);

const setNested = (obj, p, val) => {
if (p.length === 0) return val;
const [k, ...rest] = p;
const clone =
typeof k === "number"
? Array.isArray(obj) ? obj.slice() : [] // keep arrays as arrays
: obj && typeof obj === "object"
? { ...obj }
: {};
clone[k] = setNested(clone[k], rest, val);
return clone;
};
/* ------------------------------------------------------------------------ */

// Last full root value we have seen.
const rootRef = React.useRef(
window.sourceData ? window.sourceData[rootKey] : undefined,
);

// Local state holds ONLY the nested value the caller cares about.
const [received, setReceived] = React.useState(false);
const [doc, setDocState] = React.useState(() =>
getNested(rootRef.current, nestedPath)
);

/* -------------------------------- listen for updates from the host ------ */
React.useEffect(() => {
// Handler for document updates
function handleMessage(event) {
function handleMessage(e) {
if (
event.data &&
event.data.type === "update" &&
event.data.data[0] === key
e.data &&
e.data.type === "update" &&
e.data.data?.[0] === rootKey
) {
// Mark that we've received a response
setReceived(true)

// Update the state with the received value or null if undefined
const value = event.data.data[1]
console.log("useReactiveCell", key, "updated", value)
setDocState(value)
const newRoot = e.data.data[1];
rootRef.current = newRoot;
setDocState(getNested(newRoot, nestedPath));
setReceived(true);
}
}

window.addEventListener("message", handleMessage)

// Subscribe to the specific key
window.parent.postMessage({ type: "subscribe", data: [key] }, "*")
window.parent.postMessage({ type: "read", data: key }, "*")
window.addEventListener("message", handleMessage);
window.parent.postMessage({ type: "subscribe", data: [rootKey] }, "*");
window.parent.postMessage({ type: "read", data: rootKey }, "*");

return () => {
window.removeEventListener("message", handleMessage)
window.parent.postMessage({ type: "unsubscribe", data: [key] }, "*")
}
}, [key])
window.removeEventListener("message", handleMessage);
window.parent.postMessage({ type: "unsubscribe", data: [rootKey] }, "*");
};
}, [rootKey, nestedPath.join(".")]); // Dependency on stringified path
Copy link

Copilot AI Apr 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Using nestedPath.join(".") in the dependency array could be problematic if any key contains periods. Consider an alternative approach for dependency tracking that avoids potential collisions in key names.

Suggested change
}, [rootKey, nestedPath.join(".")]); // Dependency on stringified path
}, [rootKey, nestedPath]); // Dependency on array reference

Copilot uses AI. Check for mistakes.
/* ------------------------------------------------------------------------ */

// Update function
/* ----------------------------- setter ----------------------------------- */
const updateDoc = newValue => {
if (typeof newValue === "function") {
newValue = newValue(doc)
}
console.log("useReactiveCell", key, "written", newValue)
setDocState(newValue)
window.parent.postMessage({ type: "write", data: [key, newValue] }, "*")
}
if (typeof newValue === "function") newValue = newValue(doc);

// Build the new *root* object immutably
const newRoot = setNested(rootRef.current ?? {}, nestedPath, newValue);
rootRef.current = newRoot;
setDocState(newValue);

window.parent.postMessage({ type: "write", data: [rootKey, newRoot] }, "*");
};
/* ------------------------------------------------------------------------ */

// If we never received an explicit update yet, fall back to pre-loaded data
const fallback = getNested(window.sourceData?.[rootKey], nestedPath);

return [received ? doc : fallback, updateDoc];
};

// If we have not yet received response from the host we use field from the
// sourceData which was preloaded via `subscribeToSource` during initialization
// in the `initializeApp`.
// ⚠️ Please note that value we prefetched still could be out of date because
// `*` subscription is removed in the iframe-ctx.ts file which could lead
// charm to make wrong conclusion and overwrite key.
return [received ? doc : window.sourceData[key], updateDoc]
}

window.useDoc = window.useReactiveCell;

Expand Down Expand Up @@ -106,7 +134,7 @@ window.generateText = function ({ system, messages }) {
return window.llm({
system,
messages,
}).then(result => result.content)
})
}

window.generateObject = function ({ system, messages }) {
Expand All @@ -115,7 +143,6 @@ window.generateObject = function ({ system, messages }) {
messages,
mode: 'json'
})
.then(result => result.content)
.then(result => {
try {
// Handle possible control characters and escape sequences
Expand Down Expand Up @@ -215,11 +242,12 @@ window.readWebpage = (function () {
return readWebpage
})()

// Define generateImage utility with React available
window.generateImage = function (prompt) {
return "/api/ai/img?prompt=" + encodeURIComponent(prompt)
}

window.generateImageUrl = window.generateImage;

// Error handling
window.onerror = function (message, source, lineno, colno, error) {
window.parent.postMessage(
Expand Down
2 changes: 1 addition & 1 deletion llm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const DEFAULT_MODEL_NAME: ModelName =

// NOTE(ja): This should be an array of models, the first model will be tried, if it
// fails, the second model will be tried, etc.
export const DEFAULT_IFRAME_MODELS: ModelName = "google:gemini-2.0-flash";
export const DEFAULT_IFRAME_MODELS: ModelName = "openai:gpt-4.1-nano";

export type LLMResponse = {
content: string;
Expand Down
Loading