@tpitre/story-ui 2.2.0 → 2.3.1

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.
Files changed (188) hide show
  1. package/.env.sample +82 -11
  2. package/README.md +89 -0
  3. package/dist/cli/deploy.d.ts +17 -0
  4. package/dist/cli/deploy.d.ts.map +1 -0
  5. package/dist/cli/deploy.js +696 -0
  6. package/dist/cli/index.d.ts +3 -0
  7. package/dist/cli/index.d.ts.map +1 -0
  8. package/dist/cli/index.js +26 -2
  9. package/dist/cli/setup.d.ts +11 -0
  10. package/dist/cli/setup.d.ts.map +1 -0
  11. package/dist/cli/setup.js +437 -110
  12. package/dist/mcp-server/index.d.ts +2 -0
  13. package/dist/mcp-server/index.d.ts.map +1 -0
  14. package/dist/mcp-server/index.js +120 -2
  15. package/dist/mcp-server/mcp-stdio-server.d.ts +3 -0
  16. package/dist/mcp-server/mcp-stdio-server.d.ts.map +1 -0
  17. package/dist/mcp-server/mcp-stdio-server.js +8 -1
  18. package/dist/mcp-server/routes/claude.d.ts +3 -0
  19. package/dist/mcp-server/routes/claude.d.ts.map +1 -0
  20. package/dist/mcp-server/routes/claude.js +60 -23
  21. package/dist/mcp-server/routes/components.d.ts +4 -0
  22. package/dist/mcp-server/routes/components.d.ts.map +1 -0
  23. package/dist/mcp-server/routes/frameworks.d.ts +38 -0
  24. package/dist/mcp-server/routes/frameworks.d.ts.map +1 -0
  25. package/dist/mcp-server/routes/frameworks.js +183 -0
  26. package/dist/mcp-server/routes/generateStory.d.ts +3 -0
  27. package/dist/mcp-server/routes/generateStory.d.ts.map +1 -0
  28. package/dist/mcp-server/routes/generateStory.js +160 -76
  29. package/dist/mcp-server/routes/generateStoryStream.d.ts +12 -0
  30. package/dist/mcp-server/routes/generateStoryStream.d.ts.map +1 -0
  31. package/dist/mcp-server/routes/generateStoryStream.js +947 -0
  32. package/dist/mcp-server/routes/hybridStories.d.ts +18 -0
  33. package/dist/mcp-server/routes/hybridStories.d.ts.map +1 -0
  34. package/dist/mcp-server/routes/mcpRemote.d.ts +14 -0
  35. package/dist/mcp-server/routes/mcpRemote.d.ts.map +1 -0
  36. package/dist/mcp-server/routes/mcpRemote.js +489 -0
  37. package/dist/mcp-server/routes/memoryStories.d.ts +26 -0
  38. package/dist/mcp-server/routes/memoryStories.d.ts.map +1 -0
  39. package/dist/mcp-server/routes/providers.d.ts +89 -0
  40. package/dist/mcp-server/routes/providers.d.ts.map +1 -0
  41. package/dist/mcp-server/routes/providers.js +369 -0
  42. package/dist/mcp-server/routes/storySync.d.ts +26 -0
  43. package/dist/mcp-server/routes/storySync.d.ts.map +1 -0
  44. package/dist/mcp-server/routes/streamTypes.d.ts +110 -0
  45. package/dist/mcp-server/routes/streamTypes.d.ts.map +1 -0
  46. package/dist/mcp-server/routes/streamTypes.js +18 -0
  47. package/dist/mcp-server/sessionManager.d.ts +50 -0
  48. package/dist/mcp-server/sessionManager.d.ts.map +1 -0
  49. package/dist/story-generator/componentBlacklist.d.ts +21 -0
  50. package/dist/story-generator/componentBlacklist.d.ts.map +1 -0
  51. package/dist/story-generator/componentDiscovery.d.ts +28 -0
  52. package/dist/story-generator/componentDiscovery.d.ts.map +1 -0
  53. package/dist/story-generator/componentRegistryGenerator.d.ts +49 -0
  54. package/dist/story-generator/componentRegistryGenerator.d.ts.map +1 -0
  55. package/dist/story-generator/componentRegistryGenerator.js +205 -0
  56. package/dist/story-generator/configLoader.d.ts +33 -0
  57. package/dist/story-generator/configLoader.d.ts.map +1 -0
  58. package/dist/story-generator/considerationsLoader.d.ts +32 -0
  59. package/dist/story-generator/considerationsLoader.d.ts.map +1 -0
  60. package/dist/story-generator/documentation-sources.d.ts +28 -0
  61. package/dist/story-generator/documentation-sources.d.ts.map +1 -0
  62. package/dist/story-generator/documentationLoader.d.ts +64 -0
  63. package/dist/story-generator/documentationLoader.d.ts.map +1 -0
  64. package/dist/story-generator/dynamicPackageDiscovery.d.ts +97 -0
  65. package/dist/story-generator/dynamicPackageDiscovery.d.ts.map +1 -0
  66. package/dist/story-generator/enhancedComponentDiscovery.d.ts +125 -0
  67. package/dist/story-generator/enhancedComponentDiscovery.d.ts.map +1 -0
  68. package/dist/story-generator/enhancedComponentDiscovery.js +111 -11
  69. package/dist/story-generator/framework-adapters/angular-adapter.d.ts +40 -0
  70. package/dist/story-generator/framework-adapters/angular-adapter.d.ts.map +1 -0
  71. package/dist/story-generator/framework-adapters/angular-adapter.js +427 -0
  72. package/dist/story-generator/framework-adapters/base-adapter.d.ts +75 -0
  73. package/dist/story-generator/framework-adapters/base-adapter.d.ts.map +1 -0
  74. package/dist/story-generator/framework-adapters/base-adapter.js +147 -0
  75. package/dist/story-generator/framework-adapters/framework-detector.d.ts +55 -0
  76. package/dist/story-generator/framework-adapters/framework-detector.d.ts.map +1 -0
  77. package/dist/story-generator/framework-adapters/framework-detector.js +323 -0
  78. package/dist/story-generator/framework-adapters/index.d.ts +97 -0
  79. package/dist/story-generator/framework-adapters/index.d.ts.map +1 -0
  80. package/dist/story-generator/framework-adapters/index.js +198 -0
  81. package/dist/story-generator/framework-adapters/react-adapter.d.ts +40 -0
  82. package/dist/story-generator/framework-adapters/react-adapter.d.ts.map +1 -0
  83. package/dist/story-generator/framework-adapters/react-adapter.js +316 -0
  84. package/dist/story-generator/framework-adapters/svelte-adapter.d.ts +40 -0
  85. package/dist/story-generator/framework-adapters/svelte-adapter.d.ts.map +1 -0
  86. package/dist/story-generator/framework-adapters/svelte-adapter.js +372 -0
  87. package/dist/story-generator/framework-adapters/types.d.ts +182 -0
  88. package/dist/story-generator/framework-adapters/types.d.ts.map +1 -0
  89. package/dist/story-generator/framework-adapters/types.js +8 -0
  90. package/dist/story-generator/framework-adapters/vue-adapter.d.ts +36 -0
  91. package/dist/story-generator/framework-adapters/vue-adapter.d.ts.map +1 -0
  92. package/dist/story-generator/framework-adapters/vue-adapter.js +336 -0
  93. package/dist/story-generator/framework-adapters/web-components-adapter.d.ts +54 -0
  94. package/dist/story-generator/framework-adapters/web-components-adapter.d.ts.map +1 -0
  95. package/dist/story-generator/framework-adapters/web-components-adapter.js +387 -0
  96. package/dist/story-generator/generateStory.d.ts +7 -0
  97. package/dist/story-generator/generateStory.d.ts.map +1 -0
  98. package/dist/story-generator/gitignoreManager.d.ts +50 -0
  99. package/dist/story-generator/gitignoreManager.d.ts.map +1 -0
  100. package/dist/story-generator/imageProcessor.d.ts +80 -0
  101. package/dist/story-generator/imageProcessor.d.ts.map +1 -0
  102. package/dist/story-generator/imageProcessor.js +391 -0
  103. package/dist/story-generator/inMemoryStoryService.d.ts +89 -0
  104. package/dist/story-generator/inMemoryStoryService.d.ts.map +1 -0
  105. package/dist/story-generator/llm-providers/base-provider.d.ts +36 -0
  106. package/dist/story-generator/llm-providers/base-provider.d.ts.map +1 -0
  107. package/dist/story-generator/llm-providers/base-provider.js +135 -0
  108. package/dist/story-generator/llm-providers/claude-provider.d.ts +23 -0
  109. package/dist/story-generator/llm-providers/claude-provider.d.ts.map +1 -0
  110. package/dist/story-generator/llm-providers/claude-provider.js +414 -0
  111. package/dist/story-generator/llm-providers/gemini-provider.d.ts +24 -0
  112. package/dist/story-generator/llm-providers/gemini-provider.d.ts.map +1 -0
  113. package/dist/story-generator/llm-providers/gemini-provider.js +406 -0
  114. package/dist/story-generator/llm-providers/index.d.ts +63 -0
  115. package/dist/story-generator/llm-providers/index.d.ts.map +1 -0
  116. package/dist/story-generator/llm-providers/index.js +169 -0
  117. package/dist/story-generator/llm-providers/openai-provider.d.ts +24 -0
  118. package/dist/story-generator/llm-providers/openai-provider.d.ts.map +1 -0
  119. package/dist/story-generator/llm-providers/openai-provider.js +458 -0
  120. package/dist/story-generator/llm-providers/settings-manager.d.ts +75 -0
  121. package/dist/story-generator/llm-providers/settings-manager.d.ts.map +1 -0
  122. package/dist/story-generator/llm-providers/settings-manager.js +173 -0
  123. package/dist/story-generator/llm-providers/story-llm-service.d.ts +79 -0
  124. package/dist/story-generator/llm-providers/story-llm-service.d.ts.map +1 -0
  125. package/dist/story-generator/llm-providers/story-llm-service.js +240 -0
  126. package/dist/story-generator/llm-providers/types.d.ts +153 -0
  127. package/dist/story-generator/llm-providers/types.d.ts.map +1 -0
  128. package/dist/story-generator/llm-providers/types.js +8 -0
  129. package/dist/story-generator/logger.d.ts +14 -0
  130. package/dist/story-generator/logger.d.ts.map +1 -0
  131. package/dist/story-generator/logger.js +96 -29
  132. package/dist/story-generator/postProcessStory.d.ts +6 -0
  133. package/dist/story-generator/postProcessStory.d.ts.map +1 -0
  134. package/dist/story-generator/productionGitignoreManager.d.ts +91 -0
  135. package/dist/story-generator/productionGitignoreManager.d.ts.map +1 -0
  136. package/dist/story-generator/promptGenerator.d.ts +48 -0
  137. package/dist/story-generator/promptGenerator.d.ts.map +1 -0
  138. package/dist/story-generator/promptGenerator.js +186 -1
  139. package/dist/story-generator/storyHistory.d.ts +44 -0
  140. package/dist/story-generator/storyHistory.d.ts.map +1 -0
  141. package/dist/story-generator/storySync.d.ts +68 -0
  142. package/dist/story-generator/storySync.d.ts.map +1 -0
  143. package/dist/story-generator/storyTracker.d.ts +48 -0
  144. package/dist/story-generator/storyTracker.d.ts.map +1 -0
  145. package/dist/story-generator/storyValidator.d.ts +6 -0
  146. package/dist/story-generator/storyValidator.d.ts.map +1 -0
  147. package/dist/story-generator/universalDesignSystemAdapter.d.ts +68 -0
  148. package/dist/story-generator/universalDesignSystemAdapter.d.ts.map +1 -0
  149. package/dist/story-generator/universalDesignSystemAdapter.js +138 -1
  150. package/dist/story-generator/urlRedirectService.d.ts +21 -0
  151. package/dist/story-generator/urlRedirectService.d.ts.map +1 -0
  152. package/dist/story-generator/validateStory.d.ts +19 -0
  153. package/dist/story-generator/validateStory.d.ts.map +1 -0
  154. package/dist/story-generator/validateStory.js +6 -2
  155. package/dist/story-generator/visionPrompts.d.ts +88 -0
  156. package/dist/story-generator/visionPrompts.d.ts.map +1 -0
  157. package/dist/story-generator/visionPrompts.js +462 -0
  158. package/dist/story-ui.config.d.ts +78 -0
  159. package/dist/story-ui.config.d.ts.map +1 -0
  160. package/dist/templates/StoryUI/StoryUIPanel.d.ts +4 -0
  161. package/dist/templates/StoryUI/StoryUIPanel.d.ts.map +1 -0
  162. package/dist/templates/StoryUI/StoryUIPanel.js +1874 -0
  163. package/dist/templates/StoryUI/StoryUIPanel.stories.d.ts +18 -0
  164. package/dist/templates/StoryUI/StoryUIPanel.stories.d.ts.map +1 -0
  165. package/dist/templates/StoryUI/StoryUIPanel.stories.js +37 -0
  166. package/dist/templates/StoryUI/index.d.ts +3 -0
  167. package/dist/templates/StoryUI/index.d.ts.map +1 -0
  168. package/dist/templates/StoryUI/index.js +2 -0
  169. package/package.json +17 -3
  170. package/templates/StoryUI/StoryUIPanel.tsx +1960 -384
  171. package/templates/StoryUI/index.tsx +1 -1
  172. package/templates/StoryUI/manager.tsx +264 -0
  173. package/templates/production-app/.env.example +11 -0
  174. package/templates/production-app/index.html +66 -0
  175. package/templates/production-app/package.json +30 -0
  176. package/templates/production-app/public/favicon.svg +5 -0
  177. package/templates/production-app/src/App.tsx +1560 -0
  178. package/templates/production-app/src/LivePreviewRenderer.tsx +420 -0
  179. package/templates/production-app/src/componentRegistry.ts +315 -0
  180. package/templates/production-app/src/considerations.ts +16 -0
  181. package/templates/production-app/src/index.css +284 -0
  182. package/templates/production-app/src/main.tsx +25 -0
  183. package/templates/production-app/tsconfig.json +32 -0
  184. package/templates/production-app/tsconfig.node.json +11 -0
  185. package/templates/production-app/vite.config.ts +83 -0
  186. package/templates/react-import-rule.json +2 -2
  187. package/dist/index.js +0 -12
  188. package/dist/story-ui.config.loader.js +0 -205
@@ -0,0 +1,1560 @@
1
+ /**
2
+ * Story UI Production App
3
+ *
4
+ * A Lovable/Bolt-style interface for generating UI components
5
+ * using AI and a user's component library.
6
+ */
7
+
8
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
9
+ import { LivePreviewRenderer } from './LivePreviewRenderer';
10
+ import { availableComponents } from './componentRegistry';
11
+ import { aiConsiderations, hasConsiderations } from './considerations';
12
+
13
+ // ============================================================================
14
+ // TYPES
15
+ // ============================================================================
16
+
17
+ interface Message {
18
+ id: string;
19
+ role: 'user' | 'assistant';
20
+ content: string;
21
+ timestamp: number;
22
+ generatedCode?: string;
23
+ images?: ImageAttachment[];
24
+ }
25
+
26
+ interface ImageAttachment {
27
+ id: string;
28
+ data: string;
29
+ type: string;
30
+ name: string;
31
+ }
32
+
33
+ interface Conversation {
34
+ id: string;
35
+ title: string;
36
+ messages: Message[];
37
+ createdAt: number;
38
+ updatedAt: number;
39
+ }
40
+
41
+ interface ProviderOption {
42
+ id: string;
43
+ name: string;
44
+ isDefault?: boolean;
45
+ }
46
+
47
+ interface ModelOption {
48
+ id: string;
49
+ name: string;
50
+ provider?: string;
51
+ }
52
+
53
+ interface ServerConfig {
54
+ providers: ProviderOption[];
55
+ currentProvider: string;
56
+ models: ModelOption[];
57
+ currentModel: string;
58
+ isConfigured: boolean;
59
+ loading: boolean;
60
+ error: string | null;
61
+ }
62
+
63
+ // ============================================================================
64
+ // CONSTANTS & CONFIG
65
+ // ============================================================================
66
+
67
+ const getServerUrl = (): string => {
68
+ if (import.meta.env.VITE_STORY_UI_SERVER) {
69
+ return import.meta.env.VITE_STORY_UI_SERVER;
70
+ }
71
+ return window.location.origin;
72
+ };
73
+
74
+ const SERVER_URL = getServerUrl();
75
+
76
+ const THEME = {
77
+ bg: '#09090b',
78
+ bgSurface: '#18181b',
79
+ bgElevated: '#27272a',
80
+ bgHover: '#3f3f46',
81
+ border: '#27272a',
82
+ borderSubtle: '#3f3f46',
83
+ text: '#fafafa',
84
+ textMuted: '#a1a1aa',
85
+ textSubtle: '#71717a',
86
+ accent: '#3b82f6',
87
+ accentHover: '#2563eb',
88
+ accentMuted: 'rgba(59, 130, 246, 0.15)',
89
+ success: '#22c55e',
90
+ error: '#ef4444',
91
+ warning: '#f59e0b',
92
+ };
93
+
94
+ const generateId = () => Math.random().toString(36).substring(2, 15);
95
+
96
+ // ============================================================================
97
+ // HOOKS
98
+ // ============================================================================
99
+
100
+ const useLocalStorage = <T,>(key: string, initialValue: T): [T, React.Dispatch<React.SetStateAction<T>>] => {
101
+ const [storedValue, setStoredValue] = useState<T>(() => {
102
+ try {
103
+ const item = window.localStorage.getItem(key);
104
+ return item ? JSON.parse(item) : initialValue;
105
+ } catch {
106
+ return initialValue;
107
+ }
108
+ });
109
+
110
+ // Use a ref to always have access to the latest value for functional updates
111
+ const storedValueRef = useRef(storedValue);
112
+ storedValueRef.current = storedValue;
113
+
114
+ const setValue: React.Dispatch<React.SetStateAction<T>> = useCallback((value) => {
115
+ const valueToStore = value instanceof Function ? value(storedValueRef.current) : value;
116
+ storedValueRef.current = valueToStore;
117
+ setStoredValue(valueToStore);
118
+ window.localStorage.setItem(key, JSON.stringify(valueToStore));
119
+ }, [key]);
120
+
121
+ return [storedValue, setValue];
122
+ };
123
+
124
+ const useResizable = (initialWidth: number, minWidth: number, maxWidth: number) => {
125
+ const [width, setWidth] = useState(initialWidth);
126
+ const isDragging = useRef(false);
127
+ const startX = useRef(0);
128
+ const startWidth = useRef(0);
129
+
130
+ const startResize = useCallback((e: React.MouseEvent) => {
131
+ isDragging.current = true;
132
+ startX.current = e.clientX;
133
+ startWidth.current = width;
134
+ document.body.style.cursor = 'col-resize';
135
+ document.body.style.userSelect = 'none';
136
+ }, [width]);
137
+
138
+ useEffect(() => {
139
+ const handleMouseMove = (e: MouseEvent) => {
140
+ if (!isDragging.current) return;
141
+ const delta = e.clientX - startX.current;
142
+ const newWidth = Math.min(Math.max(startWidth.current + delta, minWidth), maxWidth);
143
+ setWidth(newWidth);
144
+ };
145
+
146
+ const handleMouseUp = () => {
147
+ isDragging.current = false;
148
+ document.body.style.cursor = '';
149
+ document.body.style.userSelect = '';
150
+ };
151
+
152
+ document.addEventListener('mousemove', handleMouseMove);
153
+ document.addEventListener('mouseup', handleMouseUp);
154
+ return () => {
155
+ document.removeEventListener('mousemove', handleMouseMove);
156
+ document.removeEventListener('mouseup', handleMouseUp);
157
+ };
158
+ }, [minWidth, maxWidth]);
159
+
160
+ return { width, startResize };
161
+ };
162
+
163
+ // Default models for fallback
164
+ const DEFAULT_MODELS: Record<string, ModelOption[]> = {
165
+ claude: [
166
+ { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', provider: 'claude' },
167
+ { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'claude' },
168
+ { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', provider: 'claude' },
169
+ ],
170
+ openai: [
171
+ { id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' },
172
+ { id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'openai' },
173
+ { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' },
174
+ ],
175
+ gemini: [
176
+ { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', provider: 'gemini' },
177
+ { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', provider: 'gemini' },
178
+ { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', provider: 'gemini' },
179
+ ],
180
+ };
181
+
182
+ const useServerConfig = () => {
183
+ const [config, setConfig] = useState<ServerConfig>({
184
+ providers: [],
185
+ currentProvider: 'claude',
186
+ models: DEFAULT_MODELS.claude,
187
+ currentModel: DEFAULT_MODELS.claude[0].id,
188
+ isConfigured: false,
189
+ loading: true,
190
+ error: null,
191
+ });
192
+
193
+ const fetchConfig = useCallback(async () => {
194
+ setConfig(prev => ({ ...prev, loading: true, error: null }));
195
+ try {
196
+ const response = await fetch(`${SERVER_URL}/story-ui/providers`);
197
+ if (response.ok) {
198
+ const data = await response.json();
199
+ const providers = data.providers || [];
200
+ const defaultProvider = providers.find((p: ProviderOption) => p.isDefault)?.id || providers[0]?.id || 'claude';
201
+ const models = DEFAULT_MODELS[defaultProvider] || DEFAULT_MODELS.claude;
202
+ setConfig({
203
+ providers,
204
+ currentProvider: defaultProvider,
205
+ models,
206
+ currentModel: models[0]?.id || '',
207
+ isConfigured: true,
208
+ loading: false,
209
+ error: null,
210
+ });
211
+ } else {
212
+ throw new Error('Failed to fetch providers');
213
+ }
214
+ } catch (err) {
215
+ // Fallback to default Claude config
216
+ setConfig({
217
+ providers: [{ id: 'claude', name: 'Claude (Anthropic)', isDefault: true }],
218
+ currentProvider: 'claude',
219
+ models: DEFAULT_MODELS.claude,
220
+ currentModel: DEFAULT_MODELS.claude[0].id,
221
+ isConfigured: true,
222
+ loading: false,
223
+ error: null,
224
+ });
225
+ }
226
+ }, []);
227
+
228
+ useEffect(() => {
229
+ fetchConfig();
230
+ }, [fetchConfig]);
231
+
232
+ const changeProvider = useCallback((providerId: string) => {
233
+ const models = DEFAULT_MODELS[providerId] || DEFAULT_MODELS.claude;
234
+ setConfig(prev => ({
235
+ ...prev,
236
+ currentProvider: providerId,
237
+ models,
238
+ currentModel: models[0]?.id || '',
239
+ }));
240
+ }, []);
241
+
242
+ const changeModel = useCallback((modelId: string) => {
243
+ setConfig(prev => ({
244
+ ...prev,
245
+ currentModel: modelId,
246
+ }));
247
+ }, []);
248
+
249
+ return { ...config, refetch: fetchConfig, changeProvider, changeModel };
250
+ };
251
+
252
+ // ============================================================================
253
+ // ICON COMPONENTS
254
+ // ============================================================================
255
+
256
+ const Icons = {
257
+ Plus: () => (
258
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
259
+ <path d="M8 3v10M3 8h10" />
260
+ </svg>
261
+ ),
262
+ Trash: () => (
263
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
264
+ <path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v9a1 1 0 001 1h4a1 1 0 001-1V4" />
265
+ </svg>
266
+ ),
267
+ Send: () => (
268
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
269
+ <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
270
+ </svg>
271
+ ),
272
+ Image: () => (
273
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
274
+ <rect x="3" y="3" width="18" height="18" rx="2" />
275
+ <circle cx="8.5" cy="8.5" r="1.5" />
276
+ <path d="M21 15l-5-5L5 21" />
277
+ </svg>
278
+ ),
279
+ Code: () => (
280
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
281
+ <path d="M16 18l6-6-6-6M8 6l-6 6 6 6" />
282
+ </svg>
283
+ ),
284
+ Eye: () => (
285
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
286
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
287
+ <circle cx="12" cy="12" r="3" />
288
+ </svg>
289
+ ),
290
+ Copy: () => (
291
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
292
+ <rect x="9" y="9" width="13" height="13" rx="2" />
293
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
294
+ </svg>
295
+ ),
296
+ Sidebar: () => (
297
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
298
+ <rect x="3" y="3" width="18" height="18" rx="2" />
299
+ <path d="M9 3v18" />
300
+ </svg>
301
+ ),
302
+ Check: () => (
303
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
304
+ <path d="M20 6L9 17l-5-5" />
305
+ </svg>
306
+ ),
307
+ X: () => (
308
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
309
+ <path d="M18 6L6 18M6 6l12 12" />
310
+ </svg>
311
+ ),
312
+ ChevronDown: () => (
313
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
314
+ <path d="M6 9l6 6 6-6" />
315
+ </svg>
316
+ ),
317
+ };
318
+
319
+ // ============================================================================
320
+ // SUB-COMPONENTS
321
+ // ============================================================================
322
+
323
+ // Dropdown component for provider/model selection
324
+ const Dropdown: React.FC<{
325
+ label: string;
326
+ value: string;
327
+ options: { id: string; name: string }[];
328
+ onChange: (value: string) => void;
329
+ disabled?: boolean;
330
+ }> = ({ label, value, options, onChange, disabled }) => {
331
+ const [isOpen, setIsOpen] = useState(false);
332
+ const ref = useRef<HTMLDivElement>(null);
333
+ const selectedOption = options.find(o => o.id === value);
334
+
335
+ useEffect(() => {
336
+ const handleClickOutside = (e: MouseEvent) => {
337
+ if (ref.current && !ref.current.contains(e.target as Node)) {
338
+ setIsOpen(false);
339
+ }
340
+ };
341
+ document.addEventListener('mousedown', handleClickOutside);
342
+ return () => document.removeEventListener('mousedown', handleClickOutside);
343
+ }, []);
344
+
345
+ return (
346
+ <div ref={ref} style={{ position: 'relative', width: '100%' }}>
347
+ <button
348
+ onClick={() => !disabled && setIsOpen(!isOpen)}
349
+ disabled={disabled}
350
+ style={{
351
+ display: 'flex',
352
+ alignItems: 'center',
353
+ justifyContent: 'space-between',
354
+ width: '100%',
355
+ padding: '8px 10px',
356
+ background: THEME.bgElevated,
357
+ border: `1px solid ${THEME.border}`,
358
+ borderRadius: '6px',
359
+ color: disabled ? THEME.textSubtle : THEME.text,
360
+ fontSize: '12px',
361
+ cursor: disabled ? 'not-allowed' : 'pointer',
362
+ transition: 'all 0.2s',
363
+ opacity: disabled ? 0.6 : 1,
364
+ }}
365
+ >
366
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px' }}>
367
+ <span style={{ color: THEME.textSubtle, fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
368
+ {label}
369
+ </span>
370
+ <span style={{ fontWeight: 500 }}>{selectedOption?.name || 'Select'}</span>
371
+ </div>
372
+ <Icons.ChevronDown />
373
+ </button>
374
+ {isOpen && !disabled && (
375
+ <div
376
+ style={{
377
+ position: 'absolute',
378
+ bottom: '100%',
379
+ left: 0,
380
+ right: 0,
381
+ marginBottom: '4px',
382
+ maxHeight: '200px',
383
+ overflowY: 'auto',
384
+ background: THEME.bgElevated,
385
+ border: `1px solid ${THEME.border}`,
386
+ borderRadius: '6px',
387
+ boxShadow: '0 -4px 12px rgba(0,0,0,0.3)',
388
+ zIndex: 100,
389
+ }}
390
+ >
391
+ {options.map(option => (
392
+ <button
393
+ key={option.id}
394
+ onClick={() => {
395
+ onChange(option.id);
396
+ setIsOpen(false);
397
+ }}
398
+ style={{
399
+ display: 'block',
400
+ width: '100%',
401
+ padding: '8px 10px',
402
+ background: option.id === value ? THEME.accentMuted : 'transparent',
403
+ border: 'none',
404
+ textAlign: 'left',
405
+ color: THEME.text,
406
+ fontSize: '12px',
407
+ cursor: 'pointer',
408
+ transition: 'background 0.15s',
409
+ }}
410
+ onMouseEnter={e => {
411
+ if (option.id !== value) e.currentTarget.style.background = THEME.bgHover;
412
+ }}
413
+ onMouseLeave={e => {
414
+ e.currentTarget.style.background = option.id === value ? THEME.accentMuted : 'transparent';
415
+ }}
416
+ >
417
+ {option.name}
418
+ </button>
419
+ ))}
420
+ </div>
421
+ )}
422
+ </div>
423
+ );
424
+ };
425
+
426
+ const LoadingDots: React.FC = () => (
427
+ <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
428
+ {[0, 1, 2].map(i => (
429
+ <div
430
+ key={i}
431
+ style={{
432
+ width: '6px',
433
+ height: '6px',
434
+ borderRadius: '50%',
435
+ background: THEME.accent,
436
+ animation: `pulse 1.4s ease-in-out ${i * 0.2}s infinite`,
437
+ }}
438
+ />
439
+ ))}
440
+ </div>
441
+ );
442
+
443
+ const ImageUploadArea: React.FC<{
444
+ images: ImageAttachment[];
445
+ onImagesChange: (images: ImageAttachment[]) => void;
446
+ disabled?: boolean;
447
+ }> = ({ images, onImagesChange, disabled }) => {
448
+ const fileInputRef = useRef<HTMLInputElement>(null);
449
+
450
+ const handleFiles = useCallback((files: FileList) => {
451
+ Array.from(files).forEach(file => {
452
+ if (file.type.startsWith('image/')) {
453
+ const reader = new FileReader();
454
+ reader.onload = (e) => {
455
+ const newImage: ImageAttachment = {
456
+ id: generateId(),
457
+ data: e.target?.result as string,
458
+ type: file.type,
459
+ name: file.name,
460
+ };
461
+ onImagesChange([...images, newImage]);
462
+ };
463
+ reader.readAsDataURL(file);
464
+ }
465
+ });
466
+ }, [images, onImagesChange]);
467
+
468
+ const removeImage = (id: string) => {
469
+ onImagesChange(images.filter(img => img.id !== id));
470
+ };
471
+
472
+ return (
473
+ <div style={{ marginBottom: images.length > 0 ? '12px' : 0 }}>
474
+ <input
475
+ ref={fileInputRef}
476
+ type="file"
477
+ accept="image/*"
478
+ multiple
479
+ style={{ display: 'none' }}
480
+ onChange={(e) => e.target.files && handleFiles(e.target.files)}
481
+ disabled={disabled}
482
+ />
483
+
484
+ {images.length > 0 && (
485
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '8px' }}>
486
+ {images.map(img => (
487
+ <div
488
+ key={img.id}
489
+ style={{
490
+ position: 'relative',
491
+ width: '64px',
492
+ height: '64px',
493
+ borderRadius: '8px',
494
+ overflow: 'hidden',
495
+ border: `1px solid ${THEME.border}`,
496
+ }}
497
+ >
498
+ <img
499
+ src={img.data}
500
+ alt={img.name}
501
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
502
+ />
503
+ <button
504
+ onClick={() => removeImage(img.id)}
505
+ style={{
506
+ position: 'absolute',
507
+ top: '2px',
508
+ right: '2px',
509
+ width: '18px',
510
+ height: '18px',
511
+ borderRadius: '50%',
512
+ background: 'rgba(0,0,0,0.7)',
513
+ border: 'none',
514
+ color: '#fff',
515
+ cursor: 'pointer',
516
+ display: 'flex',
517
+ alignItems: 'center',
518
+ justifyContent: 'center',
519
+ fontSize: '10px',
520
+ }}
521
+ >
522
+ <Icons.X />
523
+ </button>
524
+ </div>
525
+ ))}
526
+ </div>
527
+ )}
528
+
529
+ <button
530
+ onClick={() => fileInputRef.current?.click()}
531
+ disabled={disabled}
532
+ style={{
533
+ display: 'flex',
534
+ alignItems: 'center',
535
+ gap: '6px',
536
+ padding: '6px 10px',
537
+ background: 'transparent',
538
+ border: `1px dashed ${THEME.border}`,
539
+ borderRadius: '6px',
540
+ color: THEME.textMuted,
541
+ fontSize: '12px',
542
+ cursor: disabled ? 'not-allowed' : 'pointer',
543
+ transition: 'all 0.2s',
544
+ opacity: disabled ? 0.5 : 1,
545
+ }}
546
+ >
547
+ <Icons.Image />
548
+ <span>Add image</span>
549
+ </button>
550
+ </div>
551
+ );
552
+ };
553
+
554
+ const CodeViewer: React.FC<{ code: string }> = ({ code }) => {
555
+ const [copied, setCopied] = useState(false);
556
+
557
+ const handleCopy = async () => {
558
+ await navigator.clipboard.writeText(code);
559
+ setCopied(true);
560
+ setTimeout(() => setCopied(false), 2000);
561
+ };
562
+
563
+ return (
564
+ <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
565
+ <div style={{
566
+ padding: '8px 12px',
567
+ borderBottom: `1px solid ${THEME.border}`,
568
+ display: 'flex',
569
+ justifyContent: 'space-between',
570
+ alignItems: 'center',
571
+ }}>
572
+ <span style={{ fontSize: '12px', color: THEME.textMuted }}>Generated Code</span>
573
+ <button
574
+ onClick={handleCopy}
575
+ style={{
576
+ display: 'flex',
577
+ alignItems: 'center',
578
+ gap: '4px',
579
+ padding: '4px 8px',
580
+ background: copied ? 'rgba(34, 197, 94, 0.2)' : 'transparent',
581
+ border: `1px solid ${copied ? THEME.success : THEME.border}`,
582
+ borderRadius: '4px',
583
+ color: copied ? THEME.success : THEME.textMuted,
584
+ fontSize: '11px',
585
+ cursor: 'pointer',
586
+ transition: 'all 0.2s',
587
+ }}
588
+ >
589
+ {copied ? <Icons.Check /> : <Icons.Copy />}
590
+ {copied ? 'Copied!' : 'Copy'}
591
+ </button>
592
+ </div>
593
+ <pre
594
+ style={{
595
+ flex: 1,
596
+ margin: 0,
597
+ padding: '16px',
598
+ overflow: 'auto',
599
+ fontSize: '13px',
600
+ lineHeight: 1.6,
601
+ fontFamily: '"Fira Code", "SF Mono", Monaco, monospace',
602
+ background: '#0d1117',
603
+ color: '#e6edf3',
604
+ }}
605
+ >
606
+ {code}
607
+ </pre>
608
+ </div>
609
+ );
610
+ };
611
+
612
+ // ============================================================================
613
+ // MAIN APP
614
+ // ============================================================================
615
+
616
+ const App: React.FC = () => {
617
+ // Server configuration (providers, models)
618
+ const serverConfig = useServerConfig();
619
+
620
+ const [conversations, setConversations] = useLocalStorage<Conversation[]>('storyui_conversations', []);
621
+ const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
622
+ const [inputValue, setInputValue] = useState('');
623
+ const [isGenerating, setIsGenerating] = useState(false);
624
+ const [previewCode, setPreviewCode] = useState<string | null>(null);
625
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
626
+ const [previewTab, setPreviewTab] = useState<'preview' | 'code'>('preview');
627
+ const [images, setImages] = useState<ImageAttachment[]>([]);
628
+
629
+ const messagesEndRef = useRef<HTMLDivElement>(null);
630
+ const inputRef = useRef<HTMLTextAreaElement>(null);
631
+ const { width: chatWidth, startResize } = useResizable(400, 320, 600);
632
+
633
+ const activeConversation = conversations.find(c => c.id === activeConversationId);
634
+
635
+ // Filter out empty conversations for display (deferred creation)
636
+ const displayConversations = conversations.filter(c => c.messages.length > 0);
637
+
638
+ // Auto-scroll messages
639
+ useEffect(() => {
640
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
641
+ }, [activeConversation?.messages]);
642
+
643
+ // Initialize - select existing conversation or stay in "new chat" mode
644
+ useEffect(() => {
645
+ // Only select an existing conversation if we don't have one selected
646
+ // and there are conversations with messages
647
+ if (!activeConversationId && displayConversations.length > 0) {
648
+ setActiveConversationId(displayConversations[0].id);
649
+ const lastMsgWithCode = [...displayConversations[0].messages].reverse().find(m => m.generatedCode);
650
+ setPreviewCode(lastMsgWithCode?.generatedCode || null);
651
+ }
652
+ // Don't create an empty conversation - we'll create one when user sends first message
653
+ }, [displayConversations.length, activeConversationId]);
654
+
655
+ // Start a new chat - just clear state, don't create empty conversation
656
+ const startNewChat = useCallback(() => {
657
+ setActiveConversationId(null);
658
+ setPreviewCode(null);
659
+ setImages([]);
660
+ inputRef.current?.focus();
661
+ }, []);
662
+
663
+ // Actually create a conversation when first message is sent
664
+ const createConversationWithMessage = useCallback((message: Message): string => {
665
+ const newConversation: Conversation = {
666
+ id: generateId(),
667
+ title: message.content.substring(0, 40),
668
+ messages: [message],
669
+ createdAt: Date.now(),
670
+ updatedAt: Date.now(),
671
+ };
672
+ setConversations(prev => [newConversation, ...prev]);
673
+ return newConversation.id;
674
+ }, [setConversations]);
675
+
676
+ const deleteConversation = (id: string) => {
677
+ setConversations(prev => prev.filter(c => c.id !== id));
678
+ if (activeConversationId === id) {
679
+ const remaining = displayConversations.filter(c => c.id !== id);
680
+ if (remaining.length > 0) {
681
+ setActiveConversationId(remaining[0].id);
682
+ } else {
683
+ // Go to "new chat" mode instead of creating empty conversation
684
+ setActiveConversationId(null);
685
+ setPreviewCode(null);
686
+ }
687
+ }
688
+ };
689
+
690
+ const generateComponent = async (prompt: string, imageAttachments: ImageAttachment[]): Promise<string> => {
691
+ // Get the last generated code from conversation for iteration context
692
+ const lastGeneratedCode = activeConversation?.messages
693
+ .filter(m => m.generatedCode)
694
+ .slice(-1)[0]?.generatedCode;
695
+
696
+ // Check if this is an iteration (modification of existing code)
697
+ const isIteration = !!lastGeneratedCode && (activeConversation?.messages.length || 0) > 0;
698
+
699
+ // Build conversation history, but for iterations include the code reference differently
700
+ const conversationHistory = isIteration
701
+ ? activeConversation?.messages.slice(0, -1).map(msg => ({
702
+ role: msg.role,
703
+ content: msg.role === 'assistant' && msg.generatedCode
704
+ ? `[Generated JSX component - see CURRENT_CODE below]`
705
+ : msg.content
706
+ })) || []
707
+ : [];
708
+
709
+ // Build a UNIVERSAL system prompt that works with ANY component library
710
+ // Design-system-specific rules come from aiConsiderations (generated from story-ui-considerations.md)
711
+ const basePrompt = `You are a JSX code generator. Your ONLY job is to output raw JSX code.
712
+
713
+ CRITICAL OUTPUT RULES:
714
+ 1. Start DIRECTLY with < (opening tag of a component)
715
+ 2. End with > (closing tag)
716
+ 3. NO markdown, NO headers, NO explanations, NO code fences
717
+ 4. NO internal tags like <budget>, <usage>, <thinking>, or any metadata
718
+ 5. NEVER output anything except JSX components
719
+
720
+ UNIVERSAL BEST PRACTICES (applies to ALL design systems):
721
+
722
+ THEME & COLORS:
723
+ - Components render on a LIGHT BACKGROUND by default
724
+ - Use DARK text colors for body text (ensure readability)
725
+ - Never use white/light text colors unless on a dark or colored background
726
+ - Ensure sufficient color contrast (4.5:1 for normal text, 3:1 for large text)
727
+
728
+ ACCESSIBILITY (WCAG):
729
+ - Use semantic HTML structure (headings, lists, landmarks)
730
+ - Include aria-labels on interactive elements without visible text
731
+ - Ensure focusable elements have visible focus states
732
+ - Use role attributes appropriately
733
+ - 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
+
741
+ AVAILABLE COMPONENTS (use ONLY these):
742
+ ${availableComponents.join(', ')}${hasConsiderations ? `
743
+
744
+ DESIGN SYSTEM GUIDELINES:
745
+ ${aiConsiderations}` : ''}`;
746
+
747
+ let systemPrompt: string;
748
+
749
+ if (isIteration && lastGeneratedCode) {
750
+ // Iteration-specific prompt with clear modification instructions
751
+ systemPrompt = `${basePrompt}
752
+
753
+ ITERATION MODE - You are MODIFYING existing code:
754
+
755
+ CURRENT_CODE (this is what you're modifying):
756
+ ${lastGeneratedCode}
757
+
758
+ MODIFICATION RULES:
759
+ 1. Keep the overall structure unless asked to change it
760
+ 2. Only modify what the user specifically requests
761
+ 3. Preserve existing styling, layout, and components not mentioned
762
+ 4. Output the COMPLETE modified JSX (not just the changed parts)
763
+
764
+ OUTPUT: Start immediately with < and output only the complete modified JSX.`;
765
+ } else {
766
+ // New generation prompt
767
+ systemPrompt = `${basePrompt}
768
+
769
+ GENERATION RULES:
770
+ 1. Output a SINGLE JSX expression starting with < and ending with >
771
+ 2. Use ONLY components from the list above
772
+ 3. NO imports, NO exports, NO function definitions
773
+ 4. NO explanations, NO comments outside JSX
774
+
775
+ OUTPUT: Start immediately with < and output only JSX.`;
776
+ }
777
+
778
+ // Use assistant prefill to force Claude to start with JSX
779
+ // This is a powerful technique that constrains the output format
780
+ const prefillAssistant = '<';
781
+
782
+ const response = await fetch(`${SERVER_URL}/story-ui/claude`, {
783
+ method: 'POST',
784
+ headers: { 'Content-Type': 'application/json' },
785
+ body: JSON.stringify({
786
+ prompt,
787
+ messages: conversationHistory,
788
+ systemPrompt,
789
+ prefillAssistant,
790
+ model: serverConfig.currentModel,
791
+ maxTokens: 4096,
792
+ images: imageAttachments.map(img => ({
793
+ type: img.type,
794
+ data: img.data.split(',')[1],
795
+ })),
796
+ }),
797
+ });
798
+
799
+ if (!response.ok) {
800
+ const errorData = await response.json().catch(() => ({}));
801
+ throw new Error(errorData.error || `Server Error: ${response.status}`);
802
+ }
803
+
804
+ const data = await response.json();
805
+ // Handle various response formats from different LLM providers
806
+ let content: string;
807
+ if (Array.isArray(data.content)) {
808
+ // Claude API format: { content: [{ type: 'text', text: '...' }] }
809
+ content = data.content[0]?.text || '';
810
+ } else if (typeof data.content === 'string') {
811
+ content = data.content;
812
+ } else if (typeof data.text === 'string') {
813
+ // Alternative format
814
+ content = data.text;
815
+ } else {
816
+ content = String(data.content || data.text || '');
817
+ }
818
+
819
+ // Clean up the response
820
+ let cleanCode = content.trim();
821
+ if (cleanCode.startsWith('```')) {
822
+ cleanCode = cleanCode
823
+ .replace(/^```(?:jsx|tsx|javascript|js)?\n?/, '')
824
+ .replace(/\n?```$/, '');
825
+ }
826
+
827
+ return cleanCode;
828
+ };
829
+
830
+ const sendMessage = async () => {
831
+ if (!inputValue.trim() || isGenerating) return;
832
+
833
+ const userMessage: Message = {
834
+ id: generateId(),
835
+ role: 'user',
836
+ content: inputValue.trim(),
837
+ timestamp: Date.now(),
838
+ images: images.length > 0 ? [...images] : undefined,
839
+ };
840
+
841
+ // Determine if we need to create a new conversation or add to existing
842
+ let conversationId = activeConversationId;
843
+
844
+ if (!conversationId) {
845
+ // Create new conversation with the first message
846
+ conversationId = createConversationWithMessage(userMessage);
847
+ setActiveConversationId(conversationId);
848
+ } else {
849
+ // Add message to existing conversation
850
+ setConversations(prev => prev.map(conv => {
851
+ if (conv.id === conversationId) {
852
+ return {
853
+ ...conv,
854
+ messages: [...conv.messages, userMessage],
855
+ updatedAt: Date.now(),
856
+ };
857
+ }
858
+ return conv;
859
+ }));
860
+ }
861
+
862
+ const currentImages = [...images];
863
+ setInputValue('');
864
+ setImages([]);
865
+ setIsGenerating(true);
866
+
867
+ try {
868
+ const generatedCode = await generateComponent(inputValue.trim(), currentImages);
869
+
870
+ const assistantMessage: Message = {
871
+ id: generateId(),
872
+ role: 'assistant',
873
+ content: 'Component generated successfully.',
874
+ timestamp: Date.now(),
875
+ generatedCode,
876
+ };
877
+
878
+ setConversations(prev => prev.map(conv => {
879
+ if (conv.id === conversationId) {
880
+ return {
881
+ ...conv,
882
+ messages: [...conv.messages, assistantMessage],
883
+ updatedAt: Date.now(),
884
+ };
885
+ }
886
+ return conv;
887
+ }));
888
+
889
+ if (generatedCode) {
890
+ setPreviewCode(generatedCode);
891
+ setPreviewTab('preview');
892
+ }
893
+ } catch (error) {
894
+ const errorMessage: Message = {
895
+ id: generateId(),
896
+ role: 'assistant',
897
+ content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
898
+ timestamp: Date.now(),
899
+ };
900
+
901
+ setConversations(prev => prev.map(conv => {
902
+ if (conv.id === conversationId) {
903
+ return {
904
+ ...conv,
905
+ messages: [...conv.messages, errorMessage],
906
+ updatedAt: Date.now(),
907
+ };
908
+ }
909
+ return conv;
910
+ }));
911
+ } finally {
912
+ setIsGenerating(false);
913
+ }
914
+ };
915
+
916
+ const handleKeyPress = (e: React.KeyboardEvent) => {
917
+ if (e.key === 'Enter' && !e.shiftKey) {
918
+ e.preventDefault();
919
+ sendMessage();
920
+ }
921
+ };
922
+
923
+ return (
924
+ <div style={{
925
+ display: 'flex',
926
+ height: '100vh',
927
+ overflow: 'hidden',
928
+ background: THEME.bg,
929
+ color: THEME.text,
930
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
931
+ }}>
932
+ {/* Global Styles */}
933
+ <style>{`
934
+ @keyframes pulse {
935
+ 0%, 100% { opacity: 0.4; transform: scale(0.8); }
936
+ 50% { opacity: 1; transform: scale(1); }
937
+ }
938
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
939
+ ::-webkit-scrollbar-track { background: transparent; }
940
+ ::-webkit-scrollbar-thumb { background: ${THEME.bgElevated}; border-radius: 3px; }
941
+ ::-webkit-scrollbar-thumb:hover { background: ${THEME.bgHover}; }
942
+ `}</style>
943
+
944
+ {/* Sidebar */}
945
+ <aside style={{
946
+ width: sidebarCollapsed ? '60px' : '240px',
947
+ background: THEME.bgSurface,
948
+ borderRight: `1px solid ${THEME.border}`,
949
+ display: 'flex',
950
+ flexDirection: 'column',
951
+ transition: 'width 0.2s ease',
952
+ overflow: 'hidden',
953
+ }}>
954
+ {/* Sidebar Header */}
955
+ <div style={{
956
+ padding: '16px',
957
+ borderBottom: `1px solid ${THEME.border}`,
958
+ display: 'flex',
959
+ alignItems: 'center',
960
+ justifyContent: sidebarCollapsed ? 'center' : 'space-between',
961
+ }}>
962
+ {!sidebarCollapsed && (
963
+ <h1 style={{
964
+ fontSize: '16px',
965
+ fontWeight: 600,
966
+ background: `linear-gradient(135deg, ${THEME.accent}, #8b5cf6)`,
967
+ WebkitBackgroundClip: 'text',
968
+ WebkitTextFillColor: 'transparent',
969
+ letterSpacing: '-0.5px',
970
+ }}>
971
+ Story UI
972
+ </h1>
973
+ )}
974
+ <button
975
+ onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
976
+ style={{
977
+ padding: '8px',
978
+ background: 'transparent',
979
+ border: 'none',
980
+ color: THEME.textMuted,
981
+ cursor: 'pointer',
982
+ borderRadius: '6px',
983
+ }}
984
+ >
985
+ <Icons.Sidebar />
986
+ </button>
987
+ </div>
988
+
989
+ {/* New Chat Button */}
990
+ <div style={{ padding: sidebarCollapsed ? '8px' : '12px' }}>
991
+ <button
992
+ onClick={startNewChat}
993
+ style={{
994
+ width: '100%',
995
+ padding: sidebarCollapsed ? '10px' : '10px 14px',
996
+ background: THEME.accent,
997
+ border: 'none',
998
+ borderRadius: '8px',
999
+ color: '#fff',
1000
+ fontSize: '13px',
1001
+ fontWeight: 500,
1002
+ cursor: 'pointer',
1003
+ display: 'flex',
1004
+ alignItems: 'center',
1005
+ justifyContent: 'center',
1006
+ gap: '8px',
1007
+ }}
1008
+ >
1009
+ <Icons.Plus />
1010
+ {!sidebarCollapsed && 'New Chat'}
1011
+ </button>
1012
+ </div>
1013
+
1014
+ {/* Conversation List */}
1015
+ {!sidebarCollapsed && (
1016
+ <div style={{ flex: 1, overflow: 'auto', padding: '8px 12px' }}>
1017
+ {displayConversations.map(conv => (
1018
+ <div
1019
+ key={conv.id}
1020
+ onClick={() => {
1021
+ setActiveConversationId(conv.id);
1022
+ const lastMsgWithCode = [...conv.messages].reverse().find(m => m.generatedCode);
1023
+ setPreviewCode(lastMsgWithCode?.generatedCode || null);
1024
+ }}
1025
+ style={{
1026
+ padding: '10px 12px',
1027
+ marginBottom: '4px',
1028
+ borderRadius: '8px',
1029
+ cursor: 'pointer',
1030
+ display: 'flex',
1031
+ alignItems: 'center',
1032
+ justifyContent: 'space-between',
1033
+ background: conv.id === activeConversationId ? THEME.bgElevated : 'transparent',
1034
+ }}
1035
+ >
1036
+ <span style={{
1037
+ fontSize: '13px',
1038
+ color: conv.id === activeConversationId ? THEME.text : THEME.textMuted,
1039
+ overflow: 'hidden',
1040
+ textOverflow: 'ellipsis',
1041
+ whiteSpace: 'nowrap',
1042
+ flex: 1,
1043
+ }}>
1044
+ {conv.title}
1045
+ </span>
1046
+ <button
1047
+ onClick={(e) => {
1048
+ e.stopPropagation();
1049
+ deleteConversation(conv.id);
1050
+ }}
1051
+ style={{
1052
+ padding: '4px',
1053
+ background: 'transparent',
1054
+ border: 'none',
1055
+ color: THEME.textSubtle,
1056
+ cursor: 'pointer',
1057
+ opacity: 0.5,
1058
+ }}
1059
+ >
1060
+ <Icons.Trash />
1061
+ </button>
1062
+ </div>
1063
+ ))}
1064
+ </div>
1065
+ )}
1066
+
1067
+ {/* Sidebar Footer - Provider/Model Info */}
1068
+ {!sidebarCollapsed && (
1069
+ <div style={{
1070
+ padding: '12px',
1071
+ borderTop: `1px solid ${THEME.border}`,
1072
+ display: 'flex',
1073
+ flexDirection: 'column',
1074
+ gap: '6px',
1075
+ }}>
1076
+ {/* Provider Label */}
1077
+ <div style={{ fontSize: '12px', color: THEME.textMuted }}>
1078
+ <span style={{ color: THEME.textSubtle, textTransform: 'uppercase', fontSize: '10px', letterSpacing: '0.5px' }}>Provider: </span>
1079
+ <span style={{ color: THEME.text, fontWeight: 500 }}>
1080
+ {serverConfig.providers.find(p => p.id === serverConfig.currentProvider)?.name || 'Claude (Anthropic)'}
1081
+ </span>
1082
+ </div>
1083
+
1084
+ {/* Model Dropdown */}
1085
+ <div style={{ fontSize: '12px', color: THEME.textMuted }}>
1086
+ <span style={{ color: THEME.textSubtle, textTransform: 'uppercase', fontSize: '10px', letterSpacing: '0.5px' }}>Model</span>
1087
+ </div>
1088
+ <select
1089
+ value={serverConfig.currentModel}
1090
+ onChange={(e) => serverConfig.changeModel(e.target.value)}
1091
+ disabled={serverConfig.loading}
1092
+ style={{
1093
+ width: '100%',
1094
+ padding: '8px 10px',
1095
+ background: THEME.bgElevated,
1096
+ border: `1px solid ${THEME.border}`,
1097
+ borderRadius: '6px',
1098
+ color: THEME.text,
1099
+ fontSize: '12px',
1100
+ cursor: 'pointer',
1101
+ outline: 'none',
1102
+ appearance: 'none',
1103
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")`,
1104
+ backgroundRepeat: 'no-repeat',
1105
+ backgroundPosition: 'right 8px center',
1106
+ paddingRight: '28px',
1107
+ }}
1108
+ >
1109
+ {serverConfig.models.map(model => (
1110
+ <option key={model.id} value={model.id}>{model.id}</option>
1111
+ ))}
1112
+ </select>
1113
+
1114
+ {/* Components Count */}
1115
+ <div style={{
1116
+ display: 'flex',
1117
+ alignItems: 'center',
1118
+ gap: '6px',
1119
+ fontSize: '11px',
1120
+ color: THEME.textSubtle,
1121
+ paddingTop: '4px',
1122
+ }}>
1123
+ <div style={{
1124
+ width: '6px',
1125
+ height: '6px',
1126
+ borderRadius: '50%',
1127
+ background: THEME.success,
1128
+ }} />
1129
+ <span>{availableComponents.length} components available</span>
1130
+ </div>
1131
+ </div>
1132
+ )}
1133
+ </aside>
1134
+
1135
+ {/* Chat Panel */}
1136
+ <div style={{
1137
+ width: `${chatWidth}px`,
1138
+ display: 'flex',
1139
+ flexDirection: 'column',
1140
+ background: THEME.bg,
1141
+ borderRight: `1px solid ${THEME.border}`,
1142
+ position: 'relative',
1143
+ }}>
1144
+ {/* Messages */}
1145
+ <div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
1146
+ {(!activeConversation || activeConversation.messages.length === 0) && (
1147
+ <div style={{
1148
+ padding: '32px 16px',
1149
+ textAlign: 'center',
1150
+ }}>
1151
+ <h2 style={{
1152
+ fontSize: '24px',
1153
+ fontWeight: 600,
1154
+ marginBottom: '8px',
1155
+ background: `linear-gradient(135deg, ${THEME.text}, ${THEME.textMuted})`,
1156
+ WebkitBackgroundClip: 'text',
1157
+ WebkitTextFillColor: 'transparent',
1158
+ }}>
1159
+ Build UI Components
1160
+ </h2>
1161
+ <p style={{
1162
+ color: THEME.textMuted,
1163
+ fontSize: '14px',
1164
+ marginBottom: '24px',
1165
+ lineHeight: 1.6,
1166
+ }}>
1167
+ Describe what you want to build using your component library.
1168
+ </p>
1169
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'center' }}>
1170
+ {['Create a pricing card', 'Build a dashboard', 'Design a login form'].map(suggestion => (
1171
+ <button
1172
+ key={suggestion}
1173
+ onClick={() => setInputValue(suggestion)}
1174
+ style={{
1175
+ padding: '8px 14px',
1176
+ background: THEME.bgSurface,
1177
+ border: `1px solid ${THEME.border}`,
1178
+ borderRadius: '20px',
1179
+ color: THEME.textMuted,
1180
+ fontSize: '13px',
1181
+ cursor: 'pointer',
1182
+ }}
1183
+ >
1184
+ {suggestion}
1185
+ </button>
1186
+ ))}
1187
+ </div>
1188
+ </div>
1189
+ )}
1190
+
1191
+ {activeConversation?.messages.map(message => (
1192
+ <div
1193
+ key={message.id}
1194
+ style={{
1195
+ marginBottom: '16px',
1196
+ padding: '14px 16px',
1197
+ borderRadius: '12px',
1198
+ maxWidth: '90%',
1199
+ marginLeft: message.role === 'user' ? 'auto' : '0',
1200
+ marginRight: message.role === 'user' ? '0' : 'auto',
1201
+ background: message.role === 'user' ? THEME.bgElevated : THEME.bgSurface,
1202
+ border: `1px solid ${THEME.border}`,
1203
+ }}
1204
+ >
1205
+ {message.images && message.images.length > 0 && (
1206
+ <div style={{ display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap' }}>
1207
+ {message.images.map(img => (
1208
+ <img
1209
+ key={img.id}
1210
+ src={img.data}
1211
+ alt={img.name}
1212
+ style={{
1213
+ width: '80px',
1214
+ height: '80px',
1215
+ objectFit: 'cover',
1216
+ borderRadius: '8px',
1217
+ border: `1px solid ${THEME.border}`,
1218
+ }}
1219
+ />
1220
+ ))}
1221
+ </div>
1222
+ )}
1223
+ <div style={{ fontSize: '14px', lineHeight: 1.6, color: THEME.text }}>
1224
+ {message.content}
1225
+ </div>
1226
+ {message.generatedCode && (
1227
+ <button
1228
+ onClick={() => {
1229
+ setPreviewCode(message.generatedCode!);
1230
+ setPreviewTab('preview');
1231
+ }}
1232
+ style={{
1233
+ marginTop: '10px',
1234
+ padding: '6px 12px',
1235
+ background: THEME.accentMuted,
1236
+ border: `1px solid ${THEME.accent}`,
1237
+ borderRadius: '6px',
1238
+ color: THEME.accent,
1239
+ fontSize: '12px',
1240
+ cursor: 'pointer',
1241
+ display: 'flex',
1242
+ alignItems: 'center',
1243
+ gap: '6px',
1244
+ }}
1245
+ >
1246
+ <Icons.Eye />
1247
+ View Component
1248
+ </button>
1249
+ )}
1250
+ </div>
1251
+ ))}
1252
+
1253
+ {isGenerating && (
1254
+ <div style={{
1255
+ marginBottom: '16px',
1256
+ padding: '14px 16px',
1257
+ borderRadius: '12px',
1258
+ maxWidth: '90%',
1259
+ background: THEME.bgSurface,
1260
+ border: `1px solid ${THEME.border}`,
1261
+ }}>
1262
+ <LoadingDots />
1263
+ </div>
1264
+ )}
1265
+
1266
+ <div ref={messagesEndRef} />
1267
+ </div>
1268
+
1269
+ {/* Input Area - ChatGPT/Lovable Style */}
1270
+ <div style={{
1271
+ padding: '16px',
1272
+ borderTop: `1px solid ${THEME.border}`,
1273
+ background: THEME.bgSurface,
1274
+ }}>
1275
+ {/* Image thumbnails if any */}
1276
+ {images.length > 0 && (
1277
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' }}>
1278
+ {images.map(img => (
1279
+ <div
1280
+ key={img.id}
1281
+ style={{
1282
+ position: 'relative',
1283
+ width: '64px',
1284
+ height: '64px',
1285
+ borderRadius: '8px',
1286
+ overflow: 'hidden',
1287
+ border: `1px solid ${THEME.border}`,
1288
+ }}
1289
+ >
1290
+ <img
1291
+ src={img.data}
1292
+ alt={img.name}
1293
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
1294
+ />
1295
+ <button
1296
+ onClick={() => setImages(images.filter(i => i.id !== img.id))}
1297
+ style={{
1298
+ position: 'absolute',
1299
+ top: '2px',
1300
+ right: '2px',
1301
+ width: '18px',
1302
+ height: '18px',
1303
+ borderRadius: '50%',
1304
+ background: 'rgba(0,0,0,0.7)',
1305
+ border: 'none',
1306
+ color: '#fff',
1307
+ cursor: 'pointer',
1308
+ display: 'flex',
1309
+ alignItems: 'center',
1310
+ justifyContent: 'center',
1311
+ fontSize: '10px',
1312
+ }}
1313
+ >
1314
+ <Icons.X />
1315
+ </button>
1316
+ </div>
1317
+ ))}
1318
+ </div>
1319
+ )}
1320
+
1321
+ {/* Combined input container matching reference */}
1322
+ <div style={{
1323
+ display: 'flex',
1324
+ flexDirection: 'column',
1325
+ background: THEME.bgElevated,
1326
+ border: `1px solid ${THEME.border}`,
1327
+ borderRadius: '12px',
1328
+ overflow: 'hidden',
1329
+ }}>
1330
+ {/* Text input area */}
1331
+ <textarea
1332
+ ref={inputRef}
1333
+ value={inputValue}
1334
+ onChange={e => setInputValue(e.target.value)}
1335
+ onKeyDown={handleKeyPress}
1336
+ placeholder="Describe the component you want to create..."
1337
+ disabled={isGenerating}
1338
+ style={{
1339
+ width: '100%',
1340
+ padding: '14px 16px',
1341
+ background: 'transparent',
1342
+ border: 'none',
1343
+ color: THEME.text,
1344
+ fontSize: '14px',
1345
+ resize: 'none',
1346
+ outline: 'none',
1347
+ fontFamily: 'inherit',
1348
+ minHeight: '24px',
1349
+ maxHeight: '120px',
1350
+ }}
1351
+ rows={1}
1352
+ />
1353
+
1354
+ {/* Bottom row with attach button and send */}
1355
+ <div style={{
1356
+ display: 'flex',
1357
+ alignItems: 'center',
1358
+ justifyContent: 'space-between',
1359
+ padding: '8px 12px',
1360
+ borderTop: `1px solid ${THEME.border}`,
1361
+ }}>
1362
+ {/* + Attach button */}
1363
+ <label
1364
+ style={{
1365
+ display: 'flex',
1366
+ alignItems: 'center',
1367
+ gap: '6px',
1368
+ padding: '6px 12px',
1369
+ background: THEME.bgHover,
1370
+ border: `1px solid ${THEME.borderSubtle}`,
1371
+ borderRadius: '6px',
1372
+ color: THEME.textMuted,
1373
+ fontSize: '13px',
1374
+ cursor: isGenerating ? 'not-allowed' : 'pointer',
1375
+ opacity: isGenerating ? 0.5 : 1,
1376
+ transition: 'all 0.2s',
1377
+ }}
1378
+ >
1379
+ <Icons.Plus />
1380
+ <span>Attach</span>
1381
+ <input
1382
+ type="file"
1383
+ accept="image/*"
1384
+ multiple
1385
+ style={{ display: 'none' }}
1386
+ disabled={isGenerating}
1387
+ onChange={(e) => {
1388
+ if (e.target.files) {
1389
+ Array.from(e.target.files).forEach(file => {
1390
+ if (file.type.startsWith('image/')) {
1391
+ const reader = new FileReader();
1392
+ reader.onload = (ev) => {
1393
+ const newImage: ImageAttachment = {
1394
+ id: generateId(),
1395
+ data: ev.target?.result as string,
1396
+ type: file.type,
1397
+ name: file.name,
1398
+ };
1399
+ setImages(prev => [...prev, newImage]);
1400
+ };
1401
+ reader.readAsDataURL(file);
1402
+ }
1403
+ });
1404
+ }
1405
+ }}
1406
+ />
1407
+ </label>
1408
+
1409
+ {/* Send button */}
1410
+ <button
1411
+ onClick={sendMessage}
1412
+ disabled={isGenerating || !inputValue.trim()}
1413
+ style={{
1414
+ padding: '8px 12px',
1415
+ background: (isGenerating || !inputValue.trim()) ? THEME.bgHover : THEME.accent,
1416
+ border: 'none',
1417
+ borderRadius: '8px',
1418
+ color: (isGenerating || !inputValue.trim()) ? THEME.textSubtle : '#fff',
1419
+ cursor: (isGenerating || !inputValue.trim()) ? 'not-allowed' : 'pointer',
1420
+ display: 'flex',
1421
+ alignItems: 'center',
1422
+ justifyContent: 'center',
1423
+ }}
1424
+ >
1425
+ <Icons.Send />
1426
+ </button>
1427
+ </div>
1428
+ </div>
1429
+ </div>
1430
+
1431
+ {/* Resize Handle */}
1432
+ <div
1433
+ onMouseDown={startResize}
1434
+ style={{
1435
+ position: 'absolute',
1436
+ right: 0,
1437
+ top: 0,
1438
+ bottom: 0,
1439
+ width: '4px',
1440
+ cursor: 'col-resize',
1441
+ background: 'transparent',
1442
+ }}
1443
+ />
1444
+ </div>
1445
+
1446
+ {/* Preview Panel */}
1447
+ <div style={{
1448
+ flex: 1,
1449
+ display: 'flex',
1450
+ flexDirection: 'column',
1451
+ background: THEME.bg,
1452
+ minWidth: 0,
1453
+ }}>
1454
+ {/* Preview Header */}
1455
+ <div style={{
1456
+ padding: '12px 16px',
1457
+ borderBottom: `1px solid ${THEME.border}`,
1458
+ display: 'flex',
1459
+ alignItems: 'center',
1460
+ justifyContent: 'space-between',
1461
+ }}>
1462
+ <div style={{ display: 'flex', gap: '4px' }}>
1463
+ <button
1464
+ onClick={() => setPreviewTab('preview')}
1465
+ style={{
1466
+ padding: '8px 14px',
1467
+ background: previewTab === 'preview' ? THEME.bgElevated : 'transparent',
1468
+ border: 'none',
1469
+ borderRadius: '6px',
1470
+ color: previewTab === 'preview' ? THEME.text : THEME.textMuted,
1471
+ fontSize: '13px',
1472
+ cursor: 'pointer',
1473
+ display: 'flex',
1474
+ alignItems: 'center',
1475
+ gap: '6px',
1476
+ }}
1477
+ >
1478
+ <Icons.Eye />
1479
+ Preview
1480
+ </button>
1481
+ <button
1482
+ onClick={() => setPreviewTab('code')}
1483
+ style={{
1484
+ padding: '8px 14px',
1485
+ background: previewTab === 'code' ? THEME.bgElevated : 'transparent',
1486
+ border: 'none',
1487
+ borderRadius: '6px',
1488
+ color: previewTab === 'code' ? THEME.text : THEME.textMuted,
1489
+ fontSize: '13px',
1490
+ cursor: 'pointer',
1491
+ display: 'flex',
1492
+ alignItems: 'center',
1493
+ gap: '6px',
1494
+ }}
1495
+ >
1496
+ <Icons.Code />
1497
+ Code
1498
+ </button>
1499
+ </div>
1500
+
1501
+ {previewCode && previewTab === 'preview' && (
1502
+ <span style={{ fontSize: '12px', color: THEME.textMuted }}>
1503
+ Live Preview
1504
+ </span>
1505
+ )}
1506
+ </div>
1507
+
1508
+ {/* Preview Content */}
1509
+ <div style={{ flex: 1, overflow: 'hidden' }}>
1510
+ {previewCode ? (
1511
+ previewTab === 'preview' ? (
1512
+ <LivePreviewRenderer
1513
+ code={previewCode}
1514
+ containerStyle={{ height: '100%', background: THEME.bgSurface }}
1515
+ onError={(err) => console.error('Preview error:', err)}
1516
+ />
1517
+ ) : (
1518
+ <CodeViewer code={previewCode} />
1519
+ )
1520
+ ) : (
1521
+ <div style={{
1522
+ height: '100%',
1523
+ display: 'flex',
1524
+ flexDirection: 'column',
1525
+ alignItems: 'center',
1526
+ justifyContent: 'center',
1527
+ color: THEME.textSubtle,
1528
+ padding: '40px',
1529
+ }}>
1530
+ <div style={{
1531
+ width: '80px',
1532
+ height: '80px',
1533
+ borderRadius: '20px',
1534
+ background: THEME.bgSurface,
1535
+ display: 'flex',
1536
+ alignItems: 'center',
1537
+ justifyContent: 'center',
1538
+ marginBottom: '20px',
1539
+ border: `1px solid ${THEME.border}`,
1540
+ }}>
1541
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
1542
+ <rect x="3" y="3" width="18" height="18" rx="2" />
1543
+ <path d="M3 9h18M9 21V9" />
1544
+ </svg>
1545
+ </div>
1546
+ <p style={{ fontSize: '16px', fontWeight: 500, marginBottom: '8px', color: THEME.textMuted }}>
1547
+ Ready to Build
1548
+ </p>
1549
+ <p style={{ fontSize: '14px', maxWidth: '260px', textAlign: 'center', lineHeight: 1.5 }}>
1550
+ Describe a component and watch it come to life
1551
+ </p>
1552
+ </div>
1553
+ )}
1554
+ </div>
1555
+ </div>
1556
+ </div>
1557
+ );
1558
+ };
1559
+
1560
+ export default App;