Merge pull request 'Sitesmith: AI site builder addon (frontend)' (#1) from sitesmith-ai-builder into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
3189
craft/package-lock.json
generated
Normal file
3189
craft/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,20 +8,28 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "playwright test tests/site-builder.spec.ts --reporter=list",
|
"test": "playwright test tests/site-builder.spec.ts --reporter=list",
|
||||||
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed"
|
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed",
|
||||||
|
"test:e2e:sitesmith": "playwright test tests/sitesmith.spec.ts --reporter=list",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@craftjs/core": "^0.2.10",
|
"@craftjs/core": "^0.2.10",
|
||||||
"@craftjs/layers": "^0.2.7",
|
"@craftjs/layers": "^0.2.7",
|
||||||
|
"dompurify": "^3.4.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/ui": "^4.1.7",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^6.0.5"
|
"vite": "^6.0.5",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
craft/src/components/basic/HtmlBlock.test.ts
Normal file
23
craft/src/components/basic/HtmlBlock.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { purifyHtml } from './HtmlBlock';
|
||||||
|
|
||||||
|
describe('purifyHtml', () => {
|
||||||
|
test('strips script tags', () => {
|
||||||
|
expect(purifyHtml('<p>ok</p><script>alert(1)</script>')).not.toContain('<script');
|
||||||
|
});
|
||||||
|
test('strips on-event handlers', () => {
|
||||||
|
const out = purifyHtml('<a onclick="bad()" href="/x">x</a>');
|
||||||
|
expect(out).not.toContain('onclick');
|
||||||
|
expect(out).toContain('href="/x"');
|
||||||
|
});
|
||||||
|
test('blocks javascript: URLs', () => {
|
||||||
|
expect(purifyHtml('<a href="javascript:void(0)">x</a>')).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
test('allows YouTube iframe', () => {
|
||||||
|
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||||
|
expect(out).toContain('youtube.com/embed/abc');
|
||||||
|
});
|
||||||
|
test('strips form/input', () => {
|
||||||
|
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,34 +1,52 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties, useMemo } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
interface HtmlBlockProps {
|
interface HtmlBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
aiName?: string;
|
||||||
|
node_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({
|
const PURIFY_CONFIG = {
|
||||||
code = '',
|
ALLOWED_TAGS: [
|
||||||
style = {},
|
'a','p','br','hr','div','span','section','article',
|
||||||
}) => {
|
'header','footer','main','aside','nav',
|
||||||
const {
|
'ul','ol','li',
|
||||||
connectors: { connect, drag },
|
'h1','h2','h3','h4','h5','h6',
|
||||||
selected,
|
'em','strong','b','i','u','s',
|
||||||
} = useNode((node) => ({
|
'blockquote','code','pre',
|
||||||
selected: node.events.selected,
|
'img','figure','figcaption',
|
||||||
}));
|
'iframe',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'href','src','alt','title','target','rel',
|
||||||
|
'width','height','class',
|
||||||
|
'allowfullscreen','allow','frameborder',
|
||||||
|
],
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
|
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
|
||||||
|
FORBID_ATTR: [/^on/i],
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
export function purifyHtml(input: string): string {
|
||||||
<div
|
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
}
|
||||||
style={{
|
|
||||||
|
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
|
||||||
|
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
||||||
|
const clean = useMemo(() => purifyHtml(code), [code]);
|
||||||
|
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
||||||
|
return React.createElement('div', {
|
||||||
|
ref: setRef,
|
||||||
|
style: {
|
||||||
minHeight: '40px',
|
minHeight: '40px',
|
||||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||||
...style,
|
...style,
|
||||||
}}
|
},
|
||||||
dangerouslySetInnerHTML={{ __html: code }}
|
dangerouslySetInnerHTML: { __html: clean },
|
||||||
/>
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- Settings panel ---------- */
|
/* ---------- Settings panel ---------- */
|
||||||
|
|||||||
53
craft/src/hooks/useSitesmith.ts
Normal file
53
craft/src/hooks/useSitesmith.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||||
|
import { SitesmithSummary, SitesmithMessage, SendResult } from '../types/sitesmith';
|
||||||
|
|
||||||
|
function apiBase(apiUrl: string): string {
|
||||||
|
return apiUrl.replace(/site-builder\.php$/, 'sitesmith.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSitesmith(siteId: number) {
|
||||||
|
const { whpConfig } = useEditorConfig();
|
||||||
|
const [summary, setSummary] = useState<SitesmithSummary | null>(null);
|
||||||
|
const [messages, setMessages] = useState<SitesmithMessage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshEntitlement = useCallback(async () => {
|
||||||
|
if (!whpConfig) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=entitlement`, { credentials: 'include' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) setSummary(j.summary);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}, [whpConfig]);
|
||||||
|
|
||||||
|
const fetchHistory = useCallback(async () => {
|
||||||
|
if (!whpConfig) { setLoading(false); return; }
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=history&site_id=${siteId}`, { credentials: 'include' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) setMessages(j.messages);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, [whpConfig, siteId]);
|
||||||
|
|
||||||
|
useEffect(() => { void refreshEntitlement(); void fetchHistory(); }, [refreshEntitlement, fetchHistory]);
|
||||||
|
|
||||||
|
const send = useCallback(async (userText: string, canvasSummary: string): Promise<SendResult> => {
|
||||||
|
if (!whpConfig) return { ok: false, status: 'BLOCKED', message: 'No WHP config' };
|
||||||
|
setMessages((m) => [...m, { role: 'user', content: userText, response_type: null, created_at: new Date().toISOString() }]);
|
||||||
|
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=send`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken },
|
||||||
|
body: JSON.stringify({ site_id: siteId, message: userText, canvas_summary: canvasSummary }),
|
||||||
|
});
|
||||||
|
const j: SendResult = await r.json();
|
||||||
|
void fetchHistory();
|
||||||
|
void refreshEntitlement();
|
||||||
|
return j;
|
||||||
|
}, [whpConfig, siteId, fetchHistory, refreshEntitlement]);
|
||||||
|
|
||||||
|
return { summary, messages, loading, error, send, refreshEntitlement };
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
|
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
|
||||||
? (nodeType as any).resolvedName
|
? (nodeType as any).resolvedName
|
||||||
: typeof nodeType === 'string' ? nodeType : undefined;
|
: typeof nodeType === 'string' ? nodeType : undefined;
|
||||||
const displayName = node.data.displayName || resolvedName || 'Component';
|
const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node';
|
||||||
const childNodeIds: string[] = node.data.nodes || [];
|
const childNodeIds: string[] = node.data.nodes || [];
|
||||||
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
|
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
|
||||||
const allChildren = [...childNodeIds, ...linkedNodeIds];
|
const allChildren = [...childNodeIds, ...linkedNodeIds];
|
||||||
|
|||||||
25
craft/src/panels/sitesmith/ChatInput.tsx
Normal file
25
craft/src/panels/sitesmith/ChatInput.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React, { useState, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
interface Props { disabled?: boolean; placeholder?: string; onSend: (text: string) => void; }
|
||||||
|
|
||||||
|
export const ChatInput: React.FC<Props> = ({ disabled, placeholder, onSend }) => {
|
||||||
|
const [v, setV] = useState('');
|
||||||
|
const fire = () => { const t = v.trim(); if (!t || disabled) return; onSend(t); setV(''); };
|
||||||
|
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); fire(); } };
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 8, padding: '8px 0' }}>
|
||||||
|
<textarea value={v} onChange={(e) => setV(e.target.value)} onKeyDown={onKey} rows={2} disabled={disabled}
|
||||||
|
placeholder={placeholder || 'Describe what you want...'}
|
||||||
|
style={{
|
||||||
|
flex: 1, background: disabled ? '#1f1f24' : '#0f0f17', color: '#e5e7eb',
|
||||||
|
border: '1px solid #3f3f46', borderRadius: 6, padding: 10, fontSize: 14, resize: 'none',
|
||||||
|
}} />
|
||||||
|
<button onClick={fire} disabled={disabled || v.trim() === ''}
|
||||||
|
style={{
|
||||||
|
background: disabled ? '#27272a' : '#7c3aed', color: '#fff',
|
||||||
|
border: 'none', padding: '0 16px', borderRadius: 6,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer', fontWeight: 500,
|
||||||
|
}}>→</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
craft/src/panels/sitesmith/MessageList.tsx
Normal file
32
craft/src/panels/sitesmith/MessageList.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SitesmithMessage } from '../../types/sitesmith';
|
||||||
|
|
||||||
|
export const MessageList: React.FC<{ messages: SitesmithMessage[] }> = ({ messages }) => {
|
||||||
|
const extract = (m: SitesmithMessage): string => {
|
||||||
|
if (m.role === 'user') return m.content;
|
||||||
|
try { const obj = JSON.parse(m.content); if (obj.type === 'ask') return obj.question; if (obj.message) return obj.message; } catch {}
|
||||||
|
return m.content;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ color: '#71717a', fontSize: 13, textAlign: 'center', padding: 30 }}>
|
||||||
|
Describe the site you want and Sitesmith builds it. e.g. "A two-page site for a small bakery, friendly tone, photo of cupcakes in the hero."
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m, i) => {
|
||||||
|
const isUser = m.role === 'user';
|
||||||
|
return (
|
||||||
|
<div key={i} style={{
|
||||||
|
alignSelf: isUser ? 'flex-end' : 'flex-start',
|
||||||
|
maxWidth: '80%', padding: '10px 14px', borderRadius: 10,
|
||||||
|
background: isUser ? '#312e81' : '#1f2937', color: '#f3f4f6',
|
||||||
|
fontSize: 14, whiteSpace: 'pre-wrap',
|
||||||
|
}}>
|
||||||
|
{extract(m)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
craft/src/panels/sitesmith/ScopeConfirmDialog.tsx
Normal file
36
craft/src/panels/sitesmith/ScopeConfirmDialog.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
pendingMessage?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScopeConfirmDialog: React.FC<Props> = ({ open, pendingMessage, onConfirm, onCancel }) => {
|
||||||
|
if (!open) return null;
|
||||||
|
const overlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
|
||||||
|
const box: React.CSSProperties = { background: '#1a1a2e', border: '1px solid #3f3f46', borderRadius: 10, padding: 22, maxWidth: 480, color: '#fff' };
|
||||||
|
const cancel: React.CSSProperties = { background: '#27272a', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6, cursor: 'pointer' };
|
||||||
|
const ok: React.CSSProperties = { background: '#b91c1c', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6, cursor: 'pointer' };
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-modal="true" style={overlay}>
|
||||||
|
<div style={box}>
|
||||||
|
<h3 style={{ margin: 0 }}>Replace your entire site?</h3>
|
||||||
|
<p style={{ color: '#cbd5e1', fontSize: 14 }}>
|
||||||
|
Sitesmith will replace every page, your header, and your footer with the new design.
|
||||||
|
Manual edits will be lost.
|
||||||
|
</p>
|
||||||
|
{pendingMessage && (
|
||||||
|
<blockquote style={{ borderLeft: '3px solid #7c3aed', paddingLeft: 12, color: '#a5b4fc', fontSize: 13 }}>
|
||||||
|
{pendingMessage}
|
||||||
|
</blockquote>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14 }}>
|
||||||
|
<button onClick={onCancel} style={cancel}>Cancel</button>
|
||||||
|
<button onClick={onConfirm} style={ok}>Replace site</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
29
craft/src/panels/sitesmith/SitesmithButton.tsx
Normal file
29
craft/src/panels/sitesmith/SitesmithButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSitesmith } from '../../hooks/useSitesmith';
|
||||||
|
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||||
|
|
||||||
|
interface Props { onClick: () => void; }
|
||||||
|
|
||||||
|
export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
|
||||||
|
const cfg = useEditorConfig();
|
||||||
|
const siteId = cfg.whpConfig?.siteId ?? 0;
|
||||||
|
const { summary } = useSitesmith(siteId);
|
||||||
|
const locked = summary?.status === 'DISABLED';
|
||||||
|
const capped = summary?.status === 'CAP_REACHED';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="topbar-btn sitesmith-btn"
|
||||||
|
title={locked ? 'Sitesmith — paid addon (click to learn more)' : 'Sitesmith AI Builder'}
|
||||||
|
style={{
|
||||||
|
background: locked ? '#1f1f24' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||||
|
color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✨ Sitesmith
|
||||||
|
{locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 12 }}>🔒</span>}
|
||||||
|
{capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
craft/src/panels/sitesmith/SitesmithModal.tsx
Normal file
94
craft/src/panels/sitesmith/SitesmithModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||||
|
import { useSitesmith } from '../../hooks/useSitesmith';
|
||||||
|
import { useApplyAiResponse } from '../../utils/apply-ai-response';
|
||||||
|
import { summarizeCanvas } from '../../utils/canvas-summary';
|
||||||
|
import { UpgradeBanner } from './UpgradeBanner';
|
||||||
|
import { ScopeConfirmDialog } from './ScopeConfirmDialog';
|
||||||
|
import { MessageList } from './MessageList';
|
||||||
|
import { ChatInput } from './ChatInput';
|
||||||
|
import { SitesmithResponse } from '../../types/sitesmith';
|
||||||
|
|
||||||
|
interface Props { onClose: () => void; }
|
||||||
|
|
||||||
|
export const SitesmithModal: React.FC<Props> = ({ onClose }) => {
|
||||||
|
const cfg = useEditorConfig();
|
||||||
|
const siteId = cfg.whpConfig?.siteId ?? 0;
|
||||||
|
const { query } = useEditor();
|
||||||
|
const { summary, messages, send, loading } = useSitesmith(siteId);
|
||||||
|
const apply = useApplyAiResponse();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
|
||||||
|
|
||||||
|
const overlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)', zIndex: 9000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
|
||||||
|
const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' };
|
||||||
|
const header: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid #27272a' };
|
||||||
|
const body: React.CSSProperties = { flex: 1, padding: '14px 18px', overflowY: 'auto', display: 'flex', flexDirection: 'column' };
|
||||||
|
const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a' };
|
||||||
|
const closeBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: 'none', fontSize: 18, cursor: 'pointer' };
|
||||||
|
const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '8px 12px', borderRadius: 6, marginBottom: 10, fontSize: 13 };
|
||||||
|
|
||||||
|
const handleSend = async (text: string) => {
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try {
|
||||||
|
const canvas = summarizeCanvas(query.getSerializedNodes());
|
||||||
|
const result = await send(text, canvas);
|
||||||
|
if (!result.ok) { setError(result.message || 'Failed'); return; }
|
||||||
|
if (result.response.type === 'replace' && result.response.scope === 'site') {
|
||||||
|
setPendingReplace(result.response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const applied = await apply(result.response);
|
||||||
|
if (!applied.ok) setError(applied.message || 'Apply failed');
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmReplace = async () => {
|
||||||
|
if (!pendingReplace) return;
|
||||||
|
const r = await apply(pendingReplace);
|
||||||
|
setPendingReplace(null);
|
||||||
|
if (!r.ok) setError(r.message || 'Apply failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-modal="true" style={overlay}>
|
||||||
|
<div style={panel}>
|
||||||
|
<div style={header}>
|
||||||
|
<div style={{ fontWeight: 600, color: '#fff' }}>✨ Sitesmith</div>
|
||||||
|
{summary && summary.enabled && (
|
||||||
|
<div style={{ fontSize: 12, color: '#a1a1aa' }}>
|
||||||
|
{summary.monthly_used} / {summary.monthly_cap} this month
|
||||||
|
{summary.bonus_credits > 0 && ` • +${summary.bonus_credits} bonus`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={body}>
|
||||||
|
<UpgradeBanner summary={summary} />
|
||||||
|
{error && <div role="alert" style={errBox}>{error}</div>}
|
||||||
|
{loading
|
||||||
|
? <div style={{ color: '#71717a', textAlign: 'center', padding: 30 }}>Loading…</div>
|
||||||
|
: <MessageList messages={messages} />}
|
||||||
|
</div>
|
||||||
|
<div style={footer}>
|
||||||
|
<ChatInput
|
||||||
|
disabled={!canChat || busy}
|
||||||
|
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : busy ? 'Thinking…' : 'Describe what you want…'}
|
||||||
|
onSend={handleSend}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScopeConfirmDialog
|
||||||
|
open={!!pendingReplace}
|
||||||
|
pendingMessage={pendingReplace && 'message' in pendingReplace ? (pendingReplace as any).message : undefined}
|
||||||
|
onConfirm={confirmReplace}
|
||||||
|
onCancel={() => setPendingReplace(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
35
craft/src/panels/sitesmith/UpgradeBanner.tsx
Normal file
35
craft/src/panels/sitesmith/UpgradeBanner.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SitesmithSummary } from '../../types/sitesmith';
|
||||||
|
|
||||||
|
interface Props { summary: SitesmithSummary | null; }
|
||||||
|
|
||||||
|
export const UpgradeBanner: React.FC<Props> = ({ summary }) => {
|
||||||
|
if (!summary) return null;
|
||||||
|
if (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY') return null;
|
||||||
|
const isLocked = summary.status === 'DISABLED';
|
||||||
|
const isCapped = summary.status === 'CAP_REACHED';
|
||||||
|
return (
|
||||||
|
<div role="status" style={{
|
||||||
|
background: isLocked ? '#3b1d4d' : '#3b2d1d',
|
||||||
|
border: `1px solid ${isLocked ? '#7c3aed' : '#b45309'}`,
|
||||||
|
color: '#fbcfe8', padding: '14px 18px', borderRadius: 8, marginBottom: 14,
|
||||||
|
}}>
|
||||||
|
{isLocked && (<>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>Sitesmith is a paid addon</div>
|
||||||
|
<div style={{ fontSize: 13, marginBottom: 10 }}>
|
||||||
|
Describe the site you want and our AI builds it. You can edit everything afterward.
|
||||||
|
</div>
|
||||||
|
<a href="https://anhonesthost.com/clientarea.php?action=services" target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ color: '#fff', background: '#7c3aed', padding: '8px 14px', borderRadius: 6, textDecoration: 'none' }}>
|
||||||
|
Upgrade your plan →
|
||||||
|
</a>
|
||||||
|
</>)}
|
||||||
|
{isCapped && (<>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>Monthly cap reached</div>
|
||||||
|
<div style={{ fontSize: 13 }}>
|
||||||
|
You've used {summary.monthly_used} of {summary.monthly_cap} Sitesmith builds this month. Resets on {summary.resets_on}.
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ import { usePages } from '../../state/PageContext';
|
|||||||
import { DeviceMode } from '../../types';
|
import { DeviceMode } from '../../types';
|
||||||
import { TemplateModal } from './TemplateModal';
|
import { TemplateModal } from './TemplateModal';
|
||||||
import { HeadCodeModal } from './HeadCodeModal';
|
import { HeadCodeModal } from './HeadCodeModal';
|
||||||
|
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||||
|
import { SitesmithModal } from '../sitesmith/SitesmithModal';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
device: DeviceMode;
|
device: DeviceMode;
|
||||||
@@ -26,6 +28,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
const [isDraft, setIsDraft] = useState(false);
|
const [isDraft, setIsDraft] = useState(false);
|
||||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||||
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
|
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
|
||||||
|
const [sitesmithOpen, setSitesmithOpen] = useState(false);
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const hasLoadedRef = useRef(false);
|
const hasLoadedRef = useRef(false);
|
||||||
@@ -239,6 +242,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SitesmithButton onClick={() => setSitesmithOpen(true)} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="topbar-btn primary"
|
className="topbar-btn primary"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
@@ -269,6 +274,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
</div>
|
</div>
|
||||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||||
|
{sitesmithOpen && <SitesmithModal onClose={() => setSitesmithOpen(false)} />}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { PageData } from '../types';
|
import { PageData } from '../types';
|
||||||
|
import { SerializedTreeNode } from '../types/sitesmith';
|
||||||
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
||||||
|
|
||||||
interface PageContextValue {
|
interface PageContextValue {
|
||||||
@@ -19,6 +20,11 @@ interface PageContextValue {
|
|||||||
setHeaderCraftState: (craftState: string) => void;
|
setHeaderCraftState: (craftState: string) => void;
|
||||||
setFooterCraftState: (craftState: string) => void;
|
setFooterCraftState: (craftState: string) => void;
|
||||||
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
|
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
|
||||||
|
/** AI helpers — replace entire site or page with a new tree */
|
||||||
|
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void;
|
||||||
|
replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void;
|
||||||
|
setHeader: (tree: SerializedTreeNode) => void;
|
||||||
|
setFooter: (tree: SerializedTreeNode) => void;
|
||||||
siteDesign: SiteDesign;
|
siteDesign: SiteDesign;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +56,10 @@ const PageContext = createContext<PageContextValue>({
|
|||||||
setHeaderCraftState: () => {},
|
setHeaderCraftState: () => {},
|
||||||
setFooterCraftState: () => {},
|
setFooterCraftState: () => {},
|
||||||
setPagesCraftState: () => {},
|
setPagesCraftState: () => {},
|
||||||
|
replaceAllPages: () => {},
|
||||||
|
replaceCurrentPage: () => {},
|
||||||
|
setHeader: () => {},
|
||||||
|
setFooter: () => {},
|
||||||
siteDesign: {} as SiteDesign,
|
siteDesign: {} as SiteDesign,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,6 +283,110 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
})));
|
})));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** Flatten a SerializedTreeNode into a Craft.js SerializedNodes JSON string */
|
||||||
|
const treeToState = useCallback((tree: SerializedTreeNode): string => {
|
||||||
|
let counter = 0;
|
||||||
|
const nodes: Record<string, unknown> = {};
|
||||||
|
const walk = (node: SerializedTreeNode, parent: string | null): string => {
|
||||||
|
const id = (node.props.node_id as string | undefined) || `ai-auto-${counter++}`;
|
||||||
|
const childIds: string[] = [];
|
||||||
|
nodes[id] = {
|
||||||
|
type: node.type,
|
||||||
|
isCanvas: true,
|
||||||
|
props: node.props,
|
||||||
|
displayName: node.type.resolvedName,
|
||||||
|
custom: {},
|
||||||
|
hidden: false,
|
||||||
|
parent,
|
||||||
|
nodes: childIds,
|
||||||
|
linkedNodes: {},
|
||||||
|
};
|
||||||
|
for (const child of node.nodes ?? []) {
|
||||||
|
childIds.push(walk(child, id));
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
const rootId = walk(tree, null);
|
||||||
|
// Craft.js deserialize requires the root node keyed as 'ROOT'
|
||||||
|
if (rootId !== 'ROOT') {
|
||||||
|
nodes['ROOT'] = nodes[rootId];
|
||||||
|
(nodes['ROOT'] as any).parent = null;
|
||||||
|
delete nodes[rootId];
|
||||||
|
// Fix up parent references from ROOT's children
|
||||||
|
for (const childId of (nodes['ROOT'] as any).nodes) {
|
||||||
|
if (nodes[childId]) (nodes[childId] as any).parent = 'ROOT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(nodes);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI helper: replace all pages with newly generated trees.
|
||||||
|
* Stores each page's serialized state without touching the live canvas
|
||||||
|
* (the canvas still shows the currently active page — call switchPage() if needed).
|
||||||
|
*/
|
||||||
|
const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]) => {
|
||||||
|
if (newPages.length === 0) return;
|
||||||
|
const built = newPages.map((p, i) => ({
|
||||||
|
id: i === 0 ? 'home' : `page_${Date.now()}_${i}`,
|
||||||
|
name: p.name,
|
||||||
|
slug: slugify(p.name),
|
||||||
|
craftState: treeToState(p.tree),
|
||||||
|
headCode: '',
|
||||||
|
}));
|
||||||
|
setPages(built);
|
||||||
|
// Load the first page into the live canvas
|
||||||
|
const firstState = built[0].craftState;
|
||||||
|
setActivePageId(built[0].id);
|
||||||
|
activePageIdRef.current = built[0].id;
|
||||||
|
loadState(firstState, EMPTY_CANVAS);
|
||||||
|
}, [treeToState, loadState]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI helper: replace the current page's tree.
|
||||||
|
* Deserializes the new tree into the live Craft.js canvas and persists it.
|
||||||
|
*/
|
||||||
|
const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }) => {
|
||||||
|
const craftState = treeToState(page.tree);
|
||||||
|
const currentId = activePageIdRef.current;
|
||||||
|
if (currentId === HEADER_ID) {
|
||||||
|
setHeaderPage((prev) => ({ ...prev, name: page.name, craftState }));
|
||||||
|
} else if (currentId === FOOTER_ID) {
|
||||||
|
setFooterPage((prev) => ({ ...prev, name: page.name, craftState }));
|
||||||
|
} else {
|
||||||
|
setPages((prev) =>
|
||||||
|
prev.map((p) => (p.id === currentId ? { ...p, name: page.name, craftState } : p)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
loadState(craftState, EMPTY_CANVAS);
|
||||||
|
}, [treeToState, loadState]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI helper: replace the shared header tree.
|
||||||
|
* Updates stored state; does NOT switch the canvas to header view.
|
||||||
|
*/
|
||||||
|
const setHeader = useCallback((tree: SerializedTreeNode) => {
|
||||||
|
const craftState = treeToState(tree);
|
||||||
|
setHeaderPage((prev) => ({ ...prev, craftState }));
|
||||||
|
// If the canvas is currently showing the header, refresh it live
|
||||||
|
if (activePageIdRef.current === HEADER_ID) {
|
||||||
|
loadState(craftState, EMPTY_HEADER);
|
||||||
|
}
|
||||||
|
}, [treeToState, loadState]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI helper: replace the shared footer tree.
|
||||||
|
* Updates stored state; does NOT switch the canvas to footer view.
|
||||||
|
*/
|
||||||
|
const setFooter = useCallback((tree: SerializedTreeNode) => {
|
||||||
|
const craftState = treeToState(tree);
|
||||||
|
setFooterPage((prev) => ({ ...prev, craftState }));
|
||||||
|
// If the canvas is currently showing the footer, refresh it live
|
||||||
|
if (activePageIdRef.current === FOOTER_ID) {
|
||||||
|
loadState(craftState, EMPTY_FOOTER);
|
||||||
|
}
|
||||||
|
}, [treeToState, loadState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContext.Provider
|
<PageContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -291,6 +405,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setHeaderCraftState,
|
setHeaderCraftState,
|
||||||
setFooterCraftState,
|
setFooterCraftState,
|
||||||
setPagesCraftState,
|
setPagesCraftState,
|
||||||
|
replaceAllPages,
|
||||||
|
replaceCurrentPage,
|
||||||
|
setHeader,
|
||||||
|
setFooter,
|
||||||
siteDesign: design,
|
siteDesign: design,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
49
craft/src/types/sitesmith.ts
Normal file
49
craft/src/types/sitesmith.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { SerializedNodes } from '@craftjs/core';
|
||||||
|
|
||||||
|
export type SitesmithStatus =
|
||||||
|
| 'OK_BONUS' | 'OK_MONTHLY'
|
||||||
|
| 'DISABLED' | 'CAP_REACHED'
|
||||||
|
| 'USER_KILLSWITCH' | 'SERVER_KILLSWITCH'
|
||||||
|
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID';
|
||||||
|
|
||||||
|
export interface SitesmithSummary {
|
||||||
|
enabled: boolean;
|
||||||
|
monthly_cap: number;
|
||||||
|
monthly_used: number;
|
||||||
|
bonus_credits: number;
|
||||||
|
resets_on: string;
|
||||||
|
status: SitesmithStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SitesmithMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
response_type: 'replace' | 'patch' | 'ask' | 'error' | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedTreeNode {
|
||||||
|
type: { resolvedName: string };
|
||||||
|
props: Record<string, unknown> & { aiName?: string; node_id?: string };
|
||||||
|
nodes?: SerializedTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SitesmithPatchOp =
|
||||||
|
| { op: 'update_props'; node_id: string; props: Record<string, unknown> }
|
||||||
|
| { op: 'replace_node'; node_id: string; tree: SerializedTreeNode }
|
||||||
|
| { op: 'insert_after'; node_id: string; tree: SerializedTreeNode }
|
||||||
|
| { op: 'insert_before'; node_id: string; tree: SerializedTreeNode }
|
||||||
|
| { op: 'delete_node'; node_id: string };
|
||||||
|
|
||||||
|
export type SitesmithResponse =
|
||||||
|
| { type: 'replace'; scope: 'site' | 'page' | 'section';
|
||||||
|
pages: Array<{ name: string; tree: SerializedTreeNode }>;
|
||||||
|
header?: { tree: SerializedTreeNode };
|
||||||
|
footer?: { tree: SerializedTreeNode };
|
||||||
|
message: string; }
|
||||||
|
| { type: 'patch'; ops: SitesmithPatchOp[]; message: string; }
|
||||||
|
| { type: 'ask'; question: string; options?: string[]; };
|
||||||
|
|
||||||
|
export interface SendResultOk { ok: true; response: SitesmithResponse; }
|
||||||
|
export interface SendResultErr { ok: false; status: SitesmithStatus | 'BLOCKED'; message: string; }
|
||||||
|
export type SendResult = SendResultOk | SendResultErr;
|
||||||
90
craft/src/utils/apply-ai-response.test.ts
Normal file
90
craft/src/utils/apply-ai-response.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { serializeTreeForCraft, __test } from './apply-ai-response';
|
||||||
|
|
||||||
|
describe('serializeTreeForCraft', () => {
|
||||||
|
test('flattens nested tree', () => {
|
||||||
|
const tree = {
|
||||||
|
type: { resolvedName: 'Section' },
|
||||||
|
props: { aiName: 'Hero', node_id: 'ai-hero-1' },
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: { resolvedName: 'Heading' },
|
||||||
|
props: { aiName: 'Title', node_id: 'ai-h-1', text: 'Welcome' },
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect(out.rootNodeId).toBe('ai-hero-1');
|
||||||
|
expect((out.nodes['ai-hero-1'] as any).nodes).toEqual(['ai-h-1']);
|
||||||
|
expect((out.nodes['ai-h-1'] as any).parent).toBe('ROOT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-generates ids when node_id is missing', () => {
|
||||||
|
const tree = { type: { resolvedName: 'Heading' }, props: {}, nodes: [] };
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect(typeof out.rootNodeId).toBe('string');
|
||||||
|
expect(out.nodes[out.rootNodeId]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets isCanvas true for layout components', () => {
|
||||||
|
const tree = {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
props: { node_id: 'c1' },
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect((out.nodes['ROOT'] as any).isCanvas).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets isCanvas false for leaf components', () => {
|
||||||
|
const tree = {
|
||||||
|
type: { resolvedName: 'Heading' },
|
||||||
|
props: { node_id: 'h1' },
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect((out.nodes['ROOT'] as any).isCanvas).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aliases root node to ROOT key', () => {
|
||||||
|
const tree = {
|
||||||
|
type: { resolvedName: 'Section' },
|
||||||
|
props: { node_id: 'ai-section-1' },
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect(out.nodes['ROOT']).toBeDefined();
|
||||||
|
expect((out.nodes['ROOT'] as any).parent).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findNodeIdByAiNodeId', () => {
|
||||||
|
const query = {
|
||||||
|
getNodes: () => ({
|
||||||
|
'craft-id-1': { data: { props: { node_id: 'ai-hero-1' } } },
|
||||||
|
'craft-id-2': { data: { props: { node_id: 'ai-cta-1' } } },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('returns craft id for matching node_id prop', () => {
|
||||||
|
expect(__test.findNodeIdByAiNodeId(query, 'ai-hero-1')).toBe('craft-id-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns craft id for second entry', () => {
|
||||||
|
expect(__test.findNodeIdByAiNodeId(query, 'ai-cta-1')).toBe('craft-id-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to raw id match', () => {
|
||||||
|
const q = {
|
||||||
|
getNodes: () => ({
|
||||||
|
'exact-id': { data: { props: {} } },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
expect(__test.findNodeIdByAiNodeId(q, 'exact-id')).toBe('exact-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when not found', () => {
|
||||||
|
expect(__test.findNodeIdByAiNodeId({ getNodes: () => ({}) }, 'missing')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
219
craft/src/utils/apply-ai-response.ts
Normal file
219
craft/src/utils/apply-ai-response.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import type { NodeTree } from '@craftjs/core';
|
||||||
|
import { usePages } from '../state/PageContext';
|
||||||
|
import { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith';
|
||||||
|
|
||||||
|
/** Component types that act as drop targets (isCanvas = true) */
|
||||||
|
const CANVAS_TYPES = new Set([
|
||||||
|
'Container', 'Section', 'ColumnLayout', 'BackgroundSection',
|
||||||
|
'HeroSimple', 'FeaturesGrid', 'CTASection',
|
||||||
|
'FormContainer', 'Navbar', 'Footer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a SerializedTreeNode tree into a Craft.js node map ready for
|
||||||
|
* `actions.deserialize()`.
|
||||||
|
*
|
||||||
|
* Returns `{ rootNodeId, nodes }` where `nodes` is a flat map keyed by node id.
|
||||||
|
* The root entry is also aliased under 'ROOT' so Craft.js can find it when
|
||||||
|
* calling `actions.deserialize(JSON.stringify(nodes))`.
|
||||||
|
*/
|
||||||
|
export function serializeTreeForCraft(tree: SerializedTreeNode): { rootNodeId: string; nodes: Record<string, unknown> } {
|
||||||
|
const idCounter = { n: 0 };
|
||||||
|
const nodes: Record<string, any> = {};
|
||||||
|
|
||||||
|
const walk = (node: SerializedTreeNode, parent: string | null): string => {
|
||||||
|
const id = (node.props.node_id as string | undefined) || `ai-auto-${idCounter.n++}`;
|
||||||
|
nodes[id] = {
|
||||||
|
type: node.type,
|
||||||
|
props: node.props,
|
||||||
|
displayName: node.type.resolvedName,
|
||||||
|
isCanvas: CANVAS_TYPES.has(node.type.resolvedName),
|
||||||
|
parent,
|
||||||
|
nodes: [] as string[],
|
||||||
|
hidden: false,
|
||||||
|
custom: {},
|
||||||
|
linkedNodes: {},
|
||||||
|
};
|
||||||
|
for (const child of node.nodes ?? []) {
|
||||||
|
const childId = walk(child, id);
|
||||||
|
nodes[id].nodes.push(childId);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootId = walk(tree, null);
|
||||||
|
|
||||||
|
// Craft.js frame expects a 'ROOT' key; alias it if the AI gave a different id
|
||||||
|
if (rootId !== 'ROOT') {
|
||||||
|
nodes['ROOT'] = { ...nodes[rootId], parent: null };
|
||||||
|
// Fix children's parent reference to 'ROOT'
|
||||||
|
for (const childId of nodes['ROOT'].nodes as string[]) {
|
||||||
|
if (nodes[childId]) nodes[childId].parent = 'ROOT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rootNodeId: rootId, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Craft.js `NodeTree` from a `SerializedTreeNode` using `query.parseFreshNode`.
|
||||||
|
* This is the correct way to construct a tree for `actions.addNodeTree()` when
|
||||||
|
* inserting/replacing sections or individual nodes.
|
||||||
|
*/
|
||||||
|
function buildNodeTree(query: any, tree: SerializedTreeNode): NodeTree {
|
||||||
|
const idCounter = { n: 0 };
|
||||||
|
const craftNodes: Record<string, any> = {};
|
||||||
|
|
||||||
|
const walk = (node: SerializedTreeNode, parent: string | null): string => {
|
||||||
|
const id = (node.props.node_id as string | undefined) || `ai-auto-${idCounter.n++}`;
|
||||||
|
const craftNode = (query.parseFreshNode({
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
type: node.type,
|
||||||
|
props: node.props,
|
||||||
|
displayName: node.type.resolvedName,
|
||||||
|
isCanvas: CANVAS_TYPES.has(node.type.resolvedName),
|
||||||
|
parent,
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
hidden: false,
|
||||||
|
custom: {},
|
||||||
|
},
|
||||||
|
}) as any).toNode() as any;
|
||||||
|
craftNodes[id] = craftNode;
|
||||||
|
for (const child of node.nodes ?? []) {
|
||||||
|
const childId = walk(child, id);
|
||||||
|
craftNodes[id].data.nodes.push(childId);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootId = walk(tree, null);
|
||||||
|
return { rootNodeId: rootId, nodes: craftNodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the Craft.js node id that corresponds to an AI node_id value.
|
||||||
|
* Checks `data.props.node_id` first, then falls back to raw id equality.
|
||||||
|
*/
|
||||||
|
export function findNodeIdByAiNodeId(query: any, aiNodeId: string): string | null {
|
||||||
|
const all = query.getNodes() as Record<string, any>;
|
||||||
|
for (const [id, n] of Object.entries(all)) {
|
||||||
|
if (n.data?.props?.node_id === aiNodeId) return id;
|
||||||
|
if (id === aiNodeId) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported for unit tests */
|
||||||
|
export const __test = { findNodeIdByAiNodeId };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that returns an `apply` function.
|
||||||
|
* Call `apply(response)` after a successful Sitesmith API call to materialize
|
||||||
|
* the AI's instructions into the editor.
|
||||||
|
*/
|
||||||
|
export function useApplyAiResponse() {
|
||||||
|
const { actions, query } = useEditor();
|
||||||
|
const pages = usePages();
|
||||||
|
|
||||||
|
return async function apply(resp: SitesmithResponse): Promise<{ ok: boolean; message?: string }> {
|
||||||
|
// 'ask' type = AI wants clarification, nothing to apply
|
||||||
|
if (resp.type === 'ask') return { ok: true };
|
||||||
|
|
||||||
|
if (resp.type === 'replace') {
|
||||||
|
if (resp.scope === 'site') {
|
||||||
|
pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree })));
|
||||||
|
if (resp.header) pages.setHeader(resp.header.tree);
|
||||||
|
if (resp.footer) pages.setFooter(resp.footer.tree);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.scope === 'page') {
|
||||||
|
pages.replaceCurrentPage(resp.pages[0]);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.scope === 'section') {
|
||||||
|
// Insert each provided tree as a new node tree appended to ROOT
|
||||||
|
for (const p of resp.pages) {
|
||||||
|
try {
|
||||||
|
const nodeTree = buildNodeTree(query, p.tree);
|
||||||
|
actions.addNodeTree(nodeTree, 'ROOT');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sitesmith: failed to add section tree', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.type === 'patch') {
|
||||||
|
return applyPatch(actions, query, resp.ops);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, message: 'Unknown response type' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPatch(
|
||||||
|
actions: any,
|
||||||
|
query: any,
|
||||||
|
ops: any[],
|
||||||
|
): { ok: boolean; message?: string } {
|
||||||
|
for (const op of ops) {
|
||||||
|
const id = findNodeIdByAiNodeId(query, op.node_id);
|
||||||
|
if (!id) {
|
||||||
|
console.warn('sitesmith patch: node_id not found, skipping op:', op.node_id, op.op);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (op.op) {
|
||||||
|
case 'update_props':
|
||||||
|
actions.setProp(id, (p: any) => { Object.assign(p, op.props); });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'replace_node': {
|
||||||
|
try {
|
||||||
|
const nodeTree = buildNodeTree(query, op.tree);
|
||||||
|
const parent: string = query.node(id).get().data.parent ?? 'ROOT';
|
||||||
|
const siblings: string[] = query.node(parent).childNodes();
|
||||||
|
const index = siblings.indexOf(id);
|
||||||
|
actions.delete(id);
|
||||||
|
actions.addNodeTree(nodeTree, parent, index);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sitesmith patch: replace_node failed', e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'insert_after':
|
||||||
|
case 'insert_before': {
|
||||||
|
try {
|
||||||
|
const nodeTree = buildNodeTree(query, op.tree);
|
||||||
|
const parent: string = query.node(id).get().data.parent ?? 'ROOT';
|
||||||
|
const siblings: string[] = query.node(parent).childNodes();
|
||||||
|
const index = siblings.indexOf(id);
|
||||||
|
const at = op.op === 'insert_after' ? index + 1 : index;
|
||||||
|
actions.addNodeTree(nodeTree, parent, at);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`sitesmith patch: ${op.op} failed`, e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete_node':
|
||||||
|
try {
|
||||||
|
actions.delete(id);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sitesmith patch: delete_node failed', e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('sitesmith patch: unknown op', (op as any).op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
24
craft/src/utils/canvas-summary.test.ts
Normal file
24
craft/src/utils/canvas-summary.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { summarizeCanvas } from './canvas-summary';
|
||||||
|
|
||||||
|
const fixture = {
|
||||||
|
ROOT: { type: { resolvedName: 'Container' }, props: { aiName: 'Page Root', node_id: 'ai-root-1' }, nodes: ['n1','n2'], parent: null },
|
||||||
|
n1: { type: { resolvedName: 'Heading' }, props: { aiName: 'Hero Title', node_id: 'ai-hero-1', text: 'Welcome', level: 1, style: { color: '#fff' } }, nodes: [], parent: 'ROOT' },
|
||||||
|
n2: { type: { resolvedName: 'HtmlBlock' }, props: { aiName: 'Custom Embed', node_id: 'ai-html-1', code: '<div>opaque</div>' }, nodes: [], parent: 'ROOT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('summarizeCanvas', () => {
|
||||||
|
test('one line per node with id and aiName', () => {
|
||||||
|
const out = summarizeCanvas(fixture as any);
|
||||||
|
expect(out).toContain('Container id=ai-root-1');
|
||||||
|
expect(out).toContain('Heading id=ai-hero-1 name="Hero Title"');
|
||||||
|
});
|
||||||
|
test('excludes style props', () => {
|
||||||
|
expect(summarizeCanvas(fixture as any)).not.toContain('color=');
|
||||||
|
});
|
||||||
|
test('truncates to maxChars', () => {
|
||||||
|
const out = summarizeCanvas(fixture as any, 60);
|
||||||
|
expect(out.length).toBeLessThanOrEqual(60);
|
||||||
|
expect(out).toContain('truncated');
|
||||||
|
});
|
||||||
|
});
|
||||||
32
craft/src/utils/canvas-summary.ts
Normal file
32
craft/src/utils/canvas-summary.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { SerializedNodes } from '@craftjs/core';
|
||||||
|
|
||||||
|
export function summarizeCanvas(state: SerializedNodes, maxChars = 6000): string {
|
||||||
|
const root = state['ROOT'];
|
||||||
|
if (!root) return '(empty canvas)';
|
||||||
|
const lines: string[] = [];
|
||||||
|
const visit = (id: string, depth: number) => {
|
||||||
|
const node = state[id];
|
||||||
|
if (!node) return;
|
||||||
|
const indent = ' '.repeat(depth);
|
||||||
|
const type = typeof node.type === 'object' ? (node.type as any).resolvedName : String(node.type);
|
||||||
|
const props = node.props || {};
|
||||||
|
const aiName = (props as any).aiName ?? '';
|
||||||
|
const nodeId = (props as any).node_id ?? id;
|
||||||
|
const interesting: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(props)) {
|
||||||
|
if (k === 'aiName' || k === 'node_id' || k === 'style') continue;
|
||||||
|
if (v == null) continue;
|
||||||
|
const repr = typeof v === 'string' ? v : JSON.stringify(v);
|
||||||
|
const truncated = repr.length > 60 ? repr.slice(0, 57) + '…' : repr;
|
||||||
|
interesting.push(`${k}=${truncated}`);
|
||||||
|
if (interesting.length >= 3) break;
|
||||||
|
}
|
||||||
|
lines.push(`${indent}- ${type} id=${nodeId} name="${aiName}" {${interesting.join(', ')}}`);
|
||||||
|
if (type === 'HtmlBlock') return;
|
||||||
|
for (const childId of node.nodes || []) visit(childId, depth + 1);
|
||||||
|
};
|
||||||
|
visit('ROOT', 0);
|
||||||
|
let out = lines.join('\n');
|
||||||
|
if (out.length > maxChars) out = out.slice(0, maxChars - 30) + '\n… (truncated)';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
72
craft/tests/sitesmith.spec.ts
Normal file
72
craft/tests/sitesmith.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sitesmith E2E. Requires staging users pre-created on whp-staging:
|
||||||
|
* - sitesmith_disabled (no entitlement)
|
||||||
|
* - sitesmith_enabled (sitesmith_enabled=1, cap=50, 0 used)
|
||||||
|
* - sitesmith_capped (sitesmith_enabled=1, cap=2, 2 used)
|
||||||
|
* - sitesmith_bonus (sitesmith_enabled=0, bonus=2)
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* PLAYWRIGHT_BASE_URL=https://192.168.1.105:8080
|
||||||
|
* SITESMITH_TEST_PASSWORD=...
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.1.105:8080';
|
||||||
|
const PWD = process.env.SITESMITH_TEST_PASSWORD || 'changeme';
|
||||||
|
|
||||||
|
async function login(page: any, username: string) {
|
||||||
|
await page.goto(BASE);
|
||||||
|
// WHP login uses input[name="user"], not input[name="username"]
|
||||||
|
await page.fill('input[name="user"]', username);
|
||||||
|
await page.fill('input[name="password"]', PWD);
|
||||||
|
await page.click('button[type="submit"], input[type="submit"]');
|
||||||
|
await page.waitForURL('**/index.php**');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSiteBuilder(page: any) {
|
||||||
|
await page.goto(`${BASE}/?page=site-builder`);
|
||||||
|
await page.click('a:has-text("Open Editor")');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('locked: disabled user sees upgrade banner', async ({ page }) => {
|
||||||
|
await login(page, 'sitesmith_disabled');
|
||||||
|
await openSiteBuilder(page);
|
||||||
|
await page.click('button:has-text("Sitesmith")');
|
||||||
|
await expect(page.getByText('Sitesmith is a paid addon')).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /Upgrade your plan/ })).toBeVisible();
|
||||||
|
await expect(page.locator('textarea[placeholder*="Upgrade"]')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cap reached: enabled but at cap', async ({ page }) => {
|
||||||
|
await login(page, 'sitesmith_capped');
|
||||||
|
await openSiteBuilder(page);
|
||||||
|
await page.click('button:has-text("Sitesmith")');
|
||||||
|
await expect(page.getByText(/Monthly cap reached/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bonus: bonus credits allow chat when disabled', async ({ page }) => {
|
||||||
|
await login(page, 'sitesmith_bonus');
|
||||||
|
await openSiteBuilder(page);
|
||||||
|
await page.click('button:has-text("Sitesmith")');
|
||||||
|
await expect(page.locator('textarea[placeholder*="Describe"]')).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full build → patch preserves manual edit', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
await login(page, 'sitesmith_enabled');
|
||||||
|
await openSiteBuilder(page);
|
||||||
|
await page.click('button:has-text("Sitesmith")');
|
||||||
|
await page.fill('textarea[placeholder*="Describe"]', 'Two-page site for a small bakery. Friendly tone. Hero with cupcakes.');
|
||||||
|
await page.click('button:has-text("→")');
|
||||||
|
await expect(page.getByText('Replace your entire site?')).toBeVisible({ timeout: 90_000 });
|
||||||
|
await page.click('button:has-text("Replace site")');
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.locator('h1').first().click();
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await page.keyboard.type('Custom Manual Edit');
|
||||||
|
await page.fill('textarea[placeholder*="Describe"]', 'add a 3-tier pricing section');
|
||||||
|
await page.click('button:has-text("→")');
|
||||||
|
await expect(page.locator('h1:has-text("Custom Manual Edit")')).toBeVisible({ timeout: 90_000 });
|
||||||
|
await expect(page.getByText(/pricing/i)).toBeVisible();
|
||||||
|
});
|
||||||
8
craft/vitest.config.ts
Normal file
8
craft/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user