Skip to content

Commit 12d5dff

Browse files
authored
Intent-based preview (#1113)
* Hackhackhack * Find existing charms during preview generation * Thread preview through the stack * More examples * Remove duplicate method declaration * Remove searchCharms command * Tweak searchCharms formatting * Omit empty plan * Improve schema prompting + actually pass it through to code gen * Fix type error * Update LLM cache and tests There is still a bug with extending / referencing recipes and the generated schema. * Tweak prompt * Resolve type errors after rebase * Update cache
1 parent 8c5db81 commit 12d5dff

16 files changed

+421
-442
lines changed

charm/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
charmSchema,
77
processSchema,
88
} from "./manager.ts";
9+
export { searchCharms } from "./search.ts";
910
export {
1011
castNewRecipe,
1112
compileAndRunRecipe,

charm/src/iterate.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,17 @@ export async function iterate(
105105
const { iframe } = getIframeRecipe(charm);
106106

107107
const prevSpec = iframe?.spec;
108-
if (!plan?.spec) {
108+
if (!plan?.description) {
109109
throw new Error("No specification provided");
110110
}
111-
const newSpec = plan.spec;
111+
const newSpec = plan.description;
112112

113113
const { content: newIFrameSrc, llmRequestId } = await genSrc({
114114
src: iframe?.src,
115115
spec: prevSpec,
116116
newSpec,
117117
schema: iframe?.argumentSchema || { type: "object" },
118-
steps: plan?.steps,
118+
steps: plan?.features,
119119
}, optionsWithDefaults);
120120

121121
return {
@@ -327,7 +327,7 @@ async function singlePhaseCodeGeneration(
327327
});
328328
}
329329

330-
if (!form.plan?.spec || !form.plan?.steps) {
330+
if (!form.plan?.description || !form.plan?.features) {
331331
throw new Error("Plan is missing spec or steps");
332332
}
333333

@@ -336,18 +336,20 @@ async function singlePhaseCodeGeneration(
336336
const name = extractTitle(sourceCode, title); // Use the generated title as fallback
337337
const newRecipeSrc = buildFullRecipe({
338338
src: fullCode,
339-
spec: form.plan.spec,
340-
plan: Array.isArray(form.plan.steps)
341-
? form.plan.steps.map((step, index) => `${index + 1}. ${step}`).join("\n")
342-
: form.plan.steps,
339+
spec: form.plan.description,
340+
plan: Array.isArray(form.plan.features)
341+
? form.plan.features.map((step, index) => `${index + 1}. ${step}`).join(
342+
"\n",
343+
)
344+
: form.plan.features,
343345
goal: form.input.processedInput,
344346
argumentSchema: schema,
345347
resultSchema,
346348
name,
347349
});
348350

349351
return {
350-
newSpec: form.plan.spec,
352+
newSpec: form.plan.description,
351353
newIFrameSrc: fullCode,
352354
newRecipeSrc,
353355
name,
@@ -421,7 +423,7 @@ async function twoPhaseCodeGeneration(
421423
const { content: newIFrameSrc, llmRequestId } = await genSrc({
422424
newSpec,
423425
schema,
424-
steps: form.plan?.steps,
426+
steps: form.plan?.features,
425427
}, {
426428
model: form.meta.model,
427429
generationId: form.meta.generationId,

charm/src/search.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {
2+
Charm,
3+
charmId,
4+
CharmManager,
5+
DEFAULT_MODEL,
6+
} from "@commontools/charm";
7+
import { NAME, Recipe, recipe } from "@commontools/builder";
8+
import { LLMClient } from "@commontools/llm";
9+
import { Cell, recipeManager } from "@commontools/runner";
10+
11+
export type CharmSearchResult = {
12+
charm: Cell<Charm>;
13+
name: string;
14+
reason: string;
15+
};
16+
17+
export async function searchCharms(
18+
input: string,
19+
charmManager: CharmManager,
20+
): Promise<{
21+
charms: CharmSearchResult[];
22+
thinking: string;
23+
}> {
24+
try {
25+
const charms = charmManager.getCharms();
26+
await charmManager.sync(charms);
27+
const results = await Promise.all(
28+
charms.get().map(async (charm) => {
29+
const data = charm.get();
30+
const title = data?.[NAME] ?? "Untitled";
31+
32+
const recipeId = await charmManager.syncRecipe(charm);
33+
const recipe = recipeManager.recipeById(recipeId!)!;
34+
35+
return {
36+
title: title + ` (#${charmId(charm.entityId!)!.slice(-4)})`,
37+
description: (recipe as Recipe).argumentSchema.description,
38+
id: charmId(charm.entityId!)!,
39+
value: charm.entityId!,
40+
};
41+
}),
42+
);
43+
44+
// Early return if no charms are found
45+
if (!results.length) {
46+
return {
47+
thinking: "No charms are available to search through.",
48+
charms: [],
49+
};
50+
}
51+
52+
const response = await new LLMClient().sendRequest({
53+
system:
54+
`Pick up to the 3 most appropriate (if any) charms from the list that match the user's request:
55+
<charms>
56+
${
57+
results.map((result) =>
58+
`<charm id="${result.id}">
59+
<title>${result.title}</title>
60+
<description>${result.description}</description>
61+
</charm>`
62+
).join("\n ")
63+
}
64+
</charms>
65+
66+
When responding, you may include a terse paragraph of your reasoning within a <thinking> tag, then return a list of charms using <charm id="" name="...">Reason it's appropriate</charm> in the text.`,
67+
messages: [{ role: "user", content: input }],
68+
model: DEFAULT_MODEL,
69+
cache: false,
70+
metadata: {
71+
context: "workflow",
72+
workflow: "search-charms",
73+
generationId: crypto.randomUUID(),
74+
},
75+
});
76+
77+
// Parse the thinking tag content
78+
const thinkingMatch = response.content?.match(
79+
/<thinking>([\s\S]*?)<\/thinking>/,
80+
);
81+
const thinking = thinkingMatch ? thinkingMatch[1].trim() : "";
82+
83+
// Parse all charm tags
84+
const charmMatches = response.content?.matchAll(
85+
/<charm id="([^"]+)" name="([^"]+)">([\s\S]*?)<\/charm>/g,
86+
);
87+
88+
const selectedCharms = [];
89+
if (charmMatches) {
90+
for (const match of charmMatches) {
91+
const charmId = match[1];
92+
const charmName = match[2];
93+
const reason = match[3].trim();
94+
95+
// Find the original charm data from results
96+
const originalCharm = await charmManager.get(charmId);
97+
98+
if (originalCharm) {
99+
selectedCharms.push({
100+
charm: originalCharm,
101+
name: charmName,
102+
reason,
103+
});
104+
}
105+
}
106+
}
107+
108+
return {
109+
thinking,
110+
charms: selectedCharms,
111+
};
112+
} catch (error) {
113+
console.error("Search charms error:", error);
114+
115+
return {
116+
thinking: "An error occurred while searching for charms.",
117+
charms: [],
118+
};
119+
}
120+
}

charm/src/workflow.ts

Lines changed: 29 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { formatPromptWithMentions } from "./format.ts";
2121
import { castNewRecipe } from "./iterate.ts";
2222
import { VNode } from "@commontools/html";
2323
import { applyDefaults, GenerationOptions } from "@commontools/llm";
24+
import { CharmSearchResult, searchCharms } from "./search.ts";
2425

2526
export interface RecipeRecord {
2627
argumentSchema: JSONSchema; // Schema type from jsonschema
@@ -262,10 +263,26 @@ export async function generatePlan(
262263
{ existingSpec, existingSchema, existingCode },
263264
);
264265

266+
globalThis.dispatchEvent(
267+
new CustomEvent("job-update", {
268+
detail: {
269+
type: "job-update",
270+
jobId: form.meta.generationId,
271+
title: form.input.processedInput,
272+
status: `Search for relevant data ${form.meta.model}...`,
273+
},
274+
}),
275+
);
276+
277+
const { charms } = await searchCharms(
278+
form.input.processedInput,
279+
form.meta.charmManager,
280+
);
281+
265282
return {
266-
steps: result.steps,
267-
spec: result.spec,
268-
dataModel: result.dataModel,
283+
description: result.autocompletion,
284+
features: result.features,
285+
charms,
269286
};
270287
}
271288

@@ -291,9 +308,9 @@ export interface WorkflowForm {
291308

292309
// Planning information
293310
plan: {
294-
steps?: string[];
295-
spec?: string;
296-
dataModel?: string;
311+
features?: string[];
312+
description?: string;
313+
charms?: CharmSearchResult[];
297314
} | null;
298315

299316
generation: {
@@ -467,34 +484,7 @@ export async function fillPlanningSection(
467484

468485
const newForm = { ...form };
469486

470-
// Skip for empty inputs
471-
if (!form.input.rawInput || form.input.rawInput.trim().length === 0) {
472-
newForm.plan = {
473-
steps: [],
474-
};
475-
return newForm;
476-
}
477-
478-
let planningResult: WorkflowForm["plan"];
479-
// Generate new plan based on workflow type
480-
if (
481-
form.classification.workflowType === "fix" && form.input.existingCharm
482-
) {
483-
if (form.input.existingCharm) {
484-
const { spec, schema, code } = extractContext(form.input.existingCharm);
485-
486-
if (!form.plan) {
487-
form.plan = { spec };
488-
} else {
489-
form.plan.spec = spec;
490-
}
491-
}
492-
493-
planningResult = await generatePlan(form);
494-
} else {
495-
// For edit/imagine, generate everything
496-
planningResult = await generatePlan(form);
497-
}
487+
const planningResult = await generatePlan(form);
498488

499489
newForm.plan = planningResult;
500490
return newForm;
@@ -627,21 +617,12 @@ export async function processWorkflow(
627617
// Check for cancellation before starting work
628618
checkCancellation();
629619

620+
// Apply any prefilled values if provided
630621
if (options.prefill) {
631622
console.log("prefilling form", options.prefill);
632-
// Only use prefill values for fields that aren't null or undefined in the prefill
633-
// Intentionally omit the meta
634-
form = {
635-
...form,
636-
input: options.prefill?.input
637-
? { ...form.input, ...options.prefill.input }
638-
: form.input,
639-
classification: options.prefill?.classification || form.classification,
640-
plan: options.prefill?.plan || form.plan,
641-
generation: options.prefill?.generation || form.generation,
642-
searchResults: options.prefill?.searchResults || form.searchResults,
643-
spellToCast: options.prefill?.spellToCast || form.spellToCast,
644-
};
623+
form = { ...form, ...options.prefill };
624+
// Preserve the original meta section
625+
form.meta = { ...form.meta };
645626
}
646627

647628
// Step 1: Process input (mentions, references, etc.) if not already processed
@@ -827,7 +808,7 @@ export async function processWorkflow(
827808
}
828809

829810
// Step 3: Planning if not already planned
830-
if (!form.plan || !form.plan.spec || !form.plan.steps) {
811+
if (!form.plan) {
831812
console.log("planning task");
832813
globalThis.dispatchEvent(
833814
new CustomEvent("job-update", {

jumble/integration/basic-flow.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ Deno.test({
219219
await page.keyboard.press("Enter");
220220

221221
await sleep(500);
222-
await page.keyboard.type("count the values in @v");
222+
await page.keyboard.type("show the data from @v");
223223
await sleep(500);
224224
await page.keyboard.press("Tab");
225225
await sleep(500);
@@ -238,7 +238,7 @@ Deno.test({
238238
await waitForSelectorWithText(
239239
page,
240240
"a[aria-roledescription='charm-link']",
241-
"Value Counter Utility",
241+
"SimpleValue2 Viewer",
242242
);
243243

244244
// FIXME(ja): how to look at the actual iframe content?

jumble/integration/cache/llm-api-cache/0560b002a3cd3008a12a2bdef7b434d663c4dd08ebd0d1fcf34191e2ae7dcbcc.json

Lines changed: 0 additions & 15 deletions
This file was deleted.

0 commit comments

Comments
 (0)