@tpitre/story-ui 2.3.2 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/production-app/src/App.tsx +381 -45
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
9
|
+
import * as Babel from '@babel/standalone';
|
|
9
10
|
import { LivePreviewRenderer } from './LivePreviewRenderer';
|
|
10
11
|
import { availableComponents } from './componentRegistry';
|
|
11
12
|
import { aiConsiderations, hasConsiderations } from './considerations';
|
|
@@ -160,22 +161,22 @@ const useResizable = (initialWidth: number, minWidth: number, maxWidth: number)
|
|
|
160
161
|
return { width, startResize };
|
|
161
162
|
};
|
|
162
163
|
|
|
163
|
-
// Default models for fallback
|
|
164
|
+
// Default models for fallback - Updated November 2025
|
|
164
165
|
const DEFAULT_MODELS: Record<string, ModelOption[]> = {
|
|
165
166
|
claude: [
|
|
166
|
-
{ id: 'claude-
|
|
167
|
-
{ id: 'claude-sonnet-4-
|
|
168
|
-
{ id: 'claude-
|
|
167
|
+
{ id: 'claude-opus-4-5', name: 'Opus 4.5', provider: 'claude' },
|
|
168
|
+
{ id: 'claude-sonnet-4-5', name: 'Sonnet 4.5', provider: 'claude' },
|
|
169
|
+
{ id: 'claude-haiku-4-5', name: 'Haiku 4.5', provider: 'claude' },
|
|
169
170
|
],
|
|
170
171
|
openai: [
|
|
171
|
-
{ id: 'gpt-
|
|
172
|
-
{ id: 'gpt-
|
|
173
|
-
{ id: 'gpt-
|
|
172
|
+
{ id: 'gpt-5.1', name: 'GPT-5.1', provider: 'openai' },
|
|
173
|
+
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'openai' },
|
|
174
|
+
{ id: 'gpt-5-nano', name: 'GPT-5 Nano', provider: 'openai' },
|
|
174
175
|
],
|
|
175
176
|
gemini: [
|
|
176
|
-
{ id: 'gemini-
|
|
177
|
-
{ id: 'gemini-
|
|
178
|
-
{ id: 'gemini-
|
|
177
|
+
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', provider: 'gemini' },
|
|
178
|
+
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'gemini' },
|
|
179
|
+
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'gemini' },
|
|
179
180
|
],
|
|
180
181
|
};
|
|
181
182
|
|
|
@@ -314,6 +315,11 @@ const Icons = {
|
|
|
314
315
|
<path d="M6 9l6 6 6-6" />
|
|
315
316
|
</svg>
|
|
316
317
|
),
|
|
318
|
+
ExternalLink: () => (
|
|
319
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
320
|
+
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
|
321
|
+
</svg>
|
|
322
|
+
),
|
|
317
323
|
};
|
|
318
324
|
|
|
319
325
|
// ============================================================================
|
|
@@ -551,8 +557,38 @@ const ImageUploadArea: React.FC<{
|
|
|
551
557
|
);
|
|
552
558
|
};
|
|
553
559
|
|
|
560
|
+
// Load Prism.js dynamically for syntax highlighting
|
|
561
|
+
const loadPrism = (): Promise<void> => {
|
|
562
|
+
return new Promise((resolve) => {
|
|
563
|
+
if ((window as any).Prism) {
|
|
564
|
+
resolve();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Load CSS
|
|
569
|
+
const link = document.createElement('link');
|
|
570
|
+
link.rel = 'stylesheet';
|
|
571
|
+
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css';
|
|
572
|
+
document.head.appendChild(link);
|
|
573
|
+
|
|
574
|
+
// Load Prism core
|
|
575
|
+
const script = document.createElement('script');
|
|
576
|
+
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js';
|
|
577
|
+
script.onload = () => {
|
|
578
|
+
// Load JSX component after core
|
|
579
|
+
const jsxScript = document.createElement('script');
|
|
580
|
+
jsxScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js';
|
|
581
|
+
jsxScript.onload = () => resolve();
|
|
582
|
+
document.head.appendChild(jsxScript);
|
|
583
|
+
};
|
|
584
|
+
document.head.appendChild(script);
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
|
|
554
588
|
const CodeViewer: React.FC<{ code: string }> = ({ code }) => {
|
|
555
589
|
const [copied, setCopied] = useState(false);
|
|
590
|
+
const [prismLoaded, setPrismLoaded] = useState(false);
|
|
591
|
+
const codeRef = useRef<HTMLElement>(null);
|
|
556
592
|
|
|
557
593
|
const handleCopy = async () => {
|
|
558
594
|
await navigator.clipboard.writeText(code);
|
|
@@ -560,6 +596,18 @@ const CodeViewer: React.FC<{ code: string }> = ({ code }) => {
|
|
|
560
596
|
setTimeout(() => setCopied(false), 2000);
|
|
561
597
|
};
|
|
562
598
|
|
|
599
|
+
// Load Prism on mount
|
|
600
|
+
useEffect(() => {
|
|
601
|
+
loadPrism().then(() => setPrismLoaded(true));
|
|
602
|
+
}, []);
|
|
603
|
+
|
|
604
|
+
// Apply Prism syntax highlighting when code changes or Prism loads
|
|
605
|
+
useEffect(() => {
|
|
606
|
+
if (codeRef.current && prismLoaded && (window as any).Prism) {
|
|
607
|
+
(window as any).Prism.highlightElement(codeRef.current);
|
|
608
|
+
}
|
|
609
|
+
}, [code, prismLoaded]);
|
|
610
|
+
|
|
563
611
|
return (
|
|
564
612
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
565
613
|
<div style={{
|
|
@@ -599,21 +647,97 @@ const CodeViewer: React.FC<{ code: string }> = ({ code }) => {
|
|
|
599
647
|
fontSize: '13px',
|
|
600
648
|
lineHeight: 1.6,
|
|
601
649
|
fontFamily: '"Fira Code", "SF Mono", Monaco, monospace',
|
|
602
|
-
background: '#
|
|
603
|
-
|
|
650
|
+
background: '#1d1f21',
|
|
651
|
+
borderRadius: 0,
|
|
604
652
|
}}
|
|
605
653
|
>
|
|
606
|
-
{
|
|
654
|
+
<code ref={codeRef} className="language-jsx">
|
|
655
|
+
{code}
|
|
656
|
+
</code>
|
|
607
657
|
</pre>
|
|
608
658
|
</div>
|
|
609
659
|
);
|
|
610
660
|
};
|
|
611
661
|
|
|
662
|
+
// ============================================================================
|
|
663
|
+
// POPOUT MODE COMPONENT
|
|
664
|
+
// ============================================================================
|
|
665
|
+
|
|
666
|
+
// Check if we're in popout mode (loaded in iframe for full-width preview)
|
|
667
|
+
const isPopoutMode = new URLSearchParams(window.location.search).get('popout') === 'true';
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Popout Preview Component
|
|
671
|
+
*
|
|
672
|
+
* This is a minimal component that renders ONLY the LivePreviewRenderer.
|
|
673
|
+
* It listens for postMessage from the parent window to receive the JSX code.
|
|
674
|
+
*
|
|
675
|
+
* This approach is completely design-system agnostic because:
|
|
676
|
+
* - It uses the same bundled CSS (whatever library the user has)
|
|
677
|
+
* - It uses the same component registry
|
|
678
|
+
* - No framework-specific code needed
|
|
679
|
+
*/
|
|
680
|
+
const PopoutPreview: React.FC = () => {
|
|
681
|
+
const [code, setCode] = useState<string | null>(null);
|
|
682
|
+
|
|
683
|
+
useEffect(() => {
|
|
684
|
+
// Listen for the preview code from the parent window
|
|
685
|
+
const handleMessage = (event: MessageEvent) => {
|
|
686
|
+
// Verify the message is from our parent and has the right type
|
|
687
|
+
if (event.data && event.data.type === 'PREVIEW_CODE' && typeof event.data.code === 'string') {
|
|
688
|
+
setCode(event.data.code);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
window.addEventListener('message', handleMessage);
|
|
693
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
694
|
+
}, []);
|
|
695
|
+
|
|
696
|
+
// Simple full-viewport layout for the preview
|
|
697
|
+
return (
|
|
698
|
+
<div style={{
|
|
699
|
+
width: '100%',
|
|
700
|
+
height: '100vh',
|
|
701
|
+
overflow: 'auto',
|
|
702
|
+
background: THEME.bgSurface,
|
|
703
|
+
}}>
|
|
704
|
+
{code ? (
|
|
705
|
+
<LivePreviewRenderer
|
|
706
|
+
code={code}
|
|
707
|
+
containerStyle={{
|
|
708
|
+
width: '100%',
|
|
709
|
+
minHeight: '100%',
|
|
710
|
+
padding: '24px',
|
|
711
|
+
background: THEME.bgSurface
|
|
712
|
+
}}
|
|
713
|
+
onError={(err) => console.error('Preview error:', err)}
|
|
714
|
+
/>
|
|
715
|
+
) : (
|
|
716
|
+
<div style={{
|
|
717
|
+
display: 'flex',
|
|
718
|
+
alignItems: 'center',
|
|
719
|
+
justifyContent: 'center',
|
|
720
|
+
height: '100%',
|
|
721
|
+
color: THEME.textMuted,
|
|
722
|
+
fontSize: '14px',
|
|
723
|
+
}}>
|
|
724
|
+
Loading preview...
|
|
725
|
+
</div>
|
|
726
|
+
)}
|
|
727
|
+
</div>
|
|
728
|
+
);
|
|
729
|
+
};
|
|
730
|
+
|
|
612
731
|
// ============================================================================
|
|
613
732
|
// MAIN APP
|
|
614
733
|
// ============================================================================
|
|
615
734
|
|
|
616
735
|
const App: React.FC = () => {
|
|
736
|
+
// If we're in popout mode, render only the preview component
|
|
737
|
+
if (isPopoutMode) {
|
|
738
|
+
return <PopoutPreview />;
|
|
739
|
+
}
|
|
740
|
+
|
|
617
741
|
// Server configuration (providers, models)
|
|
618
742
|
const serverConfig = useServerConfig();
|
|
619
743
|
|
|
@@ -628,6 +752,7 @@ const App: React.FC = () => {
|
|
|
628
752
|
|
|
629
753
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
630
754
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
755
|
+
const previewContainerRef = useRef<HTMLDivElement>(null);
|
|
631
756
|
const { width: chatWidth, startResize } = useResizable(400, 320, 600);
|
|
632
757
|
|
|
633
758
|
const activeConversation = conversations.find(c => c.id === activeConversationId);
|
|
@@ -687,27 +812,94 @@ const App: React.FC = () => {
|
|
|
687
812
|
}
|
|
688
813
|
};
|
|
689
814
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
815
|
+
/**
|
|
816
|
+
* Open preview in a new window with an iframe for full-width viewing.
|
|
817
|
+
*
|
|
818
|
+
* This approach is completely design-system agnostic:
|
|
819
|
+
* - The iframe loads the same production app with ?popout=true
|
|
820
|
+
* - All CSS (whatever library) is loaded automatically
|
|
821
|
+
* - All components from the registry are available
|
|
822
|
+
* - Only uses postMessage to pass the JSX code
|
|
823
|
+
*/
|
|
824
|
+
const openPreviewInNewWindow = () => {
|
|
825
|
+
const newWindow = window.open('', '_blank');
|
|
826
|
+
if (!newWindow) {
|
|
827
|
+
console.error('Could not open new window - popup may be blocked');
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Get the base URL for the popout iframe
|
|
832
|
+
const popoutUrl = `${window.location.origin}${window.location.pathname}?popout=true`;
|
|
833
|
+
|
|
834
|
+
// Create a minimal HTML wrapper with an iframe
|
|
835
|
+
// The iframe loads the same app in popout mode, ensuring all styles work
|
|
836
|
+
const html = `<!DOCTYPE html>
|
|
837
|
+
<html>
|
|
838
|
+
<head>
|
|
839
|
+
<meta charset="UTF-8">
|
|
840
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
841
|
+
<title>Story UI - Full Width Preview</title>
|
|
842
|
+
<style>
|
|
843
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
844
|
+
html, body { height: 100%; background: ${THEME.bg}; font-family: system-ui, sans-serif; }
|
|
845
|
+
.header {
|
|
846
|
+
position: fixed; top: 0; left: 0; right: 0; height: 48px;
|
|
847
|
+
padding: 0 24px; background: ${THEME.bgElevated};
|
|
848
|
+
border-bottom: 1px solid ${THEME.border};
|
|
849
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
850
|
+
z-index: 1000;
|
|
851
|
+
}
|
|
852
|
+
.header h1 { font-size: 14px; font-weight: 500; color: ${THEME.textMuted}; }
|
|
853
|
+
.header span { font-size: 12px; color: ${THEME.textSubtle}; }
|
|
854
|
+
.iframe-container { position: absolute; top: 48px; left: 0; right: 0; bottom: 0; }
|
|
855
|
+
iframe { width: 100%; height: 100%; border: none; }
|
|
856
|
+
</style>
|
|
857
|
+
</head>
|
|
858
|
+
<body>
|
|
859
|
+
<div class="header">
|
|
860
|
+
<h1>Story UI - Full Width Preview</h1>
|
|
861
|
+
<span>Close tab to return</span>
|
|
862
|
+
</div>
|
|
863
|
+
<div class="iframe-container">
|
|
864
|
+
<iframe id="preview-frame" src="${popoutUrl}"></iframe>
|
|
865
|
+
</div>
|
|
866
|
+
<script>
|
|
867
|
+
var code = ${JSON.stringify(previewCode)};
|
|
868
|
+
var iframe = document.getElementById('preview-frame');
|
|
869
|
+
iframe.onload = function() {
|
|
870
|
+
iframe.contentWindow.postMessage({ type: 'PREVIEW_CODE', code: code }, '*');
|
|
871
|
+
};
|
|
872
|
+
</script>
|
|
873
|
+
</body>
|
|
874
|
+
</html>`;
|
|
875
|
+
|
|
876
|
+
newWindow.document.write(html);
|
|
877
|
+
newWindow.document.close();
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
const generateComponent = async (prompt: string, imageAttachments: ImageAttachment[], currentMessages: Message[]): Promise<string> => {
|
|
881
|
+
// Get the last generated code from the passed-in messages (not stale state)
|
|
882
|
+
const lastGeneratedCode = currentMessages
|
|
693
883
|
.filter(m => m.generatedCode)
|
|
694
884
|
.slice(-1)[0]?.generatedCode;
|
|
695
885
|
|
|
696
886
|
// Check if this is an iteration (modification of existing code)
|
|
697
|
-
const isIteration = !!lastGeneratedCode &&
|
|
887
|
+
const isIteration = !!lastGeneratedCode && currentMessages.length > 0;
|
|
698
888
|
|
|
699
|
-
// Build conversation history
|
|
889
|
+
// Build conversation history from passed-in messages (excluding the just-added user message)
|
|
700
890
|
const conversationHistory = isIteration
|
|
701
|
-
?
|
|
891
|
+
? currentMessages.slice(0, -1).map(msg => ({
|
|
702
892
|
role: msg.role,
|
|
703
893
|
content: msg.role === 'assistant' && msg.generatedCode
|
|
704
894
|
? `[Generated JSX component - see CURRENT_CODE below]`
|
|
705
895
|
: msg.content
|
|
706
|
-
}))
|
|
896
|
+
}))
|
|
707
897
|
: [];
|
|
708
898
|
|
|
709
899
|
// Build a UNIVERSAL system prompt that works with ANY component library
|
|
710
|
-
// Design-system-specific rules come from aiConsiderations
|
|
900
|
+
// Design-system-specific rules come from aiConsiderations which includes:
|
|
901
|
+
// - Full documentation from story-ui-docs/ directory (guidelines, tokens, patterns, components)
|
|
902
|
+
// - Legacy considerations from story-ui-considerations.md
|
|
711
903
|
const basePrompt = `You are a JSX code generator. Your ONLY job is to output raw JSX code.
|
|
712
904
|
|
|
713
905
|
CRITICAL OUTPUT RULES:
|
|
@@ -719,24 +911,22 @@ CRITICAL OUTPUT RULES:
|
|
|
719
911
|
|
|
720
912
|
UNIVERSAL BEST PRACTICES (applies to ALL design systems):
|
|
721
913
|
|
|
722
|
-
|
|
723
|
-
-
|
|
724
|
-
- Use
|
|
725
|
-
- Never use white/light text colors unless on a dark or colored background
|
|
914
|
+
VISUAL DESIGN:
|
|
915
|
+
- Create polished, professional-looking interfaces
|
|
916
|
+
- Use proper visual hierarchy with appropriate heading and text sizes
|
|
726
917
|
- Ensure sufficient color contrast (4.5:1 for normal text, 3:1 for large text)
|
|
918
|
+
- Use dark text on light backgrounds for readability
|
|
919
|
+
- Never use emojis - use icons from the component library instead
|
|
920
|
+
|
|
921
|
+
RESPONSIVE DESIGN:
|
|
922
|
+
- All layouts should be responsive and mobile-friendly by default
|
|
923
|
+
- Avoid fixed pixel widths - use flexible/responsive approaches
|
|
924
|
+
- Layouts should stack appropriately on smaller screens
|
|
727
925
|
|
|
728
|
-
ACCESSIBILITY
|
|
729
|
-
- Use semantic HTML structure
|
|
926
|
+
ACCESSIBILITY:
|
|
927
|
+
- Use semantic HTML structure
|
|
730
928
|
- Include aria-labels on interactive elements without visible text
|
|
731
|
-
- Ensure focusable elements have visible focus states
|
|
732
|
-
- Use role attributes appropriately
|
|
733
929
|
- Form inputs should have associated labels
|
|
734
|
-
- Interactive elements should be keyboard accessible
|
|
735
|
-
|
|
736
|
-
RESPONSIVE DESIGN:
|
|
737
|
-
- Components should work at various viewport sizes
|
|
738
|
-
- Use relative units and flexible layouts
|
|
739
|
-
- Avoid fixed pixel widths that could cause overflow
|
|
740
930
|
|
|
741
931
|
AVAILABLE COMPONENTS (use ONLY these):
|
|
742
932
|
${availableComponents.join(', ')}${hasConsiderations ? `
|
|
@@ -826,9 +1016,124 @@ OUTPUT: Start immediately with < and output only JSX.`;
|
|
|
826
1016
|
.replace(/\n?```$/, '');
|
|
827
1017
|
}
|
|
828
1018
|
|
|
1019
|
+
// Validate that the response is actually JSX and not metadata/thinking
|
|
1020
|
+
const validationResult = validateJSXResponse(cleanCode);
|
|
1021
|
+
if (!validationResult.isValid) {
|
|
1022
|
+
throw new Error(validationResult.error || 'Invalid JSX response');
|
|
1023
|
+
}
|
|
1024
|
+
|
|
829
1025
|
return cleanCode;
|
|
830
1026
|
};
|
|
831
1027
|
|
|
1028
|
+
// Validate that the LLM response is valid JSX and not internal metadata
|
|
1029
|
+
const validateJSXResponse = (code: string): { isValid: boolean; error?: string } => {
|
|
1030
|
+
const trimmed = code.trim();
|
|
1031
|
+
|
|
1032
|
+
// Check for empty response
|
|
1033
|
+
if (!trimmed) {
|
|
1034
|
+
return { isValid: false, error: 'Empty response from LLM' };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Check for internal metadata tags that LLMs sometimes output
|
|
1038
|
+
const metadataTags = ['<budget>', '<usage>', '<thinking>', '<reflection>', '<output>', '<result>'];
|
|
1039
|
+
for (const tag of metadataTags) {
|
|
1040
|
+
if (trimmed.toLowerCase().includes(tag)) {
|
|
1041
|
+
return { isValid: false, error: 'LLM returned internal metadata instead of JSX components. Please try again.' };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Check that it starts with a valid JSX opening tag (component or HTML element)
|
|
1046
|
+
if (!trimmed.startsWith('<')) {
|
|
1047
|
+
return { isValid: false, error: 'Response does not start with JSX. LLM may have returned explanatory text.' };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Check for markdown artifacts
|
|
1051
|
+
if (trimmed.startsWith('```') || trimmed.includes('```jsx') || trimmed.includes('```tsx')) {
|
|
1052
|
+
return { isValid: false, error: 'Response contains markdown code fences' };
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Check that the JSX ends properly (not truncated mid-tag or mid-string)
|
|
1056
|
+
// Common truncation patterns:
|
|
1057
|
+
// - Ends with incomplete tag: <Text size="sm" c="dimmed" ta="center">);
|
|
1058
|
+
// - Ends mid-attribute: <Text size="
|
|
1059
|
+
// - Ends with unclosed string: "some text
|
|
1060
|
+
const lastChar = trimmed.slice(-1);
|
|
1061
|
+
const last10Chars = trimmed.slice(-10);
|
|
1062
|
+
|
|
1063
|
+
// Should end with > or /> or } or ; but NOT with ); which indicates truncation
|
|
1064
|
+
if (last10Chars.includes('>);') || last10Chars.includes('>");')) {
|
|
1065
|
+
return { isValid: false, error: 'JSX appears to be truncated (ends with >); pattern). LLM output was cut off.' };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Check for unclosed quotes at the end
|
|
1069
|
+
const quoteCount = (trimmed.match(/"/g) || []).length;
|
|
1070
|
+
if (quoteCount % 2 !== 0) {
|
|
1071
|
+
return { isValid: false, error: 'JSX has unclosed quotes - response appears truncated' };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Check for unclosed JSX expression braces
|
|
1075
|
+
const openBraces = (trimmed.match(/\{/g) || []).length;
|
|
1076
|
+
const closeBraces = (trimmed.match(/\}/g) || []).length;
|
|
1077
|
+
if (openBraces !== closeBraces) {
|
|
1078
|
+
return { isValid: false, error: `JSX has unbalanced braces (${openBraces} open, ${closeBraces} close) - response appears truncated` };
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Check for unclosed parentheses (common in arrow functions)
|
|
1082
|
+
const openParens = (trimmed.match(/\(/g) || []).length;
|
|
1083
|
+
const closeParens = (trimmed.match(/\)/g) || []).length;
|
|
1084
|
+
if (openParens !== closeParens) {
|
|
1085
|
+
return { isValid: false, error: `JSX has unbalanced parentheses (${openParens} open, ${closeParens} close) - response appears truncated` };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Try to do a basic Babel parse to catch syntax errors before rendering
|
|
1089
|
+
// This is the most reliable check - if Babel can't parse it, it won't render
|
|
1090
|
+
try {
|
|
1091
|
+
const wrappedCode = `(function() { return (${trimmed}); })()`;
|
|
1092
|
+
Babel.transform(wrappedCode, {
|
|
1093
|
+
presets: ['react'],
|
|
1094
|
+
filename: 'validation.tsx'
|
|
1095
|
+
});
|
|
1096
|
+
} catch (babelError: any) {
|
|
1097
|
+
// Extract useful error message
|
|
1098
|
+
const errorMsg = babelError.message || 'Unknown syntax error';
|
|
1099
|
+
if (errorMsg.includes('Unterminated') || errorMsg.includes('Unexpected token') || errorMsg.includes('Unexpected end')) {
|
|
1100
|
+
return { isValid: false, error: `JSX syntax error: ${errorMsg}. LLM response may be truncated or malformed.` };
|
|
1101
|
+
}
|
|
1102
|
+
// For other Babel errors, still fail validation
|
|
1103
|
+
return { isValid: false, error: `JSX parsing failed: ${errorMsg}` };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
return { isValid: true };
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
// Wrapper function with retry logic
|
|
1110
|
+
const generateComponentWithRetry = async (
|
|
1111
|
+
prompt: string,
|
|
1112
|
+
imageAttachments: ImageAttachment[],
|
|
1113
|
+
currentMessages: Message[],
|
|
1114
|
+
maxRetries: number = 2
|
|
1115
|
+
): Promise<string> => {
|
|
1116
|
+
let lastError: Error | null = null;
|
|
1117
|
+
|
|
1118
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1119
|
+
try {
|
|
1120
|
+
const result = await generateComponent(prompt, imageAttachments, currentMessages);
|
|
1121
|
+
return result;
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
1124
|
+
console.warn(`Generation attempt ${attempt + 1} failed:`, lastError.message);
|
|
1125
|
+
|
|
1126
|
+
// If this isn't the last attempt, wait briefly before retrying
|
|
1127
|
+
if (attempt < maxRetries) {
|
|
1128
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// All retries exhausted
|
|
1134
|
+
throw lastError || new Error('Generation failed after multiple attempts');
|
|
1135
|
+
};
|
|
1136
|
+
|
|
832
1137
|
const sendMessage = async () => {
|
|
833
1138
|
if (!inputValue.trim() || isGenerating) return;
|
|
834
1139
|
|
|
@@ -842,12 +1147,18 @@ OUTPUT: Start immediately with < and output only JSX.`;
|
|
|
842
1147
|
|
|
843
1148
|
// Determine if we need to create a new conversation or add to existing
|
|
844
1149
|
let conversationId = activeConversationId;
|
|
1150
|
+
let currentMessages: Message[] = [];
|
|
845
1151
|
|
|
846
1152
|
if (!conversationId) {
|
|
847
1153
|
// Create new conversation with the first message
|
|
848
1154
|
conversationId = createConversationWithMessage(userMessage);
|
|
849
1155
|
setActiveConversationId(conversationId);
|
|
1156
|
+
currentMessages = [userMessage];
|
|
850
1157
|
} else {
|
|
1158
|
+
// Get the current messages BEFORE the async state update
|
|
1159
|
+
const existingConv = conversations.find(c => c.id === conversationId);
|
|
1160
|
+
currentMessages = existingConv ? [...existingConv.messages, userMessage] : [userMessage];
|
|
1161
|
+
|
|
851
1162
|
// Add message to existing conversation
|
|
852
1163
|
setConversations(prev => prev.map(conv => {
|
|
853
1164
|
if (conv.id === conversationId) {
|
|
@@ -867,7 +1178,9 @@ OUTPUT: Start immediately with < and output only JSX.`;
|
|
|
867
1178
|
setIsGenerating(true);
|
|
868
1179
|
|
|
869
1180
|
try {
|
|
870
|
-
|
|
1181
|
+
// Pass currentMessages directly to avoid stale closure issues
|
|
1182
|
+
// Use retry wrapper to handle invalid LLM responses automatically
|
|
1183
|
+
const generatedCode = await generateComponentWithRetry(inputValue.trim(), currentImages, currentMessages);
|
|
871
1184
|
|
|
872
1185
|
const assistantMessage: Message = {
|
|
873
1186
|
id: generateId(),
|
|
@@ -1109,7 +1422,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
|
|
|
1109
1422
|
}}
|
|
1110
1423
|
>
|
|
1111
1424
|
{serverConfig.models.map(model => (
|
|
1112
|
-
<option key={model.id} value={model.id}>{model.
|
|
1425
|
+
<option key={model.id} value={model.id}>{model.name}</option>
|
|
1113
1426
|
))}
|
|
1114
1427
|
</select>
|
|
1115
1428
|
|
|
@@ -1501,9 +1814,30 @@ OUTPUT: Start immediately with < and output only JSX.`;
|
|
|
1501
1814
|
</div>
|
|
1502
1815
|
|
|
1503
1816
|
{previewCode && previewTab === 'preview' && (
|
|
1504
|
-
<
|
|
1505
|
-
|
|
1506
|
-
|
|
1817
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
1818
|
+
<span style={{ fontSize: '12px', color: THEME.textMuted }}>
|
|
1819
|
+
Live Preview
|
|
1820
|
+
</span>
|
|
1821
|
+
<button
|
|
1822
|
+
onClick={() => openPreviewInNewWindow()}
|
|
1823
|
+
title="Open in new window"
|
|
1824
|
+
style={{
|
|
1825
|
+
padding: '6px 10px',
|
|
1826
|
+
background: THEME.bgElevated,
|
|
1827
|
+
border: `1px solid ${THEME.border}`,
|
|
1828
|
+
borderRadius: '6px',
|
|
1829
|
+
color: THEME.textMuted,
|
|
1830
|
+
fontSize: '12px',
|
|
1831
|
+
cursor: 'pointer',
|
|
1832
|
+
display: 'flex',
|
|
1833
|
+
alignItems: 'center',
|
|
1834
|
+
gap: '4px',
|
|
1835
|
+
}}
|
|
1836
|
+
>
|
|
1837
|
+
<Icons.ExternalLink />
|
|
1838
|
+
Pop Out
|
|
1839
|
+
</button>
|
|
1840
|
+
</div>
|
|
1507
1841
|
)}
|
|
1508
1842
|
</div>
|
|
1509
1843
|
|
|
@@ -1511,11 +1845,13 @@ OUTPUT: Start immediately with < and output only JSX.`;
|
|
|
1511
1845
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
|
1512
1846
|
{previewCode ? (
|
|
1513
1847
|
previewTab === 'preview' ? (
|
|
1514
|
-
<
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1848
|
+
<div ref={previewContainerRef} style={{ height: '100%' }}>
|
|
1849
|
+
<LivePreviewRenderer
|
|
1850
|
+
code={previewCode}
|
|
1851
|
+
containerStyle={{ height: '100%', background: THEME.bgSurface }}
|
|
1852
|
+
onError={(err) => console.error('Preview error:', err)}
|
|
1853
|
+
/>
|
|
1854
|
+
</div>
|
|
1519
1855
|
) : (
|
|
1520
1856
|
<CodeViewer code={previewCode} />
|
|
1521
1857
|
)
|