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
9 changes: 9 additions & 0 deletions page-scripts/page-v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@
var chatForm = document.getElementById('chatForm');
if (chatForm) {
chatForm.addEventListener('submit', function() {
// Append any captured console errors to the outgoing message
var errors = window.__synthOSErrors;
if (errors && errors.length > 0) {
var ci = document.getElementById('chatInput');
if (ci && ci.value.trim()) {
ci.value = ci.value + '\n\nCONSOLE_ERRORS:\n' + errors.join('\n---\n');
window.__synthOSErrors = [];
}
}
var overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.style.display = 'flex';
chatForm.action = window.location.pathname;
Expand Down
241 changes: 241 additions & 0 deletions src/builders/anthropic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { anthropic as createAnthropicModel, completePrompt } from '../models';
import { parseChangeList, getTransformInstr } from '../service/transformPage';
import { Builder, BuilderResult, CHANGE_OPS_SCHEMA, ContextSection } from './types';

// ---------------------------------------------------------------------------
// Builder options — passed from the route handler
// ---------------------------------------------------------------------------

export interface AnthropicBuilderOptions {
apiKey?: string;
model?: string;
/** Optional wrapper applied to any internally-created model (e.g. for debug logging). */
wrapModel?: (model: completePrompt) => completePrompt;
}

// ---------------------------------------------------------------------------
// Request classification
// ---------------------------------------------------------------------------

export type Classification = 'hard-change' | 'easy-change' | 'question';

export interface ClassifyResult {
classification: Classification;
/** When classification is "question", this contains the answer text. */
answer?: string;
}

const CLASSIFIER_SYSTEM_PROMPT = `You classify user messages for a web page builder. Default to a change request. Only classify as "question" when the user is purely asking for information with zero implication that anything should change.

<DECISION_RULES>
Step 1 — Does the message describe a problem, bug, broken behavior, or something that should be different?
Yes → it is a change request (the user wants it fixed). Go to step 2.
No → go to step 3.

Step 2 — How complex is the change?
Simple (text edits, color/style changes, adding/removing a single element, toggling visibility, minor CSS tweaks) → "easy-change"
Complex (new features, games, animations, restructuring components, significant JS logic, forms with validation, multi-step work) → "hard-change"

Step 3 — Is the message a direct, explicit question asking for information only? Examples: "What color is the header?", "How many sections are there?", "What font is the title using?"
Yes, and there is absolutely no suggestion that anything should change → "question"
Otherwise → treat as a change request, go to step 2.

<OUTPUT_FORMAT>
Return only JSON. No other text.
- Change: { "classification": "easy-change" } or { "classification": "hard-change" }
- Question: { "classification": "question", "answer": "<brief answer>" }`;

export async function classifyRequest(
apiKey: string,
pageHtml: string,
userMessage: string
): Promise<ClassifyResult> {
try {
const sonnet = createAnthropicModel({ apiKey, model: 'claude-sonnet-4-5' });
const result = await sonnet({
system: { role: 'system', content: CLASSIFIER_SYSTEM_PROMPT },
prompt: { role: 'user', content: `<PAGE_HTML>\n${pageHtml}\n</PAGE_HTML>\n\n<USER_MESSAGE>\n${userMessage}\n</USER_MESSAGE>` },
jsonMode: true,
});

if (!result.completed || !result.value) {
return { classification: 'hard-change' };
}

const parsed = JSON.parse(result.value);
const c = parsed.classification;
if (c === 'question') {
return { classification: 'question', answer: typeof parsed.answer === 'string' ? parsed.answer : '' };
}
if (c === 'easy-change' || c === 'hard-change') {
return { classification: c };
}
return { classification: 'hard-change' };
} catch {
return { classification: 'hard-change' };
}
}

// ---------------------------------------------------------------------------
// Anthropic builder factory
// ---------------------------------------------------------------------------

/**
* Create an Anthropic-tuned builder.
*
* @param complete The completePrompt function (already configured with API key / model).
* @param userInstructions Optional user-level instructions from settings.
* @param productName Product name for branding in prompts (defaults to 'SynthOS').
* @param options Optional API key and model name for classifier routing.
*/
export function createAnthropicBuilder(
complete: completePrompt,
userInstructions?: string,
productName?: string,
options?: AnthropicBuilderOptions
): Builder {
const name = productName ?? 'SynthOS';

return {
async run(currentPage, additionalSections, userMessage, newBuild): Promise<BuilderResult> {
try {
const isOpus = options?.model?.startsWith('claude-opus-');

// Non-Opus models or missing apiKey: existing behavior
if (!isOpus || !options?.apiKey) {
return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name);
}

// Console errors bypass classification — always route to Opus
if (userMessage.includes('CONSOLE_ERRORS:')) {
console.log('classifyRequest: console errors detected → routing to ' + options.model!);
return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name);
}

// Classify the request using Sonnet
const classifyResult = await classifyRequest(options.apiKey, currentPage.content, userMessage);
console.log(`classifyRequest: "${classifyResult.classification}" → routing to ${routeLabel(classifyResult.classification, newBuild, options.model!)}`);

// Questions — answer was already provided by the classifier
if (classifyResult.classification === 'question') {
return { kind: 'reply', text: classifyResult.answer ?? '' };
}

// New builds always use Opus (the configured model)
if (newBuild) {
return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name);
}

// Easy changes use Sonnet
if (classifyResult.classification === 'easy-change') {
let sonnet: completePrompt = createAnthropicModel({ apiKey: options.apiKey, model: 'claude-sonnet-4-5' });
if (options.wrapModel) sonnet = options.wrapModel(sonnet);
return buildWithModel(sonnet, currentPage, additionalSections, userMessage, userInstructions, name);
}

// Hard changes use Opus
return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name);
} catch (err: unknown) {
return { kind: 'error', error: err instanceof Error ? err : new Error(String(err)) };
}
}
};
}

// ---------------------------------------------------------------------------
// Build flow — shared prompt construction + model call + parsing
// ---------------------------------------------------------------------------

export async function buildWithModel(
model: completePrompt,
currentPage: ContextSection,
additionalSections: ContextSection[],
userMessage: string,
userInstructions: string | undefined,
productName: string
): Promise<BuilderResult> {
// -- System message: all static content (cacheable) --
const systemParts: string[] = [];
for (const section of additionalSections) {
if (section.content) {
systemParts.push(`${section.title}\n${section.content}`);
}
}

const instructionParts: string[] = [];
if (userInstructions?.trim()) {
instructionParts.push(userInstructions);
}
for (const section of additionalSections) {
if (section.instructions?.trim()) {
instructionParts.push(section.instructions);
}
}
instructionParts.push(getTransformInstr(productName));
const instructions = instructionParts.filter(s => s.trim() !== '').join('\n');
systemParts.push(`<INSTRUCTIONS>\n${instructions}`);

const systemContent = systemParts.join('\n\n');

// -- User message: dynamic content only (current page + user message) --
const promptContent = `${currentPage.title}\n${currentPage.content}\n\n<USER_MESSAGE>\n${userMessage}`;

// -- Call model --
const result = await model({
system: { role: 'system', content: systemContent },
prompt: { role: 'user', content: promptContent },
cacheSystem: true,
outputSchema: CHANGE_OPS_SCHEMA,
});

if (!result.completed) {
return { kind: 'error', error: result.error ?? new Error('Model call failed') };
}

// -- Parse response --
return parseBuilderResponse(result.value!);
}

// ---------------------------------------------------------------------------
// Route label for console logging
// ---------------------------------------------------------------------------

function routeLabel(classification: Classification, newBuild: boolean, configuredModel: string): string {
if (classification === 'question') return 'classifier (answered inline)';
if (newBuild) return configuredModel;
if (classification === 'easy-change') return 'claude-sonnet-4-5';
return configuredModel;
}

// ---------------------------------------------------------------------------
// Response parsing — shared across builders
// ---------------------------------------------------------------------------

export function parseBuilderResponse(raw: string): BuilderResult {
// Try parsing as a JSON object with a kind discriminator
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
if (parsed.kind === 'transforms' && Array.isArray(parsed.changes)) {
return { kind: 'transforms', changes: parsed.changes };
}
if (parsed.kind === 'reply' && typeof parsed.text === 'string') {
return { kind: 'reply', text: parsed.text };
}
}
// Bare array — backward compat
if (Array.isArray(parsed)) {
return { kind: 'transforms', changes: parsed };
}
} catch {
// fall through to parseChangeList extraction
}

// Fall back to extracting a JSON array from the response text
try {
const changes = parseChangeList(raw);
return { kind: 'transforms', changes };
} catch {
return { kind: 'error', error: new Error('Failed to parse model response as JSON') };
}
}
59 changes: 59 additions & 0 deletions src/builders/fireworksai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { completePrompt } from '../models';
import { getTransformInstr } from '../service/transformPage';
import { parseBuilderResponse } from './anthropic';
import { Builder, BuilderResult, CHANGE_OPS_FORMAT_INSTRUCTION } from './types';

/**
* Create a FireworksAI-tuned builder.
* Currently identical to the Anthropic builder — separate file enables
* future per-provider tuning.
*/
export function createFireworksAIBuilder(complete: completePrompt, userInstructions?: string, productName?: string): Builder {
const name = productName ?? 'SynthOS';

return {
async run(currentPage, additionalSections, userMessage, newBuild): Promise<BuilderResult> {
try {
// -- System message --
const systemParts: string[] = [
`${currentPage.title}\n${currentPage.content}`,
];
for (const section of additionalSections) {
if (section.content) {
systemParts.push(`${section.title}\n${section.content}`);
}
}
const systemContent = systemParts.join('\n\n');

// -- User message --
const instructionParts: string[] = [];
if (userInstructions?.trim()) {
instructionParts.push(userInstructions);
}
for (const section of additionalSections) {
if (section.instructions?.trim()) {
instructionParts.push(section.instructions);
}
}
instructionParts.push(getTransformInstr(name));
instructionParts.push(CHANGE_OPS_FORMAT_INSTRUCTION);

const instructions = instructionParts.filter(s => s.trim() !== '').join('\n');
const promptContent = `<USER_MESSAGE>\n${userMessage}\n\n<INSTRUCTIONS>\n${instructions}`;

const result = await complete({
system: { role: 'system', content: systemContent },
prompt: { role: 'user', content: promptContent },
});

if (!result.completed) {
return { kind: 'error', error: result.error ?? new Error('Model call failed') };
}

return parseBuilderResponse(result.value!);
} catch (err: unknown) {
return { kind: 'error', error: err instanceof Error ? err : new Error(String(err)) };
}
}
};
}
33 changes: 33 additions & 0 deletions src/builders/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ProviderName, completePrompt } from '../models';
import { createAnthropicBuilder, AnthropicBuilderOptions } from './anthropic';
import { createOpenAIBuilder } from './openai';
import { createFireworksAIBuilder } from './fireworksai';
import { Builder } from './types';

export { ContextSection, BuilderResult, Builder } from './types';
export { createAnthropicBuilder, AnthropicBuilderOptions } from './anthropic';
export { createOpenAIBuilder } from './openai';
export { createFireworksAIBuilder } from './fireworksai';
export { parseBuilderResponse } from './anthropic';

/**
* Factory that creates a provider-specific builder.
*/
export function createBuilder(
provider: ProviderName,
complete: completePrompt,
userInstructions?: string,
productName?: string,
options?: AnthropicBuilderOptions
): Builder {
switch (provider) {
case 'Anthropic':
return createAnthropicBuilder(complete, userInstructions, productName, options);
case 'OpenAI':
return createOpenAIBuilder(complete, userInstructions, productName);
case 'FireworksAI':
return createFireworksAIBuilder(complete, userInstructions, productName);
default:
throw new Error(`Unknown provider: ${provider}`);
}
}
Loading