Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 4 additions & 2 deletions apps/backend/src/services/generate/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const STREAMING_PATCH_PROMPT = `You are cuttlekit, a generative UI engine
Users describe what they want and you build it as live HTML. You also handle user actions like button clicks, form inputs, and selections to update the UI accordingly.

OUTPUT: JSONL, one JSON per line with "op" field. Stream multiple small lines, NOT one big line.
{"op":"patches","patches":[...]} - 1-3 patches per line MAX, under 800 chars each. Many changes = many lines, one item per line.
{"op":"patches","patches":[...]} - 1-3 patches per line MAX, try under 400 chars each. Many changes = many lines, one item per line.
{"op":"full","html":"..."} - ONLY when UI is completely broken or unrecoverable. Patches are strongly preferred.

COMPONENTS: Register reusable UI components with define, then use custom tags in patches.
Expand Down Expand Up @@ -59,7 +59,9 @@ FONTS: Any Fontsource font via style="font-family: 'FontName'". Default Inter. C

LOADING: For large UI rebuilds (10+ patches), sandbox operations (API calls, code execution, data fetching), or multi-step workflows — emit a loading/status patch matching current UI style first, then replace it with final content. For simple updates (button clicks, text changes, toggles, counter increments, style tweaks) — emit patches directly, no loading state.

BATCHING: [NOW] list all actions and prompts in chronological order, multiple numbered. Apply ALL in order.`;
BATCHING: [NOW] list all actions and prompts in chronological order, multiple numbered. Apply ALL in order.

INSPECT: get_page_state returns rendered HTML. Avoid unless lost or verifying after many changes.`;

// Sandbox addendum — appended to system prompt only when sandbox is configured
export type PackageInfo = { package: string; envVar?: string };
Expand Down
39 changes: 25 additions & 14 deletions apps/backend/src/services/generate/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Effect, Stream, pipe, DateTime, Duration, Ref, Option } from "effect";
import { streamText, type TextStreamPart } from "ai";
import { Effect, Stream, pipe, DateTime, Duration, Ref, Option, Runtime } from "effect";
import { streamText, tool, type TextStreamPart, type ToolSet } from "ai";
import { z } from "zod";
import type { LanguageModelConfig } from "@cuttlekit/common/server";
import { MemoryService, type MemorySearchResult } from "../memory/index.js";
import { accumulateLinesWithFlush } from "../../stream/utils.js";
Expand All @@ -22,7 +23,7 @@ import {
safeAsyncIterable,
} from "./index.js";
import type { GenerationError } from "./errors.js";
import { ToolService, TOOL_STEP_LIMIT, type SandboxTools } from "./tools.js";
import { ToolService, TOOL_STEP_LIMIT } from "./tools.js";
import type { ManagedSandbox, SandboxContext } from "../sandbox/manager.js";

export class GenerateService extends Effect.Service<GenerateService>()(
Expand Down Expand Up @@ -104,7 +105,7 @@ export class GenerateService extends Effect.Service<GenerateService>()(
usageRef: Ref.Ref<Usage[]>,
ttftRef: Ref.Ref<number>,
modelConfig: LanguageModelConfig,
requestTools?: SandboxTools,
requestTools: ToolSet,
): Stream.Stream<UnifiedResponse, GenerationError | Error> =>
Stream.unwrap(
Effect.gen(function* () {
Expand All @@ -118,11 +119,9 @@ export class GenerateService extends Effect.Service<GenerateService>()(
model: modelConfig.model,
messages: messages as Message[],
providerOptions: modelConfig.providerOptions,
...(requestTools && {
tools: requestTools,
stopWhen: TOOL_STEP_LIMIT,
toolChoice: "auto",
}),
tools: requestTools,
stopWhen: TOOL_STEP_LIMIT,
toolChoice: "auto",
});

// Use fullStream to get both text AND usage events
Expand All @@ -137,7 +136,7 @@ export class GenerateService extends Effect.Service<GenerateService>()(
return pipe(
fullStream,
// Extract text from text-delta, track usage from finish
Stream.mapEffect((part: TextStreamPart<SandboxTools>) =>
Stream.mapEffect((part: TextStreamPart<ToolSet>) =>
Effect.gen(function* () {
// Capture usage from finish-step using provider-specific extractor
if (part.type === "finish-step") {
Expand Down Expand Up @@ -258,7 +257,7 @@ export class GenerateService extends Effect.Service<GenerateService>()(
modeRef: Ref.Ref<"patches" | "full">,
attempt: number,
modelConfig: LanguageModelConfig,
requestTools?: SandboxTools,
requestTools: ToolSet,
): Stream.Stream<UnifiedResponse, Error> => {
if (attempt >= MAX_RETRY_ATTEMPTS) {
return Stream.fail(
Expand Down Expand Up @@ -352,11 +351,12 @@ export class GenerateService extends Effect.Service<GenerateService>()(
const { sessionId, currentHtml, catalog, actions } = options;

const modelConfig = yield* modelRegistry.resolve(options.modelId);
const runtime = yield* Effect.runtime<never>();

// Build per-request sandbox tools (only when sandbox is configured)
// Reuse session-scoped sandboxCtx (warm mode) or create fresh one (lazy)
const packageInfo = toolService.listPackageInfo();
const requestTools =
const sandboxTools =
packageInfo.length > 0
? yield* Effect.gen(function* () {
const sandboxCtx: SandboxContext = options.sandboxCtx ?? {
Expand All @@ -365,7 +365,6 @@ export class GenerateService extends Effect.Service<GenerateService>()(
),
lock: yield* Effect.makeSemaphore(1),
};
const runtime = yield* Effect.runtime<never>();
return toolService.makeTools({
sessionId,
sandboxCtx,
Expand Down Expand Up @@ -491,6 +490,18 @@ export class GenerateService extends Effect.Service<GenerateService>()(
yield* renderCETree(validationCtx.window, validationCtx.registry);
}

// Build always-available page state tool + optional sandbox tools
const allTools: ToolSet = {
get_page_state: tool({
description: "Get the current rendered HTML. Use ONLY when you've lost track after many patches.",
inputSchema: z.object({}),
execute: async () => ({
html: Runtime.runSync(runtime)(getCompactHtmlFromCtx(validationCtx)),
}),
}),
...(sandboxTools ?? {}),
};

// Create Refs to track state across retries
const usageRef = yield* Ref.make<Usage[]>([]);
const ttftRef = yield* Ref.make<number>(0);
Expand All @@ -508,7 +519,7 @@ export class GenerateService extends Effect.Service<GenerateService>()(
modeRef,
0,
modelConfig,
requestTools,
allTools,
);

// Stats stream runs AFTER content stream completes
Expand Down
17 changes: 10 additions & 7 deletions apps/webpage/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,27 @@ const app = {
loadIconsFromHTML(content);
}

if ("text" in patch) {
el.textContent = patch.text;
} else if ("attr" in patch) {
if ("remove" in patch) {
el.remove();
return;
}
if ("attr" in patch) {
Object.entries(patch.attr).forEach(([key, value]) => {
if (value === null) {
el.removeAttribute(key);
} else {
el.setAttribute(key, value);
}
});
}
if ("text" in patch) {
el.textContent = patch.text;
} else if ("html" in patch) {
el.innerHTML = patch.html;
} else if ("append" in patch) {
el.insertAdjacentHTML("beforeend", patch.append);
} else if ("prepend" in patch) {
el.insertAdjacentHTML("afterbegin", patch.prepend);
} else if ("html" in patch) {
el.innerHTML = patch.html;
} else if ("remove" in patch) {
el.remove();
}
},

Expand Down
Loading