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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
logs
*.log

node_modules
node_modules/
dist

# Editor directories and files
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/services/generate/service.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Stream, Layer, Chunk, Option, Ref } from "effect";
import { NodeFileSystem } from "@effect/platform-node";
import { MockLanguageModelV3 } from "ai/test";
import { simulateReadableStream } from "ai";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
Expand Down Expand Up @@ -119,6 +120,7 @@ const createTestLayer = (mockModel: ReturnType<typeof createMockModel>) =>
Layer.provide(PatchValidator.Default),
Layer.provide(createMockRegistryLayer(mockModel)),
Layer.provide(MockToolServiceLayer),
Layer.provide(NodeFileSystem.layer),
);

// ============================================================
Expand Down Expand Up @@ -205,6 +207,7 @@ const createToolTestLayer = (
Layer.provide(makeMockSandboxServiceLayer(manager)),
),
),
Layer.provide(NodeFileSystem.layer),
);

// ============================================================
Expand Down
18 changes: 9 additions & 9 deletions apps/backend/src/services/generate/service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Stream, pipe, DateTime, Duration, Ref, Option, Runtime } from "effect";
import { Effect, Stream, pipe, DateTime, Duration, Ref, Option, Runtime, Schema, Either } from "effect";
import { streamText, tool, type TextStreamPart, type ToolSet } from "ai";
import { z } from "zod";
import type { LanguageModelConfig } from "@cuttlekit/common/server";
Expand Down Expand Up @@ -53,25 +53,25 @@ export class GenerateService extends Effect.Service<GenerateService>()(
}),
});

// Try parsing as LLM response (patches only)
const llmResult = LLMResponseSchema.safeParse(parseResult);
if (llmResult.success) {
return llmResult.data;
// Try parsing as LLM response
const llmResult = Schema.decodeUnknownEither(LLMResponseSchema)(parseResult);
if (Either.isRight(llmResult)) {
return llmResult.right;
}

// Fallback: check if it's a raw patch and wrap it
const patchResult = PatchSchema.safeParse(parseResult);
if (patchResult.success) {
const patchResult = Schema.decodeUnknownEither(PatchSchema)(parseResult);
if (Either.isRight(patchResult)) {
return {
op: "patches" as const,
patches: [patchResult.data],
patches: [patchResult.right],
};
}

// Neither valid - fail with JsonParseError
return yield* new JsonParseError({
line,
message: z.prettifyError(llmResult.error),
message: String(llmResult.left),
});
});

Expand Down
89 changes: 45 additions & 44 deletions apps/backend/src/services/generate/types.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,71 @@
import { z } from "zod";
import { Schema } from "effect";
import type { Option } from "effect";
import type { Action } from "@cuttlekit/common/client";
import type { SandboxContext } from "../sandbox/manager.js";
import type { ComponentSpec } from "../vdom/vdom.js";

// ============================================================
// Zod Schemas
// Effect Schemas
// ============================================================

export const PatchSchema = z.union([
z.object({ selector: z.string(), text: z.string() }),
z.object({
selector: z.string(),
attr: z.record(z.string(), z.string().nullable()),
}),
z.object({ selector: z.string(), append: z.string() }),
z.object({ selector: z.string(), prepend: z.string() }),
z.object({ selector: z.string(), html: z.string() }),
z.object({ selector: z.string(), remove: z.literal(true) }),
]);
export const PatchSchema = Schema.Struct({
selector: Schema.String,
text: Schema.optional(Schema.String),
attr: Schema.optional(
Schema.Record({ key: Schema.String, value: Schema.NullOr(Schema.String) })
),
append: Schema.optional(Schema.String),
prepend: Schema.optional(Schema.String),
html: Schema.optional(Schema.String),
remove: Schema.optional(Schema.Literal(true)),
});

export const PatchArraySchema = z.array(PatchSchema);
export const PatchArraySchema = Schema.Array(PatchSchema);

// Schema for define ops (component registration)
export const DefineOpSchema = z.object({
op: z.literal("define"),
tag: z.string(),
props: z.array(z.string()),
template: z.string(),
export const DefineOpSchema = Schema.Struct({
op: Schema.Literal("define"),
tag: Schema.String,
props: Schema.Array(Schema.String),
template: Schema.String,
});

// Schema for LLM responses — discriminated on `op`
export const LLMResponseSchema = z.union([
z.object({
op: z.literal("patches"),
patches: PatchArraySchema,
}),
z.object({
op: z.literal("full"),
html: z.string(),
}),
export const PatchesResponse = Schema.Struct({
op: Schema.Literal("patches"),
patches: PatchArraySchema,
});

export const FullResponse = Schema.Struct({
op: Schema.Literal("full"),
html: Schema.String,
});

export const LLMResponseSchema = Schema.Union(
PatchesResponse,
FullResponse,
DefineOpSchema,
]);
);

export type LLMResponse = z.infer<typeof LLMResponseSchema>;
export type LLMResponse = typeof LLMResponseSchema.Type;

// Full response schema includes stats (generated by code, not LLM)
export const UnifiedResponseSchema = z.union([
LLMResponseSchema,
z.object({
op: z.literal("stats"),
cacheRate: z.number(),
tokensPerSecond: z.number(),
mode: z.enum(["patches", "full"]),
patchCount: z.number(),
ttft: z.number(),
ttc: z.number(),
}),
]);
export type UnifiedResponse =
| LLMResponse
| {
op: "stats";
cacheRate: number;
tokensPerSecond: number;
mode: "patches" | "full";
patchCount: number;
ttft: number;
ttc: number;
};

// ============================================================
// Exported Types
// ============================================================

export type UnifiedResponse = z.infer<typeof UnifiedResponseSchema>;

export type UnifiedGenerateOptions = {
sessionId: string;
currentHtml: Option.Option<string>;
Expand Down
59 changes: 21 additions & 38 deletions apps/backend/src/services/memory/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { StoreService } from "./store.js";
// ============================================================

export type MemoryChange =
| { type: "patches"; patches: Patch[] }
| { type: "patches"; patches: readonly Patch[] }
| { type: "full"; html: string };

export type MemoryOperation = {
Expand Down Expand Up @@ -46,54 +46,35 @@ const MemorySummariesSchema = z.object({
.string()
.nullable()
.describe(
"Compressed user intent, 3-8 words. Drop articles/filler. Null if no prompts.",
"Compressed user intent. Drop articles/filler, keep all details — selectors, colors, text, layout info. Null if no prompts.",
),
actionSummary: z
.string()
.nullable()
.describe(
"Compressed action, 3-8 words. Drop articles/filler. Null if no actions.",
"Compressed action. Drop articles/filler, keep all details — element ids, values, event types. Null if no actions.",
),
changeSummary: z
.string()
.describe(
"Compressed visual/functional change, 5-12 words. Drop articles/connectives/filler. Keep facts/numbers.",
"Compressed visual/functional change. Drop articles/connectives/filler. Describe what visually changed, not DOM operations. Keep ALL important details.",
),
});

// ============================================================
// Patch Description
// ============================================================

const describePatch = (patch: Patch): string =>
Match.value(patch).pipe(
Match.when({ selector: Match.string, text: Match.string }, (p) => {
const text = p.text;
return `Set text in ${p.selector} to "${text.slice(0, 50)}${text.length > 50 ? "..." : ""}"`;
}),
Match.when(
{ selector: Match.string, attr: Match.defined },
(p) =>
`Updated attributes on ${p.selector}: ${Object.keys(p.attr).join(", ")}`,
),
Match.when(
{ selector: Match.string, html: Match.string },
(p) => `Replaced HTML in ${p.selector}`,
),
Match.when(
{ selector: Match.string, append: Match.string },
(p) => `Appended content to ${p.selector}`,
),
Match.when(
{ selector: Match.string, prepend: Match.string },
(p) => `Prepended content to ${p.selector}`,
),
Match.when(
{ selector: Match.string, remove: Match.boolean },
(p) => `Removed ${p.selector}`,
),
Match.exhaustive,
);
const describePatch = (patch: Patch): string => {
const parts: string[] = [];
if (patch.remove) { parts.push(`Removed ${patch.selector}`); return parts.join("; "); }
if (patch.attr) parts.push(`Updated attributes on ${patch.selector}: ${Object.keys(patch.attr).join(", ")}`);
if (patch.text !== undefined) parts.push(`Set text in ${patch.selector} to "${patch.text.slice(0, 50)}${patch.text.length > 50 ? "..." : ""}"`);
if (patch.html !== undefined) parts.push(`Replaced HTML in ${patch.selector}`);
if (patch.append) parts.push(`Appended content to ${patch.selector}`);
if (patch.prepend) parts.push(`Prepended content to ${patch.selector}`);
return parts.join("; ") || `Patch on ${patch.selector}`;
};

const describePatches = (patches: readonly Patch[]): string =>
patches.map(describePatch).join("\n");
Expand Down Expand Up @@ -147,9 +128,11 @@ export class MemoryService extends Effect.Service<MemoryService>()(
? `User actions:\n${op.actions.map((a, i) => `${i + 1}. ${a.action}${a.data ? ` (data: ${JSON.stringify(a.data)})` : ""}`).join("\n")}`
: "No user actions.";

const prompt = `Analyze UI changes. Generate compressed summaries.
const prompt = `Summarize UI changes. Focus on WHAT visually changed, not DOM operations.

COMPRESSION: Drop articles, connectives, filler. Active voice, present tense. Keep facts/numbers/specifics.
STYLE: Caveman compression — drop articles, connectives, filler. Keep ALL important info. Describe visual result, not selectors/divs.
BAD: "replaced html #hero, appended content #left-col, updated attributes #root class"
GOOD: "redesigned hero section, added sidebar cards, changed color palette to dark theme"

${promptContext}

Expand All @@ -158,9 +141,9 @@ ${actionContext}
${changeDescription}

Generate:
1. promptSummary: Compressed user intent (3-8 words). Null if no prompts.
2. actionSummary: Compressed action (3-8 words). Null if no actions.
3. changeSummary: Compressed visual/functional change (5-12 words).`;
1. promptSummary: What user asked for, all key details. Null if no prompts.
2. actionSummary: What user did, all key details. Null if no actions.
3. changeSummary: What visually changed, all key details. Describe appearance not DOM ops.`;

const result = yield* Effect.promise(() =>
generateText({
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/services/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export class UIService extends Effect.Service<UIService>()("UIService", {
// Transform unified responses to stream events, applying to VDOM
let lastHtml = Option.getOrElse(currentHtml, () => "");

const handlePatchResponse = (patches: Patch[]) =>
const handlePatchResponse = (patches: readonly Patch[]) =>
Effect.gen(function* () {
const events = yield* Effect.forEach(patches, (patch) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -174,7 +174,7 @@ export class UIService extends Effect.Service<UIService>()("UIService", {

const handleDefineResponse = (r: {
tag: string;
props: string[];
props: readonly string[];
template: string;
}) =>
Effect.gen(function* () {
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/services/vdom/patch-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export class PatchValidator extends Effect.Service<PatchValidator>()(
*/
const defineComponent = (
ctx: ValidationContext,
spec: { tag: string; props: string[]; template: string },
spec: { tag: string; props: readonly string[]; template: string },
) =>
Effect.gen(function* () {
const existing = ctx.registry.get(spec.tag);
Expand Down Expand Up @@ -181,9 +181,9 @@ export class PatchValidator extends Effect.Service<PatchValidator>()(
Effect.gen(function* () {
const result = yield* validateAll(ctx.doc, patches);
const hasStructuralMutation = patches.some(
(p) => "append" in p || "prepend" in p || "html" in p,
(p) => !!p.append || !!p.prepend || p.html !== undefined,
);
const hasAttrMutation = patches.some((p) => "attr" in p);
const hasAttrMutation = patches.some((p) => !!p.attr);
if ((hasStructuralMutation || hasAttrMutation) && ctx.registry.size > 0) {
yield* renderCETree(ctx.window, ctx.registry, hasAttrMutation);
}
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/services/vdom/vdom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export class VdomService extends Effect.Service<VdomService>()("VdomService", {

const define = (
sessionId: string,
op: { tag: string; props: string[]; template: string }
op: { tag: string; props: readonly string[]; template: string }
) =>
Effect.gen(function* () {
const window = yield* getOrCreateWindow(sessionId)
Expand Down Expand Up @@ -307,9 +307,9 @@ export class VdomService extends Effect.Service<VdomService>()("VdomService", {

// Re-render CEs after structural mutations and CE attr updates.
const hasStructuralMutation = patches.some(
(p) => "append" in p || "prepend" in p || "html" in p
(p) => !!p.append || !!p.prepend || p.html !== undefined
)
const hasAttrMutation = patches.some((p) => "attr" in p)
const hasAttrMutation = patches.some((p) => !!p.attr)
if (hasStructuralMutation || hasAttrMutation) {
const registry = yield* getRegistry(sessionId)
if (registry.size > 0) {
Expand Down
Loading
Loading