@tpitre/story-ui 3.8.0 → 3.10.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/dist/cli/index.js +22 -0
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +28 -2
- package/dist/cli/update.d.ts +29 -0
- package/dist/cli/update.d.ts.map +1 -0
- package/dist/cli/update.js +398 -0
- package/dist/mcp-server/index.js +81 -0
- package/dist/templates/StoryUI/StoryUIPanel.d.ts +12 -1
- package/dist/templates/StoryUI/StoryUIPanel.d.ts.map +1 -1
- package/dist/templates/StoryUI/StoryUIPanel.js +788 -1862
- package/package.json +1 -1
- package/templates/StoryUI/StoryUIPanel.css +1440 -0
- package/templates/StoryUI/StoryUIPanel.tsx +1259 -2563
|
@@ -1,129 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// Parse inline formatting within text
|
|
9
|
-
const parseInline = (str: string, paragraphIndex: number): ReactNode[] => {
|
|
10
|
-
const parts: ReactNode[] = [];
|
|
11
|
-
let remaining = str;
|
|
12
|
-
let keyIndex = 0;
|
|
13
|
-
|
|
14
|
-
while (remaining.length > 0) {
|
|
15
|
-
// Icon markers: [SUCCESS], [ERROR], [TIP], [WRENCH]
|
|
16
|
-
const iconMatch = remaining.match(/^\[(SUCCESS|ERROR|TIP|WRENCH)\]/);
|
|
17
|
-
if (iconMatch) {
|
|
18
|
-
const iconType = iconMatch[1].toLowerCase() as keyof typeof StatusIcons;
|
|
19
|
-
parts.push(<span key={`icon-${paragraphIndex}-${keyIndex++}`}>{StatusIcons[iconType]}</span>);
|
|
20
|
-
remaining = remaining.slice(iconMatch[0].length);
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Bold: **text**
|
|
25
|
-
const boldMatch = remaining.match(/^\*\*(.+?)\*\*/);
|
|
26
|
-
if (boldMatch) {
|
|
27
|
-
parts.push(<strong key={`b-${paragraphIndex}-${keyIndex++}`}>{boldMatch[1]}</strong>);
|
|
28
|
-
remaining = remaining.slice(boldMatch[0].length);
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* StoryUIPanel - AI-powered Storybook story generator
|
|
3
|
+
*
|
|
4
|
+
* ShadCN-inspired design with Gemini-style layout.
|
|
5
|
+
* Self-contained React component with no external UI dependencies.
|
|
6
|
+
* Supports light and dark modes based on Storybook theme.
|
|
7
|
+
*/
|
|
31
8
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (italicMatch) {
|
|
35
|
-
parts.push(<em key={`i-${paragraphIndex}-${keyIndex++}`} style={{ opacity: 0.7, fontSize: '0.9em' }}>{italicMatch[1]}</em>);
|
|
36
|
-
remaining = remaining.slice(italicMatch[0].length);
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
9
|
+
import React, { useState, useEffect, useRef, useCallback, useReducer } from 'react';
|
|
10
|
+
import './StoryUIPanel.css';
|
|
39
11
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
parts.push(
|
|
44
|
-
<code
|
|
45
|
-
key={`c-${paragraphIndex}-${keyIndex++}`}
|
|
46
|
-
style={{
|
|
47
|
-
background: 'rgba(0,0,0,0.08)',
|
|
48
|
-
padding: '1px 4px',
|
|
49
|
-
borderRadius: '4px',
|
|
50
|
-
fontFamily: 'ui-monospace, monospace',
|
|
51
|
-
fontSize: '0.88em'
|
|
52
|
-
}}
|
|
53
|
-
>
|
|
54
|
-
{codeMatch[1]}
|
|
55
|
-
</code>
|
|
56
|
-
);
|
|
57
|
-
remaining = remaining.slice(codeMatch[0].length);
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Single newline within paragraph - convert to space or line break
|
|
62
|
-
if (remaining.startsWith('\n')) {
|
|
63
|
-
parts.push(' ');
|
|
64
|
-
remaining = remaining.slice(1);
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Regular text - consume until next special character or bracket
|
|
69
|
-
const nextSpecial = remaining.search(/[*_`\[\n]/);
|
|
70
|
-
if (nextSpecial === -1) {
|
|
71
|
-
parts.push(remaining);
|
|
72
|
-
remaining = '';
|
|
73
|
-
} else if (nextSpecial === 0) {
|
|
74
|
-
// Special char that didn't match a pattern, treat as regular text
|
|
75
|
-
parts.push(remaining[0]);
|
|
76
|
-
remaining = remaining.slice(1);
|
|
77
|
-
} else {
|
|
78
|
-
parts.push(remaining.slice(0, nextSpecial));
|
|
79
|
-
remaining = remaining.slice(nextSpecial);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return parts;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<>
|
|
88
|
-
{paragraphs.map((paragraph, index) => (
|
|
89
|
-
<div key={`p-${index}`} style={{ marginBottom: index < paragraphs.length - 1 ? '8px' : 0 }}>
|
|
90
|
-
{parseInline(paragraph.trim(), index)}
|
|
91
|
-
</div>
|
|
92
|
-
))}
|
|
93
|
-
</>
|
|
94
|
-
);
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
// Inline SVG icons for status indicators (avoiding emojis)
|
|
98
|
-
const StatusIcons = {
|
|
99
|
-
success: (
|
|
100
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#22c55e', verticalAlign: 'middle', marginRight: '6px' }}>
|
|
101
|
-
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
102
|
-
<polyline points="22,4 12,14.01 9,11.01"/>
|
|
103
|
-
</svg>
|
|
104
|
-
),
|
|
105
|
-
error: (
|
|
106
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#ef4444', verticalAlign: 'middle', marginRight: '6px' }}>
|
|
107
|
-
<circle cx="12" cy="12" r="10"/>
|
|
108
|
-
<line x1="15" y1="9" x2="9" y2="15"/>
|
|
109
|
-
<line x1="9" y1="9" x2="15" y2="15"/>
|
|
110
|
-
</svg>
|
|
111
|
-
),
|
|
112
|
-
tip: (
|
|
113
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#f59e0b', verticalAlign: 'middle', marginRight: '4px' }}>
|
|
114
|
-
<circle cx="12" cy="12" r="10"/>
|
|
115
|
-
<line x1="12" y1="16" x2="12" y2="12"/>
|
|
116
|
-
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
|
117
|
-
</svg>
|
|
118
|
-
),
|
|
119
|
-
wrench: (
|
|
120
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#6366f1', verticalAlign: 'middle', marginRight: '4px' }}>
|
|
121
|
-
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
|
122
|
-
</svg>
|
|
123
|
-
)
|
|
124
|
-
};
|
|
12
|
+
// ============================================
|
|
13
|
+
// Types & Interfaces
|
|
14
|
+
// ============================================
|
|
125
15
|
|
|
126
|
-
// Message type
|
|
127
16
|
interface Message {
|
|
128
17
|
role: 'user' | 'ai';
|
|
129
18
|
content: string;
|
|
@@ -132,102 +21,84 @@ interface Message {
|
|
|
132
21
|
attachedImages?: AttachedImage[];
|
|
133
22
|
}
|
|
134
23
|
|
|
135
|
-
|
|
24
|
+
interface ChatSession {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
fileName: string;
|
|
28
|
+
conversation: Message[];
|
|
29
|
+
lastUpdated: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
136
32
|
interface AttachedImage {
|
|
137
33
|
id: string;
|
|
138
34
|
file: File;
|
|
139
35
|
preview: string;
|
|
140
|
-
base64
|
|
141
|
-
mediaType
|
|
36
|
+
base64: string;
|
|
37
|
+
mediaType: string;
|
|
142
38
|
}
|
|
143
39
|
|
|
144
|
-
// Provider info from /story-ui/providers API
|
|
145
|
-
interface ProviderInfo {
|
|
146
|
-
type: string;
|
|
147
|
-
name: string;
|
|
148
|
-
configured: boolean;
|
|
149
|
-
models: string[];
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
interface ProvidersResponse {
|
|
153
|
-
providers: ProviderInfo[];
|
|
154
|
-
current: {
|
|
155
|
-
provider: string;
|
|
156
|
-
model: string;
|
|
157
|
-
supportsVision: boolean;
|
|
158
|
-
supportsStreaming: boolean;
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Streaming event types (matching backend streamTypes.ts)
|
|
163
|
-
type StreamEventType = 'intent' | 'progress' | 'validation' | 'retry' | 'completion' | 'error';
|
|
164
|
-
|
|
165
40
|
interface IntentPreview {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
strategy: string;
|
|
170
|
-
estimatedComponents: string[];
|
|
171
|
-
promptAnalysis: {
|
|
172
|
-
hasVisionInput: boolean;
|
|
173
|
-
hasConversationContext: boolean;
|
|
174
|
-
hasPreviousCode: boolean;
|
|
175
|
-
};
|
|
41
|
+
title: string;
|
|
42
|
+
components: string[];
|
|
43
|
+
approach: string;
|
|
176
44
|
}
|
|
177
45
|
|
|
178
46
|
interface ProgressUpdate {
|
|
47
|
+
phase: string;
|
|
179
48
|
step: number;
|
|
180
49
|
totalSteps: number;
|
|
181
|
-
phase: 'config_loaded' | 'components_discovered' | 'prompt_built' | 'llm_thinking' | 'code_extracted' | 'validating' | 'post_processing' | 'saving';
|
|
182
50
|
message: string;
|
|
183
|
-
details?: Record<string, unknown>;
|
|
184
51
|
}
|
|
185
52
|
|
|
186
53
|
interface ValidationFeedback {
|
|
187
54
|
isValid: boolean;
|
|
188
|
-
errors
|
|
189
|
-
|
|
190
|
-
autoFixApplied: boolean;
|
|
191
|
-
fixDetails?: string[];
|
|
55
|
+
errors?: string[];
|
|
56
|
+
autoFixApplied?: boolean;
|
|
192
57
|
}
|
|
193
58
|
|
|
194
59
|
interface RetryInfo {
|
|
195
60
|
attempt: number;
|
|
196
61
|
maxAttempts: number;
|
|
197
62
|
reason: string;
|
|
198
|
-
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ComponentUsage {
|
|
66
|
+
name: string;
|
|
67
|
+
reason?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface LayoutChoice {
|
|
71
|
+
pattern: string;
|
|
72
|
+
reason: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface StyleChoice {
|
|
76
|
+
property: string;
|
|
77
|
+
value: string;
|
|
78
|
+
reason?: string;
|
|
199
79
|
}
|
|
200
80
|
|
|
201
81
|
interface CompletionFeedback {
|
|
202
82
|
success: boolean;
|
|
203
|
-
|
|
204
|
-
fileName
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
83
|
+
storyId?: string;
|
|
84
|
+
fileName?: string;
|
|
85
|
+
title?: string;
|
|
86
|
+
code?: string;
|
|
87
|
+
summary: { action: string; details: string };
|
|
88
|
+
componentsUsed: ComponentUsage[];
|
|
89
|
+
layoutChoices: LayoutChoice[];
|
|
90
|
+
styleChoices?: StyleChoice[];
|
|
91
|
+
validation?: ValidationFeedback;
|
|
210
92
|
suggestions?: string[];
|
|
211
|
-
|
|
212
|
-
code: string;
|
|
213
|
-
metrics: { totalTimeMs: number; llmCallsCount: number; tokensUsed?: number };
|
|
93
|
+
metrics?: { totalTimeMs: number; llmCallsCount: number };
|
|
214
94
|
}
|
|
215
95
|
|
|
216
96
|
interface ErrorFeedback {
|
|
217
|
-
code: string;
|
|
218
97
|
message: string;
|
|
219
98
|
details?: string;
|
|
220
|
-
recoverable: boolean;
|
|
221
99
|
suggestion?: string;
|
|
222
100
|
}
|
|
223
101
|
|
|
224
|
-
interface StreamEvent {
|
|
225
|
-
type: StreamEventType;
|
|
226
|
-
timestamp: number;
|
|
227
|
-
data: IntentPreview | ProgressUpdate | ValidationFeedback | RetryInfo | CompletionFeedback | ErrorFeedback;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// State for tracking streaming progress
|
|
231
102
|
interface StreamingState {
|
|
232
103
|
intent?: IntentPreview;
|
|
233
104
|
progress?: ProgressUpdate;
|
|
@@ -237,1536 +108,879 @@ interface StreamingState {
|
|
|
237
108
|
error?: ErrorFeedback;
|
|
238
109
|
}
|
|
239
110
|
|
|
240
|
-
|
|
241
|
-
interface ChatSession {
|
|
111
|
+
interface OrphanStory {
|
|
242
112
|
id: string;
|
|
243
113
|
title: string;
|
|
244
114
|
fileName: string;
|
|
245
|
-
conversation: Message[];
|
|
246
|
-
lastUpdated: number;
|
|
247
115
|
}
|
|
248
116
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
createdAt: number;
|
|
117
|
+
interface ProviderInfo {
|
|
118
|
+
type: string;
|
|
119
|
+
name: string;
|
|
120
|
+
configured: boolean;
|
|
121
|
+
models: string[];
|
|
255
122
|
}
|
|
256
123
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
'claude-opus-4-5-20251101': 'Claude Opus 4.5',
|
|
262
|
-
'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5',
|
|
263
|
-
'claude-haiku-4-5-20251001': 'Claude Haiku 4.5',
|
|
264
|
-
'claude-sonnet-4-20250514': 'Claude Sonnet 4',
|
|
265
|
-
'claude-opus-4-20250514': 'Claude Opus 4',
|
|
266
|
-
'claude-3-7-sonnet-20250219': 'Claude 3.7 Sonnet',
|
|
267
|
-
'claude-3-5-sonnet-20241022': 'Claude 3.5 Sonnet',
|
|
268
|
-
'claude-3-5-haiku-20241022': 'Claude 3.5 Haiku',
|
|
269
|
-
// OpenAI models
|
|
270
|
-
'gpt-5.1': 'GPT-5.1',
|
|
271
|
-
'gpt-5.1-thinking': 'GPT-5.1 Thinking',
|
|
272
|
-
'gpt-5': 'GPT-5',
|
|
273
|
-
'gpt-4o': 'GPT-4o',
|
|
274
|
-
'gpt-4o-mini': 'GPT-4o Mini',
|
|
275
|
-
'o1': 'o1',
|
|
276
|
-
'o1-mini': 'o1 Mini',
|
|
277
|
-
// Gemini models
|
|
278
|
-
'gemini-3-pro': 'Gemini 3 Pro',
|
|
279
|
-
'gemini-3-pro-preview': 'Gemini 3 Pro Preview',
|
|
280
|
-
'gemini-2.0-flash-exp': 'Gemini 2.0 Flash Exp',
|
|
281
|
-
'gemini-2.0-flash': 'Gemini 2.0 Flash',
|
|
282
|
-
'gemini-1.5-pro': 'Gemini 1.5 Pro',
|
|
283
|
-
'gemini-1.5-flash': 'Gemini 1.5 Flash',
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
// Get friendly display name for a model, falling back to the API name if not found
|
|
287
|
-
const getModelDisplayName = (modelId: string): string => {
|
|
288
|
-
return MODEL_DISPLAY_NAMES[modelId] || modelId;
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
// Determine the MCP API base URL.
|
|
292
|
-
// Priority order:
|
|
293
|
-
// 1. VITE_STORY_UI_EDGE_URL - Edge Worker URL for cloud deployments
|
|
294
|
-
// 2. window.__STORY_UI_EDGE_URL__ - Runtime override for edge URL
|
|
295
|
-
// 3. Production domains (railway.app, render.com, pages.dev) - use same origin
|
|
296
|
-
// 4. VITE_STORY_UI_PORT - Custom port for localhost
|
|
297
|
-
// 5. window.__STORY_UI_PORT__ - Legacy port override
|
|
298
|
-
// 6. window.STORY_UI_MCP_PORT - MCP port override
|
|
299
|
-
// 7. Default to localhost:4001
|
|
300
|
-
const getApiBaseUrl = () => {
|
|
301
|
-
// Check for Edge Worker URL (cloud deployment)
|
|
302
|
-
const edgeUrl = (import.meta as any).env?.VITE_STORY_UI_EDGE_URL;
|
|
303
|
-
if (edgeUrl) return edgeUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
304
|
-
|
|
305
|
-
// Check for window override for edge URL (support both naming conventions)
|
|
306
|
-
const windowEdgeUrl = (window as any).__STORY_UI_EDGE_URL__ || (window as any).STORY_UI_EDGE_URL;
|
|
307
|
-
if (windowEdgeUrl) return windowEdgeUrl.replace(/\/$/, '');
|
|
308
|
-
|
|
309
|
-
// Check if we're running on Railway production domain
|
|
310
|
-
// In this case, the MCP server is proxied through the same origin
|
|
311
|
-
if (typeof window !== 'undefined') {
|
|
312
|
-
const hostname = window.location.hostname;
|
|
313
|
-
if (hostname.includes('.railway.app')) {
|
|
314
|
-
// Use same-origin requests (empty string means relative URLs)
|
|
315
|
-
return '';
|
|
316
|
-
}
|
|
317
|
-
}
|
|
124
|
+
interface ProvidersResponse {
|
|
125
|
+
providers: ProviderInfo[];
|
|
126
|
+
current?: { provider: string; model: string };
|
|
127
|
+
}
|
|
318
128
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
129
|
+
interface StreamEvent {
|
|
130
|
+
type: 'intent' | 'progress' | 'validation' | 'retry' | 'completion' | 'error';
|
|
131
|
+
data: unknown;
|
|
132
|
+
}
|
|
322
133
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
134
|
+
// ============================================
|
|
135
|
+
// State Reducer
|
|
136
|
+
// ============================================
|
|
326
137
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
138
|
+
interface PanelState {
|
|
139
|
+
sidebarOpen: boolean;
|
|
140
|
+
showCode: boolean;
|
|
141
|
+
isDragging: boolean;
|
|
142
|
+
loading: boolean;
|
|
143
|
+
isBulkDeleting: boolean;
|
|
144
|
+
conversation: Message[];
|
|
145
|
+
recentChats: ChatSession[];
|
|
146
|
+
orphanStories: OrphanStory[];
|
|
147
|
+
activeChatId: string | null;
|
|
148
|
+
activeTitle: string;
|
|
149
|
+
input: string;
|
|
150
|
+
attachedImages: AttachedImage[];
|
|
151
|
+
selectedStoryIds: Set<string>;
|
|
152
|
+
availableProviders: ProviderInfo[];
|
|
153
|
+
selectedProvider: string;
|
|
154
|
+
selectedModel: string;
|
|
155
|
+
connectionStatus: { connected: boolean; error?: string };
|
|
156
|
+
streamingState: StreamingState | null;
|
|
157
|
+
error: string | null;
|
|
158
|
+
considerations: string;
|
|
159
|
+
isDarkMode: boolean;
|
|
160
|
+
}
|
|
330
161
|
|
|
331
|
-
|
|
162
|
+
type PanelAction =
|
|
163
|
+
| { type: 'TOGGLE_SIDEBAR' }
|
|
164
|
+
| { type: 'SET_SIDEBAR'; payload: boolean }
|
|
165
|
+
| { type: 'TOGGLE_CODE' }
|
|
166
|
+
| { type: 'SET_DRAGGING'; payload: boolean }
|
|
167
|
+
| { type: 'SET_LOADING'; payload: boolean }
|
|
168
|
+
| { type: 'SET_BULK_DELETING'; payload: boolean }
|
|
169
|
+
| { type: 'SET_CONVERSATION'; payload: Message[] }
|
|
170
|
+
| { type: 'ADD_MESSAGE'; payload: Message }
|
|
171
|
+
| { type: 'SET_RECENT_CHATS'; payload: ChatSession[] }
|
|
172
|
+
| { type: 'SET_ORPHAN_STORIES'; payload: OrphanStory[] }
|
|
173
|
+
| { type: 'SET_ACTIVE_CHAT'; payload: { id: string | null; title: string } }
|
|
174
|
+
| { type: 'SET_INPUT'; payload: string }
|
|
175
|
+
| { type: 'SET_ATTACHED_IMAGES'; payload: AttachedImage[] }
|
|
176
|
+
| { type: 'ADD_ATTACHED_IMAGE'; payload: AttachedImage }
|
|
177
|
+
| { type: 'REMOVE_ATTACHED_IMAGE'; payload: string }
|
|
178
|
+
| { type: 'CLEAR_ATTACHED_IMAGES' }
|
|
179
|
+
| { type: 'SET_SELECTED_STORY_IDS'; payload: Set<string> }
|
|
180
|
+
| { type: 'TOGGLE_STORY_SELECTION'; payload: string }
|
|
181
|
+
| { type: 'SET_PROVIDERS'; payload: ProviderInfo[] }
|
|
182
|
+
| { type: 'SET_SELECTED_PROVIDER'; payload: string }
|
|
183
|
+
| { type: 'SET_SELECTED_MODEL'; payload: string }
|
|
184
|
+
| { type: 'SET_CONNECTION_STATUS'; payload: { connected: boolean; error?: string } }
|
|
185
|
+
| { type: 'SET_STREAMING_STATE'; payload: StreamingState | null }
|
|
186
|
+
| { type: 'UPDATE_STREAMING_STATE'; payload: Partial<StreamingState> }
|
|
187
|
+
| { type: 'SET_ERROR'; payload: string | null }
|
|
188
|
+
| { type: 'SET_CONSIDERATIONS'; payload: string }
|
|
189
|
+
| { type: 'SET_DARK_MODE'; payload: boolean }
|
|
190
|
+
| { type: 'NEW_CHAT' };
|
|
191
|
+
|
|
192
|
+
const initialState: PanelState = {
|
|
193
|
+
sidebarOpen: true,
|
|
194
|
+
showCode: false,
|
|
195
|
+
isDragging: false,
|
|
196
|
+
loading: false,
|
|
197
|
+
isBulkDeleting: false,
|
|
198
|
+
conversation: [],
|
|
199
|
+
recentChats: [],
|
|
200
|
+
orphanStories: [],
|
|
201
|
+
activeChatId: null,
|
|
202
|
+
activeTitle: '',
|
|
203
|
+
input: '',
|
|
204
|
+
attachedImages: [],
|
|
205
|
+
selectedStoryIds: new Set(),
|
|
206
|
+
availableProviders: [],
|
|
207
|
+
selectedProvider: '',
|
|
208
|
+
selectedModel: '',
|
|
209
|
+
connectionStatus: { connected: false },
|
|
210
|
+
streamingState: null,
|
|
211
|
+
error: null,
|
|
212
|
+
considerations: '',
|
|
213
|
+
isDarkMode: false,
|
|
332
214
|
};
|
|
333
215
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
};
|
|
216
|
+
function panelReducer(state: PanelState, action: PanelAction): PanelState {
|
|
217
|
+
switch (action.type) {
|
|
218
|
+
case 'TOGGLE_SIDEBAR':
|
|
219
|
+
return { ...state, sidebarOpen: !state.sidebarOpen };
|
|
220
|
+
case 'SET_SIDEBAR':
|
|
221
|
+
return { ...state, sidebarOpen: action.payload };
|
|
222
|
+
case 'TOGGLE_CODE':
|
|
223
|
+
return { ...state, showCode: !state.showCode };
|
|
224
|
+
case 'SET_DRAGGING':
|
|
225
|
+
return { ...state, isDragging: action.payload };
|
|
226
|
+
case 'SET_LOADING':
|
|
227
|
+
return { ...state, loading: action.payload };
|
|
228
|
+
case 'SET_BULK_DELETING':
|
|
229
|
+
return { ...state, isBulkDeleting: action.payload };
|
|
230
|
+
case 'SET_CONVERSATION':
|
|
231
|
+
return { ...state, conversation: action.payload };
|
|
232
|
+
case 'ADD_MESSAGE':
|
|
233
|
+
return { ...state, conversation: [...state.conversation, action.payload] };
|
|
234
|
+
case 'SET_RECENT_CHATS':
|
|
235
|
+
return { ...state, recentChats: action.payload };
|
|
236
|
+
case 'SET_ORPHAN_STORIES':
|
|
237
|
+
return { ...state, orphanStories: action.payload };
|
|
238
|
+
case 'SET_ACTIVE_CHAT':
|
|
239
|
+
return { ...state, activeChatId: action.payload.id, activeTitle: action.payload.title };
|
|
240
|
+
case 'SET_INPUT':
|
|
241
|
+
return { ...state, input: action.payload };
|
|
242
|
+
case 'SET_ATTACHED_IMAGES':
|
|
243
|
+
return { ...state, attachedImages: action.payload };
|
|
244
|
+
case 'ADD_ATTACHED_IMAGE':
|
|
245
|
+
return { ...state, attachedImages: [...state.attachedImages, action.payload] };
|
|
246
|
+
case 'REMOVE_ATTACHED_IMAGE':
|
|
247
|
+
return {
|
|
248
|
+
...state,
|
|
249
|
+
attachedImages: state.attachedImages.filter(img => img.id !== action.payload),
|
|
250
|
+
};
|
|
251
|
+
case 'CLEAR_ATTACHED_IMAGES':
|
|
252
|
+
return { ...state, attachedImages: [] };
|
|
253
|
+
case 'SET_SELECTED_STORY_IDS':
|
|
254
|
+
return { ...state, selectedStoryIds: action.payload };
|
|
255
|
+
case 'TOGGLE_STORY_SELECTION': {
|
|
256
|
+
const newSet = new Set(state.selectedStoryIds);
|
|
257
|
+
if (newSet.has(action.payload)) {
|
|
258
|
+
newSet.delete(action.payload);
|
|
259
|
+
} else {
|
|
260
|
+
newSet.add(action.payload);
|
|
261
|
+
}
|
|
262
|
+
return { ...state, selectedStoryIds: newSet };
|
|
263
|
+
}
|
|
264
|
+
case 'SET_PROVIDERS':
|
|
265
|
+
return { ...state, availableProviders: action.payload };
|
|
266
|
+
case 'SET_SELECTED_PROVIDER':
|
|
267
|
+
return { ...state, selectedProvider: action.payload };
|
|
268
|
+
case 'SET_SELECTED_MODEL':
|
|
269
|
+
return { ...state, selectedModel: action.payload };
|
|
270
|
+
case 'SET_CONNECTION_STATUS':
|
|
271
|
+
return { ...state, connectionStatus: action.payload };
|
|
272
|
+
case 'SET_STREAMING_STATE':
|
|
273
|
+
return { ...state, streamingState: action.payload };
|
|
274
|
+
case 'UPDATE_STREAMING_STATE':
|
|
275
|
+
return { ...state, streamingState: { ...state.streamingState, ...action.payload } };
|
|
276
|
+
case 'SET_ERROR':
|
|
277
|
+
return { ...state, error: action.payload };
|
|
278
|
+
case 'SET_CONSIDERATIONS':
|
|
279
|
+
return { ...state, considerations: action.payload };
|
|
280
|
+
case 'SET_DARK_MODE':
|
|
281
|
+
return { ...state, isDarkMode: action.payload };
|
|
282
|
+
case 'NEW_CHAT':
|
|
283
|
+
return { ...state, conversation: [], activeChatId: null, activeTitle: '' };
|
|
284
|
+
default:
|
|
285
|
+
return state;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
340
288
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
const kebabTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
345
|
-
return `generated-${kebabTitle}--default`;
|
|
346
|
-
};
|
|
289
|
+
// ============================================
|
|
290
|
+
// Constants
|
|
291
|
+
// ============================================
|
|
347
292
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
console.log(`[Story UI] Will navigate to story "${storyPath}" in ${delayMs}ms...`);
|
|
354
|
-
|
|
355
|
-
setTimeout(() => {
|
|
356
|
-
// Navigate the TOP window (parent Storybook UI), not the iframe
|
|
357
|
-
// The Story UI panel runs inside an iframe, so we need window.top to escape it
|
|
358
|
-
const topWindow = window.top || window;
|
|
359
|
-
const newUrl = `${topWindow.location.origin}/?path=/story/${storyPath}`;
|
|
360
|
-
console.log(`[Story UI] Navigating parent window to: ${newUrl}`);
|
|
361
|
-
topWindow.location.href = newUrl;
|
|
362
|
-
}, delayMs);
|
|
363
|
-
};
|
|
293
|
+
const USE_STREAMING = true;
|
|
294
|
+
const MAX_RECENT_CHATS = 20;
|
|
295
|
+
const CHAT_STORAGE_KEY = 'story-ui-chats';
|
|
296
|
+
const MAX_IMAGES = 4;
|
|
297
|
+
const MAX_IMAGE_SIZE_MB = 20;
|
|
364
298
|
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const match = baseUrl.match(/:(\d+)$/);
|
|
369
|
-
return match ? match[1] : '4001';
|
|
370
|
-
};
|
|
299
|
+
// ============================================
|
|
300
|
+
// Helper Functions
|
|
301
|
+
// ============================================
|
|
371
302
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
return
|
|
303
|
+
function getApiBaseUrl(): string {
|
|
304
|
+
if (typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_STORY_UI_EDGE_URL) {
|
|
305
|
+
return (import.meta as any).env.VITE_STORY_UI_EDGE_URL;
|
|
306
|
+
}
|
|
307
|
+
if (typeof window !== 'undefined') {
|
|
308
|
+
if ((window as any).__STORY_UI_EDGE_URL__) {
|
|
309
|
+
return (window as any).__STORY_UI_EDGE_URL__;
|
|
310
|
+
}
|
|
311
|
+
if (window.location.hostname.includes('railway.app')) {
|
|
312
|
+
return window.location.origin;
|
|
382
313
|
}
|
|
383
314
|
}
|
|
384
|
-
|
|
385
|
-
|
|
315
|
+
let port = '4001';
|
|
316
|
+
if (typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_STORY_UI_PORT) {
|
|
317
|
+
port = (import.meta as any).env.VITE_STORY_UI_PORT;
|
|
318
|
+
} else if (typeof window !== 'undefined') {
|
|
319
|
+
if ((window as any).__STORY_UI_PORT__) {
|
|
320
|
+
port = (window as any).__STORY_UI_PORT__;
|
|
321
|
+
} else if ((window as any).STORY_UI_MCP_PORT) {
|
|
322
|
+
port = (window as any).STORY_UI_MCP_PORT;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return `http://localhost:${port}`;
|
|
326
|
+
}
|
|
386
327
|
|
|
387
328
|
const API_BASE = getApiBaseUrl();
|
|
388
|
-
const MCP_API = `${API_BASE}/
|
|
389
|
-
const MCP_STREAM_API = `${API_BASE}/
|
|
329
|
+
const MCP_API = `${API_BASE}/mcp/generate-story`;
|
|
330
|
+
const MCP_STREAM_API = `${API_BASE}/mcp/generate-story-stream`;
|
|
331
|
+
const PROVIDERS_API = `${API_BASE}/mcp/providers`;
|
|
390
332
|
const STORIES_API = `${API_BASE}/story-ui/stories`;
|
|
391
|
-
const
|
|
392
|
-
const PROVIDERS_API = `${API_BASE}/story-ui/providers`;
|
|
393
|
-
// Considerations API URL - includes storybookOrigin param for Edge mode
|
|
394
|
-
const getConsiderationsApiUrl = () => {
|
|
395
|
-
const baseUrl = `${API_BASE}/story-ui/considerations`;
|
|
396
|
-
if (isEdgeMode()) {
|
|
397
|
-
// In Edge mode, tell the Edge Worker where to fetch considerations from
|
|
398
|
-
// The Storybook origin is where the panel is running (window.location.origin)
|
|
399
|
-
const storybookOrigin = window.location.origin;
|
|
400
|
-
return `${baseUrl}?storybookOrigin=${encodeURIComponent(storybookOrigin)}`;
|
|
401
|
-
}
|
|
402
|
-
return baseUrl;
|
|
403
|
-
};
|
|
404
|
-
const CONSIDERATIONS_API = getConsiderationsApiUrl();
|
|
405
|
-
const STORAGE_KEY = `story-ui-chats-${window.location.port}`;
|
|
406
|
-
const MAX_RECENT_CHATS = 20;
|
|
333
|
+
const CONSIDERATIONS_API = `${API_BASE}/mcp/considerations`;
|
|
407
334
|
|
|
408
|
-
|
|
409
|
-
const
|
|
335
|
+
function isEdgeMode(): boolean {
|
|
336
|
+
const baseUrl = getApiBaseUrl();
|
|
337
|
+
return baseUrl.includes('railway.app') || baseUrl.includes('workers.dev');
|
|
338
|
+
}
|
|
410
339
|
|
|
411
|
-
|
|
412
|
-
const
|
|
340
|
+
function getConnectionDisplayText(): string {
|
|
341
|
+
const baseUrl = getApiBaseUrl();
|
|
342
|
+
if (baseUrl.includes('railway.app')) return 'Railway Cloud';
|
|
343
|
+
if (baseUrl.includes('workers.dev')) return 'Cloudflare Edge';
|
|
344
|
+
const port = baseUrl.match(/:(\d+)/)?.[1] || '4001';
|
|
345
|
+
return `localhost:${port}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function loadChats(): ChatSession[] {
|
|
413
349
|
try {
|
|
414
|
-
const stored = localStorage.getItem(
|
|
415
|
-
if (
|
|
416
|
-
const chats = JSON.parse(stored) as ChatSession[];
|
|
417
|
-
// Sort by lastUpdated and limit
|
|
418
|
-
return chats
|
|
419
|
-
.sort((a, b) => b.lastUpdated - a.lastUpdated)
|
|
420
|
-
.slice(0, MAX_RECENT_CHATS);
|
|
350
|
+
const stored = localStorage.getItem(CHAT_STORAGE_KEY);
|
|
351
|
+
if (stored) return JSON.parse(stored);
|
|
421
352
|
} catch (e) {
|
|
422
353
|
console.error('Failed to load chats:', e);
|
|
423
|
-
return [];
|
|
424
354
|
}
|
|
425
|
-
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
426
357
|
|
|
427
|
-
|
|
428
|
-
const saveChats = (chats: ChatSession[]) => {
|
|
358
|
+
function saveChats(chats: ChatSession[]): void {
|
|
429
359
|
try {
|
|
430
|
-
|
|
431
|
-
const toSave = chats
|
|
432
|
-
.sort((a, b) => b.lastUpdated - a.lastUpdated)
|
|
433
|
-
.slice(0, MAX_RECENT_CHATS);
|
|
434
|
-
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
|
|
360
|
+
localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(chats));
|
|
435
361
|
} catch (e) {
|
|
436
362
|
console.error('Failed to save chats:', e);
|
|
437
363
|
}
|
|
438
|
-
}
|
|
364
|
+
}
|
|
439
365
|
|
|
440
|
-
|
|
441
|
-
const syncWithActualStories = async (): Promise<ChatSession[]> => {
|
|
366
|
+
async function testMCPConnection(): Promise<{ connected: boolean; error?: string }> {
|
|
442
367
|
try {
|
|
443
|
-
const response = await fetch(
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
368
|
+
const response = await fetch(PROVIDERS_API, { method: 'GET' });
|
|
369
|
+
if (response.ok) return { connected: true };
|
|
370
|
+
return { connected: false, error: `Server returned ${response.status}` };
|
|
371
|
+
} catch (e) {
|
|
372
|
+
return { connected: false, error: 'Cannot connect to MCP server' };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
448
375
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
376
|
+
// Simply load chats from localStorage - don't filter based on server state
|
|
377
|
+
// Chats should persist independently of whether story files exist
|
|
378
|
+
async function syncWithActualStories(): Promise<ChatSession[]> {
|
|
379
|
+
return loadChats();
|
|
380
|
+
}
|
|
455
381
|
|
|
382
|
+
async function fetchOrphanStories(): Promise<OrphanStory[]> {
|
|
383
|
+
try {
|
|
384
|
+
const response = await fetch(STORIES_API);
|
|
385
|
+
if (!response.ok) return [];
|
|
456
386
|
const data = await response.json();
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
chatMap.set(chat.id, chat);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
// Update or add memory stories
|
|
469
|
-
memoryStories.forEach((story: any) => {
|
|
470
|
-
const storyId = story.storyId || story.fileName;
|
|
471
|
-
|
|
472
|
-
// Look for existing chat by ID or by matching fileName
|
|
473
|
-
let existingChat = chatMap.get(storyId);
|
|
474
|
-
|
|
475
|
-
// If not found by ID, search by fileName
|
|
476
|
-
if (!existingChat && story.fileName) {
|
|
477
|
-
for (const [id, chat] of chatMap.entries()) {
|
|
478
|
-
if (chat.fileName === story.fileName) {
|
|
479
|
-
existingChat = chat;
|
|
480
|
-
break;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (existingChat) {
|
|
486
|
-
// Update existing chat with latest info
|
|
487
|
-
existingChat.title = story.title || existingChat.title;
|
|
488
|
-
existingChat.fileName = story.fileName || existingChat.fileName;
|
|
489
|
-
existingChat.lastUpdated = new Date(story.updatedAt || story.createdAt).getTime();
|
|
490
|
-
} else {
|
|
491
|
-
// Create new chat from memory story
|
|
492
|
-
const newChat: ChatSession = {
|
|
493
|
-
id: storyId,
|
|
494
|
-
title: story.title || story.fileName,
|
|
495
|
-
fileName: story.fileName,
|
|
496
|
-
conversation: [{
|
|
497
|
-
role: 'user',
|
|
498
|
-
content: story.prompt || `Generate ${story.title}`
|
|
499
|
-
}, {
|
|
500
|
-
role: 'ai',
|
|
501
|
-
content: `[SUCCESS] Created story: "${story.title}"\n\nThis story was recovered from memory. You can continue updating it or view it in Storybook.`
|
|
502
|
-
}],
|
|
503
|
-
lastUpdated: new Date(story.updatedAt || story.createdAt).getTime()
|
|
504
|
-
};
|
|
505
|
-
chatMap.set(storyId, newChat);
|
|
506
|
-
}
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
// Convert back to array and save
|
|
510
|
-
const syncedChats = Array.from(chatMap.values());
|
|
511
|
-
saveChats(syncedChats);
|
|
512
|
-
|
|
513
|
-
return syncedChats;
|
|
514
|
-
} catch (error) {
|
|
515
|
-
console.error('Error syncing with backend:', error);
|
|
516
|
-
return loadChats();
|
|
387
|
+
const serverStories = data.stories || [];
|
|
388
|
+
const localChats = loadChats();
|
|
389
|
+
const chatIds = new Set(localChats.map(c => c.id));
|
|
390
|
+
return serverStories
|
|
391
|
+
.filter((s: any) => !chatIds.has(s.id))
|
|
392
|
+
.map((s: any) => ({ id: s.id, title: s.title, fileName: s.fileName }));
|
|
393
|
+
} catch (e) {
|
|
394
|
+
return [];
|
|
517
395
|
}
|
|
518
|
-
}
|
|
396
|
+
}
|
|
519
397
|
|
|
520
|
-
|
|
521
|
-
const deleteStoryAndChat = async (chatId: string): Promise<boolean> => {
|
|
398
|
+
async function deleteStoryAndChat(chatId: string): Promise<boolean> {
|
|
522
399
|
try {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
// First try to delete from backend
|
|
530
|
-
try {
|
|
531
|
-
const response = await fetch(`${DELETE_API_BASE}/${storyId}`, {
|
|
532
|
-
method: 'DELETE',
|
|
533
|
-
headers: { 'Content-Type': 'application/json' }
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
// 404 means story doesn't exist on server - that's OK, we can still clean up localStorage
|
|
537
|
-
if (response.ok || response.status === 404) {
|
|
538
|
-
serverDeleteSucceeded = true;
|
|
539
|
-
if (response.status === 404) {
|
|
540
|
-
console.log('Story not found on server (may have been a failed generation), cleaning up localStorage');
|
|
541
|
-
}
|
|
542
|
-
} else {
|
|
543
|
-
console.warn(`Backend delete returned ${response.status}, trying legacy endpoint`);
|
|
544
|
-
}
|
|
545
|
-
} catch (fetchError) {
|
|
546
|
-
console.warn('Backend delete request failed, trying legacy endpoint:', fetchError);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Try legacy endpoint as fallback only if primary didn't succeed
|
|
550
|
-
if (!serverDeleteSucceeded) {
|
|
551
|
-
try {
|
|
552
|
-
const legacyResponse = await fetch(`${API_BASE}/story-ui/delete`, {
|
|
553
|
-
method: 'POST',
|
|
554
|
-
headers: { 'Content-Type': 'application/json' },
|
|
555
|
-
body: JSON.stringify({
|
|
556
|
-
chatId: storyId,
|
|
557
|
-
storyId: storyId
|
|
558
|
-
})
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
// 404 is also OK for legacy endpoint
|
|
562
|
-
if (legacyResponse.ok || legacyResponse.status === 404) {
|
|
563
|
-
serverDeleteSucceeded = true;
|
|
564
|
-
} else {
|
|
565
|
-
console.warn('Legacy delete endpoint also returned non-success status');
|
|
566
|
-
}
|
|
567
|
-
} catch (legacyError) {
|
|
568
|
-
console.warn('Legacy delete request failed:', legacyError);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// Always clean up localStorage - the chat/story data is primarily client-side
|
|
573
|
-
// Even if server delete failed, we should allow users to clean up their chat history
|
|
574
|
-
const chats = loadChats().filter(chat => chat.id !== chatId);
|
|
575
|
-
saveChats(chats);
|
|
576
|
-
console.log('Cleaned up localStorage chat entry');
|
|
577
|
-
|
|
578
|
-
return true;
|
|
579
|
-
} catch (error) {
|
|
580
|
-
console.error('Error deleting story:', error);
|
|
581
|
-
// Still try to clean up localStorage even on error
|
|
582
|
-
try {
|
|
583
|
-
const chats = loadChats().filter(chat => chat.id !== chatId);
|
|
400
|
+
const response = await fetch(`${STORIES_API}/${chatId}`, { method: 'DELETE' });
|
|
401
|
+
// Delete chat from localStorage if:
|
|
402
|
+
// - Story was successfully deleted (200/204)
|
|
403
|
+
// - Story doesn't exist (404) - orphan chat case
|
|
404
|
+
if (response.ok || response.status === 404) {
|
|
405
|
+
const chats = loadChats().filter(c => c.id !== chatId);
|
|
584
406
|
saveChats(chats);
|
|
585
|
-
console.log('Cleaned up localStorage despite error');
|
|
586
407
|
return true;
|
|
587
|
-
} catch (localError) {
|
|
588
|
-
console.error('Failed to clean up localStorage:', localError);
|
|
589
|
-
return false;
|
|
590
408
|
}
|
|
409
|
+
return false;
|
|
410
|
+
} catch (e) {
|
|
411
|
+
// On network error, still allow removing the chat from localStorage
|
|
412
|
+
// since the story file may not exist anyway
|
|
413
|
+
const chats = loadChats().filter(c => c.id !== chatId);
|
|
414
|
+
saveChats(chats);
|
|
415
|
+
return true;
|
|
591
416
|
}
|
|
592
|
-
}
|
|
417
|
+
}
|
|
593
418
|
|
|
594
|
-
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
419
|
+
function formatTime(timestamp: number): string {
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
const diff = now - timestamp;
|
|
422
|
+
const minutes = Math.floor(diff / 60000);
|
|
423
|
+
const hours = Math.floor(diff / 3600000);
|
|
424
|
+
const days = Math.floor(diff / 86400000);
|
|
425
|
+
if (minutes < 1) return 'Just now';
|
|
426
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
427
|
+
if (hours < 24) return `${hours}h ago`;
|
|
428
|
+
if (days < 7) return `${days}d ago`;
|
|
429
|
+
return new Date(timestamp).toLocaleDateString();
|
|
430
|
+
}
|
|
601
431
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
432
|
+
function getModelDisplayName(model: string): string {
|
|
433
|
+
const displayNames: Record<string, string> = {
|
|
434
|
+
'claude-opus-4-5-20251101': 'Claude Opus 4.5',
|
|
435
|
+
'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5',
|
|
436
|
+
'claude-haiku-4-5-20251001': 'Claude Haiku 4.5',
|
|
437
|
+
'gpt-4o': 'GPT-4o',
|
|
438
|
+
'gpt-4o-mini': 'GPT-4o Mini',
|
|
439
|
+
'o1': 'o1',
|
|
440
|
+
'gemini-2.0-flash': 'Gemini 2.0 Flash',
|
|
441
|
+
'gemini-1.5-pro': 'Gemini 1.5 Pro',
|
|
442
|
+
};
|
|
443
|
+
return displayNames[model] || model;
|
|
444
|
+
}
|
|
605
445
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
446
|
+
// ============================================
|
|
447
|
+
// Icons (Lucide-style SVG)
|
|
448
|
+
// ============================================
|
|
610
449
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
450
|
+
const Icons = {
|
|
451
|
+
plus: (
|
|
452
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
453
|
+
<path d="M5 12h14" /><path d="M12 5v14" />
|
|
454
|
+
</svg>
|
|
455
|
+
),
|
|
456
|
+
messageSquare: (
|
|
457
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
458
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
459
|
+
</svg>
|
|
460
|
+
),
|
|
461
|
+
panelLeft: (
|
|
462
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
463
|
+
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" />
|
|
464
|
+
</svg>
|
|
465
|
+
),
|
|
466
|
+
x: (
|
|
467
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
468
|
+
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
|
469
|
+
</svg>
|
|
470
|
+
),
|
|
471
|
+
image: (
|
|
472
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
473
|
+
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
474
|
+
</svg>
|
|
475
|
+
),
|
|
476
|
+
send: (
|
|
477
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
478
|
+
<path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" />
|
|
479
|
+
</svg>
|
|
480
|
+
),
|
|
481
|
+
chevronDown: (
|
|
482
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
483
|
+
<path d="m6 9 6 6 6-6" />
|
|
484
|
+
</svg>
|
|
485
|
+
),
|
|
486
|
+
trash: (
|
|
487
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
488
|
+
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
489
|
+
</svg>
|
|
490
|
+
),
|
|
491
|
+
sparkles: (
|
|
492
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
493
|
+
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
|
494
|
+
<path d="M5 3v4" /><path d="M19 17v4" /><path d="M3 5h4" /><path d="M17 19h4" />
|
|
495
|
+
</svg>
|
|
496
|
+
),
|
|
497
|
+
moreVertical: (
|
|
498
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
499
|
+
<circle cx="12" cy="12" r="1" /><circle cx="12" cy="5" r="1" /><circle cx="12" cy="19" r="1" />
|
|
500
|
+
</svg>
|
|
501
|
+
),
|
|
502
|
+
pencil: (
|
|
503
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
504
|
+
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /><path d="m15 5 4 4" />
|
|
505
|
+
</svg>
|
|
506
|
+
),
|
|
507
|
+
check: (
|
|
508
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
509
|
+
<path d="M20 6 9 17l-5-5" />
|
|
510
|
+
</svg>
|
|
511
|
+
),
|
|
512
|
+
checkCircle: (
|
|
513
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
514
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
515
|
+
<path d="M22 4 12 14.01l-3-3" />
|
|
516
|
+
</svg>
|
|
517
|
+
),
|
|
518
|
+
xCircle: (
|
|
519
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
520
|
+
<circle cx="12" cy="12" r="10" />
|
|
521
|
+
<path d="m15 9-6 6" />
|
|
522
|
+
<path d="m9 9 6 6" />
|
|
523
|
+
</svg>
|
|
524
|
+
),
|
|
525
|
+
lightbulb: (
|
|
526
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
527
|
+
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
|
|
528
|
+
<path d="M9 18h6" />
|
|
529
|
+
<path d="M10 22h4" />
|
|
530
|
+
</svg>
|
|
531
|
+
),
|
|
532
|
+
wrench: (
|
|
533
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
534
|
+
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
|
535
|
+
</svg>
|
|
536
|
+
),
|
|
615
537
|
};
|
|
616
538
|
|
|
617
|
-
//
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
539
|
+
// ============================================
|
|
540
|
+
// Markdown Renderer
|
|
541
|
+
// ============================================
|
|
542
|
+
|
|
543
|
+
function renderMarkdown(content: string): React.ReactNode {
|
|
544
|
+
const elements: React.ReactNode[] = [];
|
|
545
|
+
let key = 0;
|
|
546
|
+
|
|
547
|
+
// Split content into blocks (paragraphs, lists, headings)
|
|
548
|
+
const blocks = content.split(/\n\n+/);
|
|
549
|
+
|
|
550
|
+
blocks.forEach(block => {
|
|
551
|
+
if (!block.trim()) return;
|
|
552
|
+
|
|
553
|
+
// Check for headings (# ## ###)
|
|
554
|
+
const headingMatch = block.match(/^(#{1,6})\s+(.+)$/);
|
|
555
|
+
if (headingMatch) {
|
|
556
|
+
const level = headingMatch[1].length;
|
|
557
|
+
const text = headingMatch[2];
|
|
558
|
+
const inlineContent = parseInline(text);
|
|
559
|
+
|
|
560
|
+
switch (level) {
|
|
561
|
+
case 1:
|
|
562
|
+
elements.push(<h1 key={key++}>{inlineContent}</h1>);
|
|
563
|
+
break;
|
|
564
|
+
case 2:
|
|
565
|
+
elements.push(<h2 key={key++}>{inlineContent}</h2>);
|
|
566
|
+
break;
|
|
567
|
+
case 3:
|
|
568
|
+
elements.push(<h3 key={key++}>{inlineContent}</h3>);
|
|
569
|
+
break;
|
|
570
|
+
case 4:
|
|
571
|
+
elements.push(<h4 key={key++}>{inlineContent}</h4>);
|
|
572
|
+
break;
|
|
573
|
+
case 5:
|
|
574
|
+
elements.push(<h5 key={key++}>{inlineContent}</h5>);
|
|
575
|
+
break;
|
|
576
|
+
case 6:
|
|
577
|
+
elements.push(<h6 key={key++}>{inlineContent}</h6>);
|
|
578
|
+
break;
|
|
657
579
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
// Sort by creation date, newest first
|
|
661
|
-
return orphans.sort((a, b) => b.createdAt - a.createdAt);
|
|
662
|
-
} catch (error) {
|
|
663
|
-
console.error('Error fetching orphan stories:', error);
|
|
664
|
-
return [];
|
|
665
|
-
}
|
|
666
|
-
};
|
|
667
|
-
|
|
668
|
-
// Component styles
|
|
669
|
-
const STYLES = {
|
|
670
|
-
container: {
|
|
671
|
-
display: 'flex',
|
|
672
|
-
flexDirection: 'row' as const,
|
|
673
|
-
fontFamily: '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif',
|
|
674
|
-
height: '100vh',
|
|
675
|
-
overflow: 'hidden',
|
|
676
|
-
background: 'linear-gradient(135deg, #1e293b 0%, #334155 100%)',
|
|
677
|
-
color: '#e2e8f0',
|
|
678
|
-
fontSize: '14px',
|
|
679
|
-
lineHeight: '1.5',
|
|
680
|
-
},
|
|
681
|
-
|
|
682
|
-
// Sidebar
|
|
683
|
-
sidebar: {
|
|
684
|
-
width: '240px',
|
|
685
|
-
background: 'rgba(255, 255, 255, 0.03)',
|
|
686
|
-
borderRight: '1px solid rgba(255, 255, 255, 0.08)',
|
|
687
|
-
display: 'flex',
|
|
688
|
-
flexDirection: 'column' as const,
|
|
689
|
-
backdropFilter: 'blur(10px)',
|
|
690
|
-
transition: 'width 0.3s ease',
|
|
691
|
-
position: 'relative' as const,
|
|
692
|
-
},
|
|
693
|
-
|
|
694
|
-
sidebarCollapsed: {
|
|
695
|
-
width: '56px',
|
|
696
|
-
},
|
|
697
|
-
|
|
698
|
-
sidebarToggle: {
|
|
699
|
-
width: '100%',
|
|
700
|
-
padding: '10px 14px',
|
|
701
|
-
background: 'rgba(59, 130, 246, 0.15)',
|
|
702
|
-
color: '#e2e8f0',
|
|
703
|
-
border: '1px solid rgba(59, 130, 246, 0.3)',
|
|
704
|
-
borderRadius: '8px',
|
|
705
|
-
fontSize: '14px',
|
|
706
|
-
fontWeight: '600',
|
|
707
|
-
cursor: 'pointer',
|
|
708
|
-
marginBottom: '8px',
|
|
709
|
-
transition: 'all 0.2s ease',
|
|
710
|
-
boxShadow: 'none',
|
|
711
|
-
display: 'flex',
|
|
712
|
-
alignItems: 'center',
|
|
713
|
-
justifyContent: 'flex-start',
|
|
714
|
-
gap: '10px',
|
|
715
|
-
lineHeight: '1',
|
|
716
|
-
},
|
|
717
|
-
|
|
718
|
-
newChatButton: {
|
|
719
|
-
width: '100%',
|
|
720
|
-
padding: '10px 14px',
|
|
721
|
-
background: '#3b82f6',
|
|
722
|
-
color: 'white',
|
|
723
|
-
border: 'none',
|
|
724
|
-
borderRadius: '8px',
|
|
725
|
-
fontSize: '14px',
|
|
726
|
-
fontWeight: '600',
|
|
727
|
-
cursor: 'pointer',
|
|
728
|
-
marginBottom: '16px',
|
|
729
|
-
transition: 'all 0.2s ease',
|
|
730
|
-
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.25)',
|
|
731
|
-
display: 'flex',
|
|
732
|
-
alignItems: 'center',
|
|
733
|
-
justifyContent: 'flex-start',
|
|
734
|
-
gap: '10px',
|
|
735
|
-
lineHeight: '1',
|
|
736
|
-
},
|
|
737
|
-
|
|
738
|
-
chatItem: {
|
|
739
|
-
padding: '8px 12px',
|
|
740
|
-
marginBottom: '4px',
|
|
741
|
-
background: 'rgba(255, 255, 255, 0.05)',
|
|
742
|
-
borderRadius: '6px',
|
|
743
|
-
cursor: 'pointer',
|
|
744
|
-
transition: 'all 0.15s ease',
|
|
745
|
-
position: 'relative' as const,
|
|
746
|
-
paddingRight: '32px',
|
|
747
|
-
},
|
|
748
|
-
|
|
749
|
-
chatItemActive: {
|
|
750
|
-
background: 'rgba(59, 130, 246, 0.15)',
|
|
751
|
-
borderLeft: '2px solid #3b82f6',
|
|
752
|
-
},
|
|
753
|
-
|
|
754
|
-
chatItemTitle: {
|
|
755
|
-
fontSize: '14px',
|
|
756
|
-
fontWeight: '500',
|
|
757
|
-
marginBottom: '2px',
|
|
758
|
-
whiteSpace: 'nowrap' as const,
|
|
759
|
-
overflow: 'hidden',
|
|
760
|
-
textOverflow: 'ellipsis',
|
|
761
|
-
},
|
|
762
|
-
|
|
763
|
-
chatItemTime: {
|
|
764
|
-
fontSize: '12px',
|
|
765
|
-
color: '#94a3b8',
|
|
766
|
-
},
|
|
767
|
-
|
|
768
|
-
deleteButton: {
|
|
769
|
-
position: 'absolute' as const,
|
|
770
|
-
right: '8px',
|
|
771
|
-
top: '50%',
|
|
772
|
-
transform: 'translateY(-50%)',
|
|
773
|
-
background: 'rgba(239, 68, 68, 0.8)',
|
|
774
|
-
color: 'white',
|
|
775
|
-
border: 'none',
|
|
776
|
-
borderRadius: '4px',
|
|
777
|
-
padding: '4px 8px',
|
|
778
|
-
fontSize: '12px',
|
|
779
|
-
cursor: 'pointer',
|
|
780
|
-
opacity: 0,
|
|
781
|
-
transition: 'opacity 0.2s ease',
|
|
782
|
-
},
|
|
783
|
-
|
|
784
|
-
// Main content
|
|
785
|
-
mainContent: {
|
|
786
|
-
flex: 1,
|
|
787
|
-
display: 'flex',
|
|
788
|
-
flexDirection: 'column' as const,
|
|
789
|
-
overflow: 'hidden',
|
|
790
|
-
},
|
|
791
|
-
|
|
792
|
-
chatHeader: {
|
|
793
|
-
padding: '12px 16px',
|
|
794
|
-
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
|
795
|
-
background: 'rgba(255, 255, 255, 0.03)',
|
|
796
|
-
backdropFilter: 'blur(10px)',
|
|
797
|
-
},
|
|
798
|
-
|
|
799
|
-
chatContainer: {
|
|
800
|
-
flex: 1,
|
|
801
|
-
padding: '16px',
|
|
802
|
-
overflowY: 'auto' as const,
|
|
803
|
-
scrollBehavior: 'smooth' as const,
|
|
804
|
-
},
|
|
805
|
-
|
|
806
|
-
emptyState: {
|
|
807
|
-
color: '#94a3b8',
|
|
808
|
-
textAlign: 'center' as const,
|
|
809
|
-
marginTop: '60px',
|
|
810
|
-
},
|
|
811
|
-
|
|
812
|
-
emptyStateTitle: {
|
|
813
|
-
fontSize: '15px',
|
|
814
|
-
fontWeight: '500',
|
|
815
|
-
marginBottom: '8px',
|
|
816
|
-
color: '#cbd5e1',
|
|
817
|
-
},
|
|
818
|
-
|
|
819
|
-
emptyStateSubtitle: {
|
|
820
|
-
fontSize: '13px',
|
|
821
|
-
color: '#64748b',
|
|
822
|
-
},
|
|
823
|
-
|
|
824
|
-
// Message bubbles
|
|
825
|
-
messageContainer: {
|
|
826
|
-
display: 'flex',
|
|
827
|
-
marginBottom: '8px',
|
|
828
|
-
},
|
|
829
|
-
|
|
830
|
-
userMessage: {
|
|
831
|
-
background: 'rgba(59, 130, 246, 0.12)',
|
|
832
|
-
color: '#e2e8f0',
|
|
833
|
-
borderRadius: '16px 16px 4px 16px',
|
|
834
|
-
padding: '10px 14px',
|
|
835
|
-
maxWidth: '85%',
|
|
836
|
-
marginLeft: 'auto',
|
|
837
|
-
fontSize: '14px',
|
|
838
|
-
lineHeight: '1.45',
|
|
839
|
-
boxShadow: 'none',
|
|
840
|
-
wordWrap: 'break-word' as const,
|
|
841
|
-
border: '1px solid rgba(59, 130, 246, 0.2)',
|
|
842
|
-
},
|
|
843
|
-
|
|
844
|
-
aiMessage: {
|
|
845
|
-
background: 'rgba(255, 255, 255, 0.95)',
|
|
846
|
-
color: '#1f2937',
|
|
847
|
-
borderRadius: '16px 16px 16px 4px',
|
|
848
|
-
padding: '10px 14px',
|
|
849
|
-
maxWidth: '90%',
|
|
850
|
-
fontSize: '14px',
|
|
851
|
-
lineHeight: '1.45',
|
|
852
|
-
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
|
|
853
|
-
border: '1px solid rgba(0, 0, 0, 0.08)',
|
|
854
|
-
wordWrap: 'break-word' as const,
|
|
855
|
-
whiteSpace: 'pre-wrap' as const,
|
|
856
|
-
},
|
|
857
|
-
|
|
858
|
-
loadingMessage: {
|
|
859
|
-
background: 'rgba(255, 255, 255, 0.95)',
|
|
860
|
-
color: '#4b5563',
|
|
861
|
-
borderRadius: '16px 16px 16px 4px',
|
|
862
|
-
padding: '10px 14px',
|
|
863
|
-
fontSize: '14px',
|
|
864
|
-
lineHeight: '1.45',
|
|
865
|
-
display: 'flex',
|
|
866
|
-
alignItems: 'center',
|
|
867
|
-
gap: '8px',
|
|
868
|
-
border: '1px solid rgba(0, 0, 0, 0.08)',
|
|
869
|
-
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
|
|
870
|
-
},
|
|
871
|
-
|
|
872
|
-
// Input form
|
|
873
|
-
inputForm: {
|
|
874
|
-
display: 'flex',
|
|
875
|
-
alignItems: 'center',
|
|
876
|
-
gap: '12px',
|
|
877
|
-
margin: '0 16px 16px 16px',
|
|
878
|
-
padding: '12px',
|
|
879
|
-
background: 'rgba(255, 255, 255, 0.03)',
|
|
880
|
-
borderRadius: '12px',
|
|
881
|
-
border: '1px solid rgba(255, 255, 255, 0.08)',
|
|
882
|
-
backdropFilter: 'blur(10px)',
|
|
883
|
-
},
|
|
884
|
-
|
|
885
|
-
textInput: {
|
|
886
|
-
font: 'inherit',
|
|
887
|
-
flex: 1,
|
|
888
|
-
padding: '12px 16px',
|
|
889
|
-
borderRadius: '8px',
|
|
890
|
-
border: '1px solid rgba(255, 255, 255, 0.15)',
|
|
891
|
-
fontSize: '13px',
|
|
892
|
-
color: '#1f2937',
|
|
893
|
-
background: '#ffffff',
|
|
894
|
-
outline: 'none',
|
|
895
|
-
transition: 'all 0.15s ease',
|
|
896
|
-
boxSizing: 'border-box' as const,
|
|
897
|
-
},
|
|
898
|
-
|
|
899
|
-
sendButton: {
|
|
900
|
-
font: 'inherit',
|
|
901
|
-
padding: '10px 16px',
|
|
902
|
-
borderRadius: '10px',
|
|
903
|
-
border: 'none',
|
|
904
|
-
background: '#3b82f6',
|
|
905
|
-
color: 'white',
|
|
906
|
-
fontSize: '14px',
|
|
907
|
-
fontWeight: '600',
|
|
908
|
-
cursor: 'pointer',
|
|
909
|
-
display: 'flex',
|
|
910
|
-
alignItems: 'center',
|
|
911
|
-
gap: '6px',
|
|
912
|
-
transition: 'all 0.2s ease',
|
|
913
|
-
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.35)',
|
|
914
|
-
flexShrink: 0,
|
|
915
|
-
},
|
|
916
|
-
|
|
917
|
-
errorMessage: {
|
|
918
|
-
background: 'rgba(248, 113, 113, 0.1)',
|
|
919
|
-
color: '#f87171',
|
|
920
|
-
padding: '8px 12px',
|
|
921
|
-
borderRadius: '6px',
|
|
922
|
-
fontSize: '13px',
|
|
923
|
-
marginBottom: '8px',
|
|
924
|
-
border: '1px solid rgba(248, 113, 113, 0.2)',
|
|
925
|
-
},
|
|
926
|
-
|
|
927
|
-
loadingDots: {
|
|
928
|
-
display: 'inline-block',
|
|
929
|
-
animation: 'loadingDots 1.4s infinite',
|
|
930
|
-
},
|
|
931
|
-
|
|
932
|
-
'@keyframes loadingDots': {
|
|
933
|
-
'0%': { content: '""' },
|
|
934
|
-
'25%': { content: '"."' },
|
|
935
|
-
'50%': { content: '".."' },
|
|
936
|
-
'75%': { content: '"..."' },
|
|
937
|
-
},
|
|
938
|
-
|
|
939
|
-
codeBlock: {
|
|
940
|
-
background: '#1e293b',
|
|
941
|
-
padding: '12px',
|
|
942
|
-
borderRadius: '8px',
|
|
943
|
-
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
|
|
944
|
-
fontSize: '12px',
|
|
945
|
-
lineHeight: '1.5',
|
|
946
|
-
overflowX: 'auto' as const,
|
|
947
|
-
marginTop: '8px',
|
|
948
|
-
border: '1px solid rgba(255, 255, 255, 0.08)',
|
|
949
|
-
},
|
|
950
|
-
|
|
951
|
-
// Streaming progress styles
|
|
952
|
-
streamingContainer: {
|
|
953
|
-
background: 'rgba(255, 255, 255, 0.95)',
|
|
954
|
-
borderRadius: '16px 16px 16px 4px',
|
|
955
|
-
padding: '10px 14px',
|
|
956
|
-
maxWidth: '90%',
|
|
957
|
-
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
|
|
958
|
-
border: '1px solid rgba(0, 0, 0, 0.08)',
|
|
959
|
-
fontSize: '14px',
|
|
960
|
-
lineHeight: '1.45',
|
|
961
|
-
},
|
|
962
|
-
|
|
963
|
-
intentPreview: {
|
|
964
|
-
background: 'rgba(59, 130, 246, 0.06)',
|
|
965
|
-
borderRadius: '8px',
|
|
966
|
-
padding: '10px 12px',
|
|
967
|
-
marginBottom: '10px',
|
|
968
|
-
border: '1px solid rgba(59, 130, 246, 0.12)',
|
|
969
|
-
},
|
|
970
|
-
|
|
971
|
-
intentTitle: {
|
|
972
|
-
fontSize: '13px',
|
|
973
|
-
fontWeight: '600',
|
|
974
|
-
color: '#1e40af',
|
|
975
|
-
marginBottom: '8px',
|
|
976
|
-
display: 'flex',
|
|
977
|
-
alignItems: 'center',
|
|
978
|
-
gap: '4px',
|
|
979
|
-
},
|
|
980
|
-
|
|
981
|
-
intentStrategy: {
|
|
982
|
-
fontSize: '12px',
|
|
983
|
-
color: '#4b5563',
|
|
984
|
-
marginBottom: '4px',
|
|
985
|
-
},
|
|
986
|
-
|
|
987
|
-
intentComponents: {
|
|
988
|
-
display: 'flex',
|
|
989
|
-
flexWrap: 'wrap' as const,
|
|
990
|
-
gap: '4px',
|
|
991
|
-
marginTop: '8px',
|
|
992
|
-
},
|
|
993
|
-
|
|
994
|
-
componentTag: {
|
|
995
|
-
background: 'rgba(59, 130, 246, 0.12)',
|
|
996
|
-
color: '#1d4ed8',
|
|
997
|
-
fontSize: '11px',
|
|
998
|
-
padding: '2px 8px',
|
|
999
|
-
borderRadius: '10px',
|
|
1000
|
-
fontWeight: '500',
|
|
1001
|
-
},
|
|
1002
|
-
|
|
1003
|
-
progressBar: {
|
|
1004
|
-
background: 'rgba(0, 0, 0, 0.08)',
|
|
1005
|
-
borderRadius: '4px',
|
|
1006
|
-
height: '4px',
|
|
1007
|
-
marginTop: '12px',
|
|
1008
|
-
marginBottom: '8px',
|
|
1009
|
-
overflow: 'hidden',
|
|
1010
|
-
},
|
|
1011
|
-
|
|
1012
|
-
progressFill: {
|
|
1013
|
-
background: '#3b82f6',
|
|
1014
|
-
height: '100%',
|
|
1015
|
-
borderRadius: '3px',
|
|
1016
|
-
transition: 'width 0.3s ease',
|
|
1017
|
-
},
|
|
1018
|
-
|
|
1019
|
-
progressPhase: {
|
|
1020
|
-
fontSize: '14px',
|
|
1021
|
-
color: '#4b5563',
|
|
1022
|
-
display: 'flex',
|
|
1023
|
-
alignItems: 'center',
|
|
1024
|
-
gap: '6px',
|
|
1025
|
-
fontWeight: '500',
|
|
1026
|
-
lineHeight: '1.45',
|
|
1027
|
-
},
|
|
1028
|
-
|
|
1029
|
-
phaseIcon: {
|
|
1030
|
-
fontSize: '14px',
|
|
1031
|
-
},
|
|
1032
|
-
|
|
1033
|
-
validationBox: {
|
|
1034
|
-
marginTop: '8px',
|
|
1035
|
-
padding: '8px',
|
|
1036
|
-
borderRadius: '6px',
|
|
1037
|
-
fontSize: '11px',
|
|
1038
|
-
},
|
|
1039
|
-
|
|
1040
|
-
validationSuccess: {
|
|
1041
|
-
background: 'rgba(16, 185, 129, 0.08)',
|
|
1042
|
-
border: '1px solid rgba(16, 185, 129, 0.15)',
|
|
1043
|
-
color: '#047857',
|
|
1044
|
-
},
|
|
1045
|
-
|
|
1046
|
-
validationWarning: {
|
|
1047
|
-
background: 'rgba(245, 158, 11, 0.08)',
|
|
1048
|
-
border: '1px solid rgba(245, 158, 11, 0.15)',
|
|
1049
|
-
color: '#b45309',
|
|
1050
|
-
},
|
|
1051
|
-
|
|
1052
|
-
validationError: {
|
|
1053
|
-
background: 'rgba(239, 68, 68, 0.08)',
|
|
1054
|
-
border: '1px solid rgba(239, 68, 68, 0.15)',
|
|
1055
|
-
color: '#dc2626',
|
|
1056
|
-
},
|
|
1057
|
-
|
|
1058
|
-
retryBadge: {
|
|
1059
|
-
background: 'rgba(245, 158, 11, 0.12)',
|
|
1060
|
-
color: '#b45309',
|
|
1061
|
-
fontSize: '11px',
|
|
1062
|
-
padding: '2px 8px',
|
|
1063
|
-
borderRadius: '10px',
|
|
1064
|
-
display: 'inline-flex',
|
|
1065
|
-
alignItems: 'center',
|
|
1066
|
-
gap: '4px',
|
|
1067
|
-
marginTop: '8px',
|
|
1068
|
-
},
|
|
1069
|
-
|
|
1070
|
-
completionSummary: {
|
|
1071
|
-
marginTop: '10px',
|
|
1072
|
-
paddingTop: '10px',
|
|
1073
|
-
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
|
|
1074
|
-
},
|
|
1075
|
-
|
|
1076
|
-
summaryTitle: {
|
|
1077
|
-
fontSize: '14px',
|
|
1078
|
-
fontWeight: '600',
|
|
1079
|
-
color: '#111827',
|
|
1080
|
-
marginBottom: '6px',
|
|
1081
|
-
display: 'flex',
|
|
1082
|
-
alignItems: 'center',
|
|
1083
|
-
gap: '6px',
|
|
1084
|
-
lineHeight: '1.45',
|
|
1085
|
-
},
|
|
1086
|
-
|
|
1087
|
-
summaryDescription: {
|
|
1088
|
-
fontSize: '14px',
|
|
1089
|
-
color: '#4b5563',
|
|
1090
|
-
lineHeight: '1.45',
|
|
1091
|
-
},
|
|
1092
|
-
|
|
1093
|
-
metricsRow: {
|
|
1094
|
-
display: 'flex',
|
|
1095
|
-
gap: '10px',
|
|
1096
|
-
marginTop: '6px',
|
|
1097
|
-
fontSize: '13px',
|
|
1098
|
-
color: '#6b7280',
|
|
1099
|
-
},
|
|
1100
|
-
|
|
1101
|
-
metric: {
|
|
1102
|
-
display: 'flex',
|
|
1103
|
-
alignItems: 'center',
|
|
1104
|
-
gap: '4px',
|
|
1105
|
-
},
|
|
1106
|
-
|
|
1107
|
-
// Code viewer styles for generated stories
|
|
1108
|
-
codeViewerContainer: {
|
|
1109
|
-
marginTop: '12px',
|
|
1110
|
-
borderTop: '1px solid rgba(0, 0, 0, 0.08)',
|
|
1111
|
-
paddingTop: '12px',
|
|
1112
|
-
},
|
|
1113
|
-
|
|
1114
|
-
codeViewerToggle: {
|
|
1115
|
-
display: 'flex',
|
|
1116
|
-
alignItems: 'center',
|
|
1117
|
-
justifyContent: 'space-between',
|
|
1118
|
-
padding: '8px 12px',
|
|
1119
|
-
background: 'rgba(59, 130, 246, 0.08)',
|
|
1120
|
-
borderRadius: '6px',
|
|
1121
|
-
cursor: 'pointer',
|
|
1122
|
-
border: '1px solid rgba(59, 130, 246, 0.15)',
|
|
1123
|
-
transition: 'all 0.2s ease',
|
|
1124
|
-
fontSize: '13px',
|
|
1125
|
-
fontWeight: '500',
|
|
1126
|
-
color: '#1e40af',
|
|
1127
|
-
},
|
|
1128
|
-
|
|
1129
|
-
codeViewerToggleHover: {
|
|
1130
|
-
background: 'rgba(59, 130, 246, 0.15)',
|
|
1131
|
-
},
|
|
1132
|
-
|
|
1133
|
-
codeViewerContent: {
|
|
1134
|
-
marginTop: '12px',
|
|
1135
|
-
background: '#1e293b',
|
|
1136
|
-
borderRadius: '8px',
|
|
1137
|
-
overflow: 'hidden',
|
|
1138
|
-
border: '1px solid rgba(255, 255, 255, 0.08)',
|
|
1139
|
-
},
|
|
1140
|
-
|
|
1141
|
-
codeViewerHeader: {
|
|
1142
|
-
display: 'flex',
|
|
1143
|
-
alignItems: 'center',
|
|
1144
|
-
justifyContent: 'space-between',
|
|
1145
|
-
padding: '8px 12px',
|
|
1146
|
-
background: 'rgba(0, 0, 0, 0.2)',
|
|
1147
|
-
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
|
1148
|
-
},
|
|
1149
|
-
|
|
1150
|
-
codeViewerFileName: {
|
|
1151
|
-
fontSize: '12px',
|
|
1152
|
-
color: '#94a3b8',
|
|
1153
|
-
fontFamily: 'ui-monospace, monospace',
|
|
1154
|
-
},
|
|
1155
|
-
|
|
1156
|
-
copyButton: {
|
|
1157
|
-
padding: '4px 12px',
|
|
1158
|
-
fontSize: '11px',
|
|
1159
|
-
fontWeight: '500',
|
|
1160
|
-
color: '#e2e8f0',
|
|
1161
|
-
background: 'rgba(59, 130, 246, 0.3)',
|
|
1162
|
-
border: '1px solid rgba(59, 130, 246, 0.5)',
|
|
1163
|
-
borderRadius: '4px',
|
|
1164
|
-
cursor: 'pointer',
|
|
1165
|
-
transition: 'all 0.2s ease',
|
|
1166
|
-
},
|
|
1167
|
-
|
|
1168
|
-
copyButtonSuccess: {
|
|
1169
|
-
background: 'rgba(34, 197, 94, 0.3)',
|
|
1170
|
-
borderColor: 'rgba(34, 197, 94, 0.5)',
|
|
1171
|
-
color: '#86efac',
|
|
1172
|
-
},
|
|
1173
|
-
|
|
1174
|
-
codeViewerPre: {
|
|
1175
|
-
margin: 0,
|
|
1176
|
-
padding: '12px',
|
|
1177
|
-
fontSize: '11px',
|
|
1178
|
-
lineHeight: '1.5',
|
|
1179
|
-
fontFamily: 'ui-monospace, Consolas, Monaco, monospace',
|
|
1180
|
-
color: '#e2e8f0',
|
|
1181
|
-
overflowX: 'auto' as const,
|
|
1182
|
-
maxHeight: '400px',
|
|
1183
|
-
overflowY: 'auto' as const,
|
|
1184
|
-
},
|
|
1185
|
-
|
|
1186
|
-
// Image upload styles
|
|
1187
|
-
uploadButton: {
|
|
1188
|
-
display: 'flex',
|
|
1189
|
-
alignItems: 'center',
|
|
1190
|
-
justifyContent: 'center',
|
|
1191
|
-
width: '36px',
|
|
1192
|
-
height: '36px',
|
|
1193
|
-
borderRadius: '6px',
|
|
1194
|
-
border: '1px solid rgba(255, 255, 255, 0.15)',
|
|
1195
|
-
background: 'rgba(255, 255, 255, 0.08)',
|
|
1196
|
-
color: '#e2e8f0',
|
|
1197
|
-
cursor: 'pointer',
|
|
1198
|
-
transition: 'all 0.2s ease',
|
|
1199
|
-
flexShrink: 0,
|
|
1200
|
-
},
|
|
1201
|
-
|
|
1202
|
-
uploadButtonHover: {
|
|
1203
|
-
background: 'rgba(59, 130, 246, 0.2)',
|
|
1204
|
-
borderColor: 'rgba(59, 130, 246, 0.5)',
|
|
1205
|
-
},
|
|
1206
|
-
|
|
1207
|
-
imagePreviewContainer: {
|
|
1208
|
-
display: 'flex',
|
|
1209
|
-
flexWrap: 'wrap' as const,
|
|
1210
|
-
gap: '8px',
|
|
1211
|
-
padding: '8px 12px',
|
|
1212
|
-
background: 'rgba(255, 255, 255, 0.03)',
|
|
1213
|
-
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
|
1214
|
-
margin: '0 16px',
|
|
1215
|
-
borderRadius: '8px 8px 0 0',
|
|
1216
|
-
},
|
|
1217
|
-
|
|
1218
|
-
imagePreviewItem: {
|
|
1219
|
-
position: 'relative' as const,
|
|
1220
|
-
width: '56px',
|
|
1221
|
-
height: '56px',
|
|
1222
|
-
borderRadius: '6px',
|
|
1223
|
-
overflow: 'hidden',
|
|
1224
|
-
border: '1px solid rgba(255, 255, 255, 0.15)',
|
|
1225
|
-
background: '#1e293b',
|
|
1226
|
-
},
|
|
1227
|
-
|
|
1228
|
-
imagePreviewImg: {
|
|
1229
|
-
width: '100%',
|
|
1230
|
-
height: '100%',
|
|
1231
|
-
objectFit: 'cover' as const,
|
|
1232
|
-
},
|
|
1233
|
-
|
|
1234
|
-
imageRemoveButton: {
|
|
1235
|
-
position: 'absolute' as const,
|
|
1236
|
-
top: '2px',
|
|
1237
|
-
right: '2px',
|
|
1238
|
-
width: '18px',
|
|
1239
|
-
height: '18px',
|
|
1240
|
-
borderRadius: '50%',
|
|
1241
|
-
background: 'rgba(239, 68, 68, 0.9)',
|
|
1242
|
-
color: 'white',
|
|
1243
|
-
border: 'none',
|
|
1244
|
-
fontSize: '12px',
|
|
1245
|
-
cursor: 'pointer',
|
|
1246
|
-
display: 'flex',
|
|
1247
|
-
alignItems: 'center',
|
|
1248
|
-
justifyContent: 'center',
|
|
1249
|
-
lineHeight: 1,
|
|
1250
|
-
},
|
|
1251
|
-
|
|
1252
|
-
imagePreviewLabel: {
|
|
1253
|
-
display: 'flex',
|
|
1254
|
-
alignItems: 'center',
|
|
1255
|
-
gap: '8px',
|
|
1256
|
-
fontSize: '12px',
|
|
1257
|
-
color: '#94a3b8',
|
|
1258
|
-
marginRight: 'auto',
|
|
1259
|
-
},
|
|
1260
|
-
|
|
1261
|
-
userMessageImages: {
|
|
1262
|
-
display: 'flex',
|
|
1263
|
-
gap: '8px',
|
|
1264
|
-
marginTop: '8px',
|
|
1265
|
-
flexWrap: 'wrap' as const,
|
|
1266
|
-
},
|
|
1267
|
-
|
|
1268
|
-
userMessageImage: {
|
|
1269
|
-
width: '40px',
|
|
1270
|
-
height: '40px',
|
|
1271
|
-
borderRadius: '6px',
|
|
1272
|
-
objectFit: 'cover' as const,
|
|
1273
|
-
border: '1px solid rgba(255, 255, 255, 0.25)',
|
|
1274
|
-
},
|
|
1275
|
-
|
|
1276
|
-
// Drag and drop overlay
|
|
1277
|
-
dropOverlay: {
|
|
1278
|
-
position: 'absolute' as const,
|
|
1279
|
-
top: 0,
|
|
1280
|
-
left: 0,
|
|
1281
|
-
right: 0,
|
|
1282
|
-
bottom: 0,
|
|
1283
|
-
background: 'rgba(59, 130, 246, 0.12)',
|
|
1284
|
-
border: '2px dashed rgba(59, 130, 246, 0.4)',
|
|
1285
|
-
borderRadius: '10px',
|
|
1286
|
-
display: 'flex',
|
|
1287
|
-
alignItems: 'center',
|
|
1288
|
-
justifyContent: 'center',
|
|
1289
|
-
zIndex: 100,
|
|
1290
|
-
backdropFilter: 'blur(3px)',
|
|
1291
|
-
},
|
|
1292
|
-
|
|
1293
|
-
dropOverlayText: {
|
|
1294
|
-
background: 'rgba(59, 130, 246, 0.85)',
|
|
1295
|
-
color: 'white',
|
|
1296
|
-
padding: '12px 24px',
|
|
1297
|
-
borderRadius: '8px',
|
|
1298
|
-
fontSize: '14px',
|
|
1299
|
-
fontWeight: '500',
|
|
1300
|
-
display: 'flex',
|
|
1301
|
-
alignItems: 'center',
|
|
1302
|
-
gap: '8px',
|
|
1303
|
-
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
|
1304
|
-
},
|
|
1305
|
-
};
|
|
1306
|
-
|
|
1307
|
-
// Add custom style for loading animation and IBM Plex Sans font
|
|
1308
|
-
// Use a unique ID to prevent duplicate stylesheets during HMR
|
|
1309
|
-
const STYLESHEET_ID = 'story-ui-panel-styles';
|
|
1310
|
-
if (!document.getElementById(STYLESHEET_ID)) {
|
|
1311
|
-
// Load IBM Plex Sans font
|
|
1312
|
-
const fontLink = document.createElement('link');
|
|
1313
|
-
fontLink.rel = 'stylesheet';
|
|
1314
|
-
fontLink.href = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap';
|
|
1315
|
-
document.head.appendChild(fontLink);
|
|
1316
|
-
|
|
1317
|
-
const styleSheet = document.createElement('style');
|
|
1318
|
-
styleSheet.id = STYLESHEET_ID;
|
|
1319
|
-
styleSheet.textContent = `
|
|
1320
|
-
@keyframes loadingDots {
|
|
1321
|
-
0%, 20% { content: "."; }
|
|
1322
|
-
40% { content: ".."; }
|
|
1323
|
-
60%, 100% { content: "..."; }
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
.loading-dots::after {
|
|
1327
|
-
content: ".";
|
|
1328
|
-
animation: loadingDots 1.4s infinite;
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
/* Override Storybook's default styles with !important */
|
|
1332
|
-
.story-ui-panel,
|
|
1333
|
-
.story-ui-panel * {
|
|
1334
|
-
font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
.story-ui-panel {
|
|
1338
|
-
font-size: 14px !important;
|
|
1339
|
-
line-height: 1.5 !important;
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
/* Message bubbles - consistent styling */
|
|
1343
|
-
.story-ui-message {
|
|
1344
|
-
font-size: 16px !important;
|
|
1345
|
-
line-height: 1.45 !important;
|
|
1346
|
-
padding: 12px 16px !important;
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
.story-ui-user-message {
|
|
1350
|
-
background: rgba(59, 130, 246, 0.12) !important;
|
|
1351
|
-
color: #e2e8f0 !important;
|
|
1352
|
-
border-radius: 18px 18px 4px 18px !important;
|
|
1353
|
-
border: 1px solid rgba(59, 130, 246, 0.2) !important;
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
.story-ui-ai-message {
|
|
1357
|
-
background: rgba(255, 255, 255, 0.97) !important;
|
|
1358
|
-
color: #1f2937 !important;
|
|
1359
|
-
border-radius: 18px 18px 18px 4px !important;
|
|
1360
|
-
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
|
1361
|
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important;
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
/* Override nested elements in AI messages (from renderMarkdown)
|
|
1365
|
-
.story-ui-ai-message p,
|
|
1366
|
-
.story-ui-ai-message span,
|
|
1367
|
-
.story-ui-ai-message strong,
|
|
1368
|
-
.story-ui-ai-message em,
|
|
1369
|
-
.story-ui-ai-message li,
|
|
1370
|
-
.story-ui-ai-message ul,
|
|
1371
|
-
.story-ui-ai-message ol {
|
|
1372
|
-
font-size: 14px !important;
|
|
1373
|
-
line-height: 1.45 !important;
|
|
1374
|
-
margin: 0 !important;
|
|
1375
|
-
} */
|
|
1376
|
-
|
|
1377
|
-
.story-ui-ai-message p + p {
|
|
1378
|
-
margin-top: 8px !important;
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
/* Status text */
|
|
1382
|
-
.story-ui-status {
|
|
1383
|
-
font-size: 13px !important;
|
|
1384
|
-
font-weight: 400 !important;
|
|
580
|
+
return;
|
|
1385
581
|
}
|
|
1386
582
|
|
|
1387
|
-
.
|
|
1388
|
-
|
|
583
|
+
// Check for ordered lists (1. 2. 3.)
|
|
584
|
+
const orderedListMatch = block.match(/^(\d+\.\s+.+)$/m);
|
|
585
|
+
if (orderedListMatch) {
|
|
586
|
+
const items = block.split('\n').filter(line => /^\d+\.\s+/.test(line));
|
|
587
|
+
const listItems = items.map((item, i) => {
|
|
588
|
+
const text = item.replace(/^\d+\.\s+/, '');
|
|
589
|
+
return <li key={i}>{parseInline(text)}</li>;
|
|
590
|
+
});
|
|
591
|
+
elements.push(<ol key={key++}>{listItems}</ol>);
|
|
592
|
+
return;
|
|
1389
593
|
}
|
|
1390
594
|
|
|
1391
|
-
|
|
1392
|
-
|
|
595
|
+
// Check for unordered lists (- or *)
|
|
596
|
+
const unorderedListMatch = block.match(/^[-*]\s+.+$/m);
|
|
597
|
+
if (unorderedListMatch) {
|
|
598
|
+
const items = block.split('\n').filter(line => /^[-*]\s+/.test(line));
|
|
599
|
+
const listItems = items.map((item, i) => {
|
|
600
|
+
const text = item.replace(/^[-*]\s+/, '');
|
|
601
|
+
return <li key={i}>{parseInline(text)}</li>;
|
|
602
|
+
});
|
|
603
|
+
elements.push(<ul key={key++}>{listItems}</ul>);
|
|
604
|
+
return;
|
|
1393
605
|
}
|
|
1394
606
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
607
|
+
// Regular paragraph with line breaks preserved
|
|
608
|
+
const lines = block.split('\n');
|
|
609
|
+
const paragraphElements = lines.map((line, i) => (
|
|
610
|
+
<React.Fragment key={i}>
|
|
611
|
+
{parseInline(line)}
|
|
612
|
+
{i < lines.length - 1 && <br />}
|
|
613
|
+
</React.Fragment>
|
|
614
|
+
));
|
|
615
|
+
elements.push(<p key={key++}>{paragraphElements}</p>);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
return <div className="sui-markdown">{elements}</div>;
|
|
619
|
+
}
|
|
1400
620
|
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
621
|
+
// Parse inline markdown elements and status icons
|
|
622
|
+
function parseInline(text: string): React.ReactNode[] {
|
|
623
|
+
const parts: React.ReactNode[] = [];
|
|
624
|
+
let remaining = text;
|
|
625
|
+
|
|
626
|
+
// Replace status markers with icon components
|
|
627
|
+
// Use {{ICON:n}} format to avoid conflict with markdown underscore patterns
|
|
628
|
+
const iconReplacements = [
|
|
629
|
+
{ pattern: /\[SUCCESS\]/g, index: 0, icon: <span key="icon-0" className="sui-icon-inline sui-icon-success" aria-label="Success">{Icons.checkCircle}</span> },
|
|
630
|
+
{ pattern: /\[ERROR\]/g, index: 1, icon: <span key="icon-1" className="sui-icon-inline sui-icon-error" aria-label="Error">{Icons.xCircle}</span> },
|
|
631
|
+
{ pattern: /\[TIP\]/g, index: 2, icon: <span key="icon-2" className="sui-icon-inline sui-icon-tip" aria-label="Tip">{Icons.lightbulb}</span> },
|
|
632
|
+
{ pattern: /\[WRENCH\]/g, index: 3, icon: <span key="icon-3" className="sui-icon-inline sui-icon-wrench" aria-label="Auto-fixed">{Icons.wrench}</span> },
|
|
633
|
+
];
|
|
634
|
+
|
|
635
|
+
iconReplacements.forEach(({ pattern, index, icon }) => {
|
|
636
|
+
remaining = remaining.replace(pattern, `{{ICON:${index}}}`);
|
|
637
|
+
parts[index] = icon;
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// Parse bold, code, italic, and icon placeholders
|
|
641
|
+
// Icon placeholder {{ICON:n}} uses curly braces to avoid markdown conflicts
|
|
642
|
+
const regex = /(\*\*[^*]+\*\*|`[^`]+`|_[^_]+_|\{\{ICON:\d+\}\})/g;
|
|
643
|
+
const tokens = remaining.split(regex);
|
|
644
|
+
|
|
645
|
+
return tokens.map((token, i) => {
|
|
646
|
+
if (token.startsWith('**') && token.endsWith('**')) {
|
|
647
|
+
return <strong key={`inline-${i}`}>{token.slice(2, -2)}</strong>;
|
|
1405
648
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
font-size: 14px !important;
|
|
1409
|
-
color: #94a3b8 !important;
|
|
649
|
+
if (token.startsWith('`') && token.endsWith('`')) {
|
|
650
|
+
return <code key={`inline-${i}`}>{token.slice(1, -1)}</code>;
|
|
1410
651
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
.story-ui-input {
|
|
1414
|
-
font-size: 14px !important;
|
|
1415
|
-
font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
|
652
|
+
if (token.startsWith('_') && token.endsWith('_') && !token.startsWith('{{')) {
|
|
653
|
+
return <em key={`inline-${i}`}>{token.slice(1, -1)}</em>;
|
|
1416
654
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace !important;
|
|
1421
|
-
font-size: 13px !important;
|
|
1422
|
-
background: rgba(0, 0, 0, 0.06) !important;
|
|
1423
|
-
padding: 2px 6px !important;
|
|
1424
|
-
border-radius: 4px !important;
|
|
655
|
+
if (token.startsWith('{{ICON:')) {
|
|
656
|
+
const iconIndex = parseInt(token.match(/\{\{ICON:(\d+)\}\}/)?.[1] || '0');
|
|
657
|
+
return parts[iconIndex] || token;
|
|
1425
658
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
659
|
+
return token;
|
|
660
|
+
}).filter(Boolean);
|
|
1428
661
|
}
|
|
1429
662
|
|
|
1430
|
-
//
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
if (!timestamp || isNaN(timestamp) || timestamp <= 0) {
|
|
1434
|
-
return '';
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
const date = new Date(timestamp);
|
|
1438
|
-
|
|
1439
|
-
// Check if the date is valid
|
|
1440
|
-
if (isNaN(date.getTime())) {
|
|
1441
|
-
return '';
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
const now = new Date();
|
|
1445
|
-
const diffMs = now.getTime() - date.getTime();
|
|
1446
|
-
const diffMins = Math.floor(diffMs / 60000);
|
|
1447
|
-
const diffHours = Math.floor(diffMs / 3600000);
|
|
1448
|
-
const diffDays = Math.floor(diffMs / 86400000);
|
|
1449
|
-
|
|
1450
|
-
if (diffMins < 1) return 'just now';
|
|
1451
|
-
if (diffMins < 60) return `${diffMins}m ago`;
|
|
1452
|
-
if (diffHours < 24) return `${diffHours}h ago`;
|
|
1453
|
-
if (diffDays < 7) return `${diffDays}d ago`;
|
|
1454
|
-
return date.toLocaleDateString();
|
|
1455
|
-
};
|
|
663
|
+
// ============================================
|
|
664
|
+
// Sub-Components
|
|
665
|
+
// ============================================
|
|
1456
666
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
prompt_built: { text: 'Building prompt' },
|
|
1463
|
-
llm_thinking: { text: 'AI is thinking' },
|
|
1464
|
-
code_extracted: { text: 'Extracting code' },
|
|
1465
|
-
validating: { text: 'Validating output' },
|
|
1466
|
-
post_processing: { text: 'Processing' },
|
|
1467
|
-
saving: { text: 'Saving story' },
|
|
1468
|
-
};
|
|
1469
|
-
return phases[phase] || { text: 'Working' };
|
|
1470
|
-
};
|
|
667
|
+
interface BadgeProps {
|
|
668
|
+
variant?: 'default' | 'secondary' | 'success' | 'destructive' | 'outline';
|
|
669
|
+
children: React.ReactNode;
|
|
670
|
+
className?: string;
|
|
671
|
+
}
|
|
1471
672
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
const [showCode, setShowCode] = useState(true); // Show code by default for better UX
|
|
1476
|
-
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle');
|
|
673
|
+
const Badge: React.FC<BadgeProps> = ({ variant = 'default', children, className = '' }) => (
|
|
674
|
+
<span className={`sui-badge sui-badge-${variant} ${className}`}>{children}</span>
|
|
675
|
+
);
|
|
1477
676
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
await navigator.clipboard.writeText(code);
|
|
1482
|
-
setCopyStatus('copied');
|
|
1483
|
-
setTimeout(() => setCopyStatus('idle'), 2000);
|
|
1484
|
-
} catch (err) {
|
|
1485
|
-
console.error('Failed to copy:', err);
|
|
1486
|
-
}
|
|
1487
|
-
};
|
|
677
|
+
interface ProgressIndicatorProps {
|
|
678
|
+
streamingState: StreamingState;
|
|
679
|
+
}
|
|
1488
680
|
|
|
1489
|
-
|
|
1490
|
-
|
|
681
|
+
const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ streamingState }) => {
|
|
682
|
+
const { progress, retry, completion, error } = streamingState;
|
|
683
|
+
if (error) {
|
|
1491
684
|
return (
|
|
1492
|
-
<div
|
|
1493
|
-
<
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
</div>
|
|
1497
|
-
<div style={STYLES.summaryDescription}>
|
|
1498
|
-
{completion.summary.description}
|
|
1499
|
-
</div>
|
|
1500
|
-
|
|
1501
|
-
{/* Components Used */}
|
|
1502
|
-
{completion.componentsUsed.length > 0 && (
|
|
1503
|
-
<div style={{ marginTop: '12px' }}>
|
|
1504
|
-
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '8px' }}>Components used:</div>
|
|
1505
|
-
<div style={STYLES.intentComponents}>
|
|
1506
|
-
{completion.componentsUsed.map((comp, i) => (
|
|
1507
|
-
<span key={i} style={STYLES.componentTag}>{comp.name}</span>
|
|
1508
|
-
))}
|
|
1509
|
-
</div>
|
|
1510
|
-
</div>
|
|
1511
|
-
)}
|
|
1512
|
-
|
|
1513
|
-
{/* Layout Choices */}
|
|
1514
|
-
{completion.layoutChoices.length > 0 && (
|
|
1515
|
-
<div style={{ marginTop: '12px' }}>
|
|
1516
|
-
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '8px' }}>Layout:</div>
|
|
1517
|
-
<div style={{ fontSize: '12px', color: '#4b5563' }}>
|
|
1518
|
-
{completion.layoutChoices.map(l => l.pattern).join(', ')}
|
|
1519
|
-
</div>
|
|
1520
|
-
</div>
|
|
1521
|
-
)}
|
|
1522
|
-
|
|
1523
|
-
{/* Validation Status */}
|
|
1524
|
-
{completion.validation && !completion.validation.isValid && (
|
|
1525
|
-
<div style={{ ...STYLES.validationBox, ...STYLES.validationWarning }}>
|
|
1526
|
-
{completion.validation.autoFixApplied ? 'Auto-fixed issues' : 'Minor issues detected'}
|
|
1527
|
-
</div>
|
|
1528
|
-
)}
|
|
1529
|
-
|
|
1530
|
-
{/* Suggestions */}
|
|
1531
|
-
{completion.suggestions && completion.suggestions.length > 0 && (
|
|
1532
|
-
<div style={{ marginTop: '12px', fontSize: '12px', color: '#6b7280', display: 'flex', alignItems: 'flex-start', gap: '6px' }}>
|
|
1533
|
-
{StatusIcons.tip} <span>{completion.suggestions[0]}</span>
|
|
1534
|
-
</div>
|
|
1535
|
-
)}
|
|
1536
|
-
|
|
1537
|
-
{/* Metrics */}
|
|
1538
|
-
{completion.metrics && (
|
|
1539
|
-
<div style={STYLES.metricsRow}>
|
|
1540
|
-
<span style={STYLES.metric}>{(completion.metrics.totalTimeMs / 1000).toFixed(1)}s</span>
|
|
1541
|
-
<span style={STYLES.metric}>{completion.metrics.llmCallsCount} LLM calls</span>
|
|
1542
|
-
</div>
|
|
1543
|
-
)}
|
|
1544
|
-
|
|
1545
|
-
{/* Code Viewer - Show the generated story code */}
|
|
1546
|
-
{completion.code && (
|
|
1547
|
-
<div style={STYLES.codeViewerContainer}>
|
|
1548
|
-
<div
|
|
1549
|
-
style={STYLES.codeViewerToggle}
|
|
1550
|
-
onClick={() => setShowCode(!showCode)}
|
|
1551
|
-
role="button"
|
|
1552
|
-
tabIndex={0}
|
|
1553
|
-
onKeyDown={(e) => e.key === 'Enter' && setShowCode(!showCode)}
|
|
1554
|
-
>
|
|
1555
|
-
<span>{showCode ? '▼' : '▶'} View Generated Code</span>
|
|
1556
|
-
<span style={{ fontSize: '11px', color: '#6366f1' }}>{completion.fileName}</span>
|
|
1557
|
-
</div>
|
|
1558
|
-
{showCode && (
|
|
1559
|
-
<div style={STYLES.codeViewerContent}>
|
|
1560
|
-
<div style={STYLES.codeViewerHeader}>
|
|
1561
|
-
<span style={STYLES.codeViewerFileName}>{completion.fileName}</span>
|
|
1562
|
-
<button
|
|
1563
|
-
style={{
|
|
1564
|
-
...STYLES.copyButton,
|
|
1565
|
-
...(copyStatus === 'copied' ? STYLES.copyButtonSuccess : {})
|
|
1566
|
-
}}
|
|
1567
|
-
onClick={() => handleCopyCode(completion.code)}
|
|
1568
|
-
>
|
|
1569
|
-
{copyStatus === 'copied' ? 'Copied' : 'Copy'}
|
|
1570
|
-
</button>
|
|
1571
|
-
</div>
|
|
1572
|
-
<pre style={STYLES.codeViewerPre}>
|
|
1573
|
-
<code>{completion.code}</code>
|
|
1574
|
-
</pre>
|
|
1575
|
-
</div>
|
|
1576
|
-
)}
|
|
1577
|
-
</div>
|
|
1578
|
-
)}
|
|
1579
|
-
</div>
|
|
685
|
+
<div className="sui-error" role="alert">
|
|
686
|
+
<strong>{error.message}</strong>
|
|
687
|
+
{error.details && <div>{error.details}</div>}
|
|
688
|
+
{error.suggestion && <div>{error.suggestion}</div>}
|
|
1580
689
|
</div>
|
|
1581
690
|
);
|
|
1582
691
|
}
|
|
1583
|
-
|
|
1584
|
-
// If error, show error
|
|
1585
|
-
if (error) {
|
|
692
|
+
if (completion) {
|
|
1586
693
|
return (
|
|
1587
|
-
<div
|
|
1588
|
-
<div
|
|
1589
|
-
<
|
|
1590
|
-
{
|
|
1591
|
-
{error.suggestion && <div style={{ marginTop: '8px', display: 'flex', alignItems: 'flex-start', gap: '6px' }}>{StatusIcons.tip} <span>{error.suggestion}</span></div>}
|
|
694
|
+
<div className="sui-completion">
|
|
695
|
+
<div className="sui-completion-header">
|
|
696
|
+
<span>{completion.success ? '\u2705' : '\u274C'}</span>
|
|
697
|
+
<span>{completion.summary.action}: {completion.title}</span>
|
|
1592
698
|
</div>
|
|
699
|
+
{completion.componentsUsed.length > 0 && (
|
|
700
|
+
<div className="sui-completion-components">
|
|
701
|
+
{completion.componentsUsed.map((comp, i) => (
|
|
702
|
+
<span key={i} className="sui-completion-tag">{comp.name}</span>
|
|
703
|
+
))}
|
|
704
|
+
</div>
|
|
705
|
+
)}
|
|
706
|
+
{completion.metrics && (
|
|
707
|
+
<div className="sui-completion-metrics">
|
|
708
|
+
<span>{(completion.metrics.totalTimeMs / 1000).toFixed(1)}s</span>
|
|
709
|
+
<span>{completion.metrics.llmCallsCount} LLM calls</span>
|
|
710
|
+
</div>
|
|
711
|
+
)}
|
|
1593
712
|
</div>
|
|
1594
713
|
);
|
|
1595
714
|
}
|
|
1596
|
-
|
|
1597
|
-
// Show progress - simplified to just show status without verbose details
|
|
1598
715
|
return (
|
|
1599
|
-
<div
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
<
|
|
1603
|
-
<span>Generating story...</span>
|
|
1604
|
-
{progress && (
|
|
1605
|
-
<span style={{ marginLeft: 'auto', color: '#9ca3af' }}>
|
|
1606
|
-
{progress.step}/{progress.totalSteps}
|
|
1607
|
-
</span>
|
|
1608
|
-
)}
|
|
1609
|
-
</div>
|
|
1610
|
-
|
|
1611
|
-
{/* Progress Bar */}
|
|
1612
|
-
{progress && (
|
|
1613
|
-
<div style={{ ...STYLES.progressBar, marginTop: '8px' }}>
|
|
1614
|
-
<div
|
|
1615
|
-
style={{
|
|
1616
|
-
...STYLES.progressFill,
|
|
1617
|
-
width: `${(progress.step / progress.totalSteps) * 100}%`
|
|
1618
|
-
}}
|
|
1619
|
-
/>
|
|
1620
|
-
</div>
|
|
1621
|
-
)}
|
|
716
|
+
<div className="sui-progress" role="progressbar" aria-valuenow={progress?.step} aria-valuemax={progress?.totalSteps}>
|
|
717
|
+
<div className="sui-progress-header">
|
|
718
|
+
<span className="sui-progress-label">{progress?.message || 'Generating story...'}</span>
|
|
719
|
+
{progress && <span className="sui-progress-step">{progress.step}/{progress.totalSteps}</span>}
|
|
1622
720
|
</div>
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
<div style={STYLES.retryBadge}>
|
|
1627
|
-
Retry {retry.attempt}/{retry.maxAttempts}: {retry.reason}
|
|
1628
|
-
</div>
|
|
1629
|
-
)}
|
|
1630
|
-
|
|
1631
|
-
{/* Loading indicator when no specific phase */}
|
|
1632
|
-
{!progress && !intent && (
|
|
1633
|
-
<div style={STYLES.progressPhase}>
|
|
1634
|
-
<span className="loading-dots">Connecting</span>
|
|
721
|
+
{progress && (
|
|
722
|
+
<div className="sui-progress-bar">
|
|
723
|
+
<div className="sui-progress-fill" style={{ width: `${(progress.step / progress.totalSteps) * 100}%` }} />
|
|
1635
724
|
</div>
|
|
1636
725
|
)}
|
|
726
|
+
{retry && <div className="sui-progress-retry">Retry {retry.attempt}/{retry.maxAttempts}: {retry.reason}</div>}
|
|
1637
727
|
</div>
|
|
1638
728
|
);
|
|
1639
729
|
};
|
|
1640
730
|
|
|
1641
|
-
//
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
const [
|
|
1651
|
-
const [
|
|
1652
|
-
const [
|
|
1653
|
-
const [
|
|
1654
|
-
const
|
|
1655
|
-
const
|
|
1656
|
-
const
|
|
1657
|
-
const [considerations, setConsiderations] = useState<string>('');
|
|
1658
|
-
const [orphanStories, setOrphanStories] = useState<OrphanStory[]>([]);
|
|
1659
|
-
const chatEndRef = useRef<HTMLDivElement | null>(null);
|
|
1660
|
-
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
1661
|
-
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
731
|
+
// ============================================
|
|
732
|
+
// Main Component
|
|
733
|
+
// ============================================
|
|
734
|
+
|
|
735
|
+
interface StoryUIPanelProps {
|
|
736
|
+
mcpPort?: number | string;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
|
|
740
|
+
const [state, dispatch] = useReducer(panelReducer, initialState);
|
|
741
|
+
const [contextMenuId, setContextMenuId] = useState<string | null>(null);
|
|
742
|
+
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
|
|
743
|
+
const [renameValue, setRenameValue] = useState('');
|
|
744
|
+
const chatEndRef = useRef<HTMLDivElement>(null);
|
|
745
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
746
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
1662
747
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
748
|
+
const hasShownRefreshHint = useRef(false);
|
|
749
|
+
|
|
750
|
+
// Set port override if provided
|
|
751
|
+
useEffect(() => {
|
|
752
|
+
if (mcpPort && typeof window !== 'undefined') {
|
|
753
|
+
(window as any).STORY_UI_MCP_PORT = String(mcpPort);
|
|
754
|
+
}
|
|
755
|
+
}, [mcpPort]);
|
|
756
|
+
|
|
757
|
+
// Detect Storybook theme
|
|
758
|
+
useEffect(() => {
|
|
759
|
+
const detectTheme = () => {
|
|
760
|
+
const body = document.body;
|
|
761
|
+
const html = document.documentElement;
|
|
762
|
+
|
|
763
|
+
// Check URL parameters for Storybook background setting
|
|
764
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
765
|
+
const globals = urlParams.get('globals') || '';
|
|
766
|
+
const hasStorybookLightBg = globals.includes('backgrounds.value:light');
|
|
767
|
+
const hasStorybookDarkBg = globals.includes('backgrounds.value:dark') ||
|
|
768
|
+
globals.includes('backgrounds.value:%23') || // Hex colors starting with #
|
|
769
|
+
globals.includes('backgrounds.value:!hex');
|
|
770
|
+
|
|
771
|
+
// Check parent frame URL if we're in an iframe (Storybook 8+)
|
|
772
|
+
let parentHasDarkBg = false;
|
|
773
|
+
let parentHasLightBg = false;
|
|
774
|
+
let parentHasDarkClass = false;
|
|
775
|
+
try {
|
|
776
|
+
if (window.parent !== window) {
|
|
777
|
+
const parentUrl = new URL(window.parent.location.href);
|
|
778
|
+
const parentGlobals = parentUrl.searchParams.get('globals') || '';
|
|
779
|
+
parentHasLightBg = parentGlobals.includes('backgrounds.value:light');
|
|
780
|
+
parentHasDarkBg = parentGlobals.includes('backgrounds.value:dark') ||
|
|
781
|
+
parentGlobals.includes('backgrounds.value:%23');
|
|
782
|
+
// Check parent document for Storybook dark theme classes
|
|
783
|
+
const parentBody = window.parent.document.body;
|
|
784
|
+
const parentHtml = window.parent.document.documentElement;
|
|
785
|
+
parentHasDarkClass = parentBody.classList.contains('sb-dark') ||
|
|
786
|
+
parentHtml.classList.contains('dark') ||
|
|
787
|
+
parentHtml.getAttribute('data-theme') === 'dark' ||
|
|
788
|
+
parentBody.getAttribute('data-theme') === 'dark';
|
|
789
|
+
// Check Storybook 8+ manager theme
|
|
790
|
+
const sbMainEl = window.parent.document.querySelector('.sb-main-padded, .sb-show-main');
|
|
791
|
+
if (sbMainEl) {
|
|
792
|
+
const sbBgColor = window.getComputedStyle(sbMainEl).backgroundColor;
|
|
793
|
+
const sbRgb = sbBgColor.match(/\d+/g);
|
|
794
|
+
if (sbRgb && sbRgb.length >= 3) {
|
|
795
|
+
const sbLuminance = (0.299 * parseInt(sbRgb[0]) + 0.587 * parseInt(sbRgb[1]) + 0.114 * parseInt(sbRgb[2])) / 255;
|
|
796
|
+
if (sbLuminance < 0.5) parentHasDarkClass = true;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
} catch {
|
|
801
|
+
// Cross-origin access not allowed, ignore
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Check the actual background color of the body
|
|
805
|
+
const bgColor = window.getComputedStyle(body).backgroundColor;
|
|
806
|
+
const rgb = bgColor.match(/\d+/g);
|
|
807
|
+
let isBackgroundDark = false;
|
|
808
|
+
if (rgb && rgb.length >= 3) {
|
|
809
|
+
const luminance = (0.299 * parseInt(rgb[0]) + 0.587 * parseInt(rgb[1]) + 0.114 * parseInt(rgb[2])) / 255;
|
|
810
|
+
isBackgroundDark = luminance < 0.5;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Check system preference as fallback
|
|
814
|
+
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
815
|
+
|
|
816
|
+
// Explicit light mode takes precedence - if user selected "light" in Storybook, respect that
|
|
817
|
+
const hasExplicitLightMode = hasStorybookLightBg || parentHasLightBg;
|
|
818
|
+
|
|
819
|
+
// Explicit dark mode indicators
|
|
820
|
+
const hasExplicitDarkMode =
|
|
821
|
+
body.classList.contains('sb-dark') ||
|
|
822
|
+
html.classList.contains('dark') ||
|
|
823
|
+
html.getAttribute('data-theme') === 'dark' ||
|
|
824
|
+
body.getAttribute('data-theme') === 'dark' ||
|
|
825
|
+
hasStorybookDarkBg ||
|
|
826
|
+
parentHasDarkBg;
|
|
827
|
+
|
|
828
|
+
// Determine dark mode: explicit light mode forces light, otherwise check dark indicators
|
|
829
|
+
const isDark = hasExplicitLightMode
|
|
830
|
+
? false
|
|
831
|
+
: (hasExplicitDarkMode || parentHasDarkClass || isBackgroundDark || systemPrefersDark);
|
|
832
|
+
dispatch({ type: 'SET_DARK_MODE', payload: isDark });
|
|
833
|
+
};
|
|
834
|
+
detectTheme();
|
|
835
|
+
|
|
836
|
+
// Listen for URL changes (Storybook uses popstate for navigation)
|
|
837
|
+
window.addEventListener('popstate', detectTheme);
|
|
838
|
+
|
|
839
|
+
// Poll for changes since Storybook might change background without popstate
|
|
840
|
+
const intervalId = setInterval(detectTheme, 500);
|
|
841
|
+
|
|
842
|
+
const observer = new MutationObserver(detectTheme);
|
|
843
|
+
observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] });
|
|
844
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] });
|
|
845
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
846
|
+
mediaQuery.addEventListener('change', detectTheme);
|
|
847
|
+
return () => {
|
|
848
|
+
window.removeEventListener('popstate', detectTheme);
|
|
849
|
+
clearInterval(intervalId);
|
|
850
|
+
observer.disconnect();
|
|
851
|
+
mediaQuery.removeEventListener('change', detectTheme);
|
|
852
|
+
};
|
|
853
|
+
}, []);
|
|
854
|
+
|
|
855
|
+
// Close context menu when clicking outside
|
|
856
|
+
useEffect(() => {
|
|
857
|
+
if (!contextMenuId) return;
|
|
858
|
+
|
|
859
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
860
|
+
const target = e.target as HTMLElement;
|
|
861
|
+
if (!target.closest('.sui-context-menu') && !target.closest('.sui-chat-item-menu')) {
|
|
862
|
+
setContextMenuId(null);
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
document.addEventListener('click', handleClickOutside);
|
|
867
|
+
return () => document.removeEventListener('click', handleClickOutside);
|
|
868
|
+
}, [contextMenuId]);
|
|
869
|
+
|
|
870
|
+
// Initialize on mount
|
|
871
|
+
useEffect(() => {
|
|
872
|
+
const initialize = async () => {
|
|
873
|
+
const connectionTest = await testMCPConnection();
|
|
874
|
+
dispatch({ type: 'SET_CONNECTION_STATUS', payload: connectionTest });
|
|
875
|
+
if (connectionTest.connected) {
|
|
876
|
+
try {
|
|
877
|
+
const res = await fetch(PROVIDERS_API);
|
|
878
|
+
if (res.ok) {
|
|
879
|
+
const data: ProvidersResponse = await res.json();
|
|
880
|
+
dispatch({ type: 'SET_PROVIDERS', payload: data.providers.filter(p => p.configured) });
|
|
881
|
+
if (data.current) {
|
|
882
|
+
dispatch({ type: 'SET_SELECTED_PROVIDER', payload: data.current.provider.toLowerCase() });
|
|
883
|
+
dispatch({ type: 'SET_SELECTED_MODEL', payload: data.current.model });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
} catch (e) {
|
|
887
|
+
console.error('Failed to fetch providers:', e);
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const res = await fetch(CONSIDERATIONS_API);
|
|
891
|
+
if (res.ok) {
|
|
892
|
+
const data = await res.json();
|
|
893
|
+
if (data.hasConsiderations && data.considerations) {
|
|
894
|
+
dispatch({ type: 'SET_CONSIDERATIONS', payload: data.considerations });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
} catch (e) {
|
|
898
|
+
console.error('Failed to fetch considerations:', e);
|
|
899
|
+
}
|
|
900
|
+
const syncedChats = await syncWithActualStories();
|
|
901
|
+
const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
|
|
902
|
+
dispatch({ type: 'SET_RECENT_CHATS', payload: sortedChats });
|
|
903
|
+
if (sortedChats.length > 0) {
|
|
904
|
+
dispatch({ type: 'SET_CONVERSATION', payload: sortedChats[0].conversation });
|
|
905
|
+
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: sortedChats[0].id, title: sortedChats[0].title } });
|
|
906
|
+
}
|
|
907
|
+
} else {
|
|
908
|
+
const localChats = loadChats();
|
|
909
|
+
dispatch({ type: 'SET_RECENT_CHATS', payload: localChats });
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
initialize();
|
|
913
|
+
}, []);
|
|
1663
914
|
|
|
1664
|
-
//
|
|
1665
|
-
|
|
1666
|
-
|
|
915
|
+
// Scroll to bottom on new messages
|
|
916
|
+
useEffect(() => {
|
|
917
|
+
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
918
|
+
}, [state.conversation, state.loading]);
|
|
1667
919
|
|
|
1668
|
-
//
|
|
920
|
+
// File handling
|
|
1669
921
|
const fileToBase64 = (file: File): Promise<string> => {
|
|
1670
922
|
return new Promise((resolve, reject) => {
|
|
1671
923
|
const reader = new FileReader();
|
|
1672
924
|
reader.readAsDataURL(file);
|
|
1673
925
|
reader.onload = () => {
|
|
1674
926
|
const result = reader.result as string;
|
|
1675
|
-
|
|
1676
|
-
const base64 = result.split(',')[1];
|
|
1677
|
-
resolve(base64);
|
|
927
|
+
resolve(result.split(',')[1]);
|
|
1678
928
|
};
|
|
1679
929
|
reader.onerror = error => reject(error);
|
|
1680
930
|
});
|
|
1681
931
|
};
|
|
1682
932
|
|
|
1683
|
-
// Handle file selection
|
|
1684
933
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
1685
934
|
const files = e.target.files;
|
|
1686
935
|
if (!files) return;
|
|
1687
|
-
|
|
1688
|
-
const newImages: AttachedImage[] = [];
|
|
1689
936
|
const errors: string[] = [];
|
|
1690
|
-
|
|
1691
|
-
for (let i = 0; i < files.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
|
|
937
|
+
for (let i = 0; i < files.length && (state.attachedImages.length + i) < MAX_IMAGES; i++) {
|
|
1692
938
|
const file = files[i];
|
|
1693
|
-
|
|
1694
|
-
// Validate file type
|
|
1695
939
|
if (!file.type.startsWith('image/')) {
|
|
1696
940
|
errors.push(`${file.name}: Not an image file`);
|
|
1697
941
|
continue;
|
|
1698
942
|
}
|
|
1699
|
-
|
|
1700
|
-
// Validate file size
|
|
1701
943
|
if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
|
|
1702
944
|
errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
|
|
1703
945
|
continue;
|
|
1704
946
|
}
|
|
1705
|
-
|
|
1706
947
|
try {
|
|
1707
948
|
const base64 = await fileToBase64(file);
|
|
1708
949
|
const preview = URL.createObjectURL(file);
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
id: `${Date.now()}-${i}`,
|
|
1712
|
-
file,
|
|
1713
|
-
preview,
|
|
1714
|
-
base64,
|
|
1715
|
-
mediaType: file.type || 'image/png',
|
|
950
|
+
dispatch({
|
|
951
|
+
type: 'ADD_ATTACHED_IMAGE',
|
|
952
|
+
payload: { id: `${Date.now()}-${i}`, file, preview, base64, mediaType: file.type || 'image/png' },
|
|
1716
953
|
});
|
|
1717
|
-
} catch
|
|
954
|
+
} catch {
|
|
1718
955
|
errors.push(`${file.name}: Failed to process`);
|
|
1719
956
|
}
|
|
1720
957
|
}
|
|
1721
|
-
|
|
1722
|
-
if (
|
|
1723
|
-
setError(errors.join('\n'));
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
setAttachedImages(prev => [...prev, ...newImages]);
|
|
1727
|
-
|
|
1728
|
-
// Reset file input
|
|
1729
|
-
if (fileInputRef.current) {
|
|
1730
|
-
fileInputRef.current.value = '';
|
|
1731
|
-
}
|
|
958
|
+
if (errors.length > 0) dispatch({ type: 'SET_ERROR', payload: errors.join('\n') });
|
|
959
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
1732
960
|
};
|
|
1733
961
|
|
|
1734
|
-
// Remove attached image
|
|
1735
962
|
const removeAttachedImage = (id: string) => {
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
URL.revokeObjectURL(removed.preview);
|
|
1740
|
-
}
|
|
1741
|
-
return prev.filter(img => img.id !== id);
|
|
1742
|
-
});
|
|
963
|
+
const img = state.attachedImages.find(i => i.id === id);
|
|
964
|
+
if (img) URL.revokeObjectURL(img.preview);
|
|
965
|
+
dispatch({ type: 'REMOVE_ATTACHED_IMAGE', payload: id });
|
|
1743
966
|
};
|
|
1744
967
|
|
|
1745
|
-
// Clear all attached images
|
|
1746
968
|
const clearAttachedImages = () => {
|
|
1747
|
-
attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
|
|
1748
|
-
|
|
969
|
+
state.attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
|
|
970
|
+
dispatch({ type: 'CLEAR_ATTACHED_IMAGES' });
|
|
1749
971
|
};
|
|
1750
972
|
|
|
1751
|
-
// Drag and drop
|
|
1752
|
-
const [isDragging, setIsDragging] = useState(false);
|
|
1753
|
-
|
|
1754
|
-
// Handle drag events
|
|
973
|
+
// Drag and drop handlers
|
|
1755
974
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
|
1756
975
|
e.preventDefault();
|
|
1757
976
|
e.stopPropagation();
|
|
1758
|
-
if (e.dataTransfer.types.includes('Files')) {
|
|
1759
|
-
setIsDragging(true);
|
|
1760
|
-
}
|
|
977
|
+
if (e.dataTransfer.types.includes('Files')) dispatch({ type: 'SET_DRAGGING', payload: true });
|
|
1761
978
|
}, []);
|
|
1762
979
|
|
|
1763
980
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
1764
981
|
e.preventDefault();
|
|
1765
982
|
e.stopPropagation();
|
|
1766
|
-
|
|
1767
|
-
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
1768
|
-
setIsDragging(false);
|
|
1769
|
-
}
|
|
983
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node)) dispatch({ type: 'SET_DRAGGING', payload: false });
|
|
1770
984
|
}, []);
|
|
1771
985
|
|
|
1772
986
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
@@ -1777,348 +991,126 @@ function StoryUIPanel() {
|
|
|
1777
991
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
|
1778
992
|
e.preventDefault();
|
|
1779
993
|
e.stopPropagation();
|
|
1780
|
-
|
|
1781
|
-
|
|
994
|
+
dispatch({ type: 'SET_DRAGGING', payload: false });
|
|
1782
995
|
const files = Array.from(e.dataTransfer.files);
|
|
1783
996
|
const imageFiles = files.filter(f => f.type.startsWith('image/'));
|
|
1784
|
-
|
|
1785
997
|
if (imageFiles.length === 0) {
|
|
1786
|
-
|
|
1787
|
-
return;
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
for (let i = 0; i < imageFiles.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
|
|
1794
|
-
const file = imageFiles[i];
|
|
1795
|
-
|
|
1796
|
-
if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
|
|
1797
|
-
errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
|
|
1798
|
-
continue;
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
try {
|
|
1802
|
-
const base64 = await fileToBase64(file);
|
|
1803
|
-
const preview = URL.createObjectURL(file);
|
|
1804
|
-
|
|
1805
|
-
newImages.push({
|
|
1806
|
-
id: `${Date.now()}-${i}`,
|
|
1807
|
-
file,
|
|
1808
|
-
preview,
|
|
1809
|
-
base64,
|
|
1810
|
-
mediaType: file.type || 'image/png',
|
|
1811
|
-
});
|
|
1812
|
-
} catch (err) {
|
|
1813
|
-
errors.push(`${file.name}: Failed to process`);
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
if (errors.length > 0) {
|
|
1818
|
-
setError(errors.join('\n'));
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
setAttachedImages(prev => [...prev, ...newImages]);
|
|
1822
|
-
}, [attachedImages.length, fileToBase64]);
|
|
1823
|
-
|
|
1824
|
-
// Handle clipboard paste for images
|
|
1825
|
-
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
|
1826
|
-
const items = e.clipboardData?.items;
|
|
1827
|
-
if (!items) return;
|
|
1828
|
-
|
|
1829
|
-
const imageItems: DataTransferItem[] = [];
|
|
1830
|
-
for (let i = 0; i < items.length; i++) {
|
|
1831
|
-
if (items[i].type.startsWith('image/')) {
|
|
1832
|
-
imageItems.push(items[i]);
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
|
|
1836
|
-
if (imageItems.length === 0) return;
|
|
1837
|
-
|
|
1838
|
-
// Prevent default text paste behavior when pasting images
|
|
1839
|
-
e.preventDefault();
|
|
1840
|
-
|
|
1841
|
-
if (attachedImages.length >= MAX_IMAGES) {
|
|
1842
|
-
setError(`Maximum ${MAX_IMAGES} images allowed`);
|
|
1843
|
-
return;
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
const newImages: AttachedImage[] = [];
|
|
1847
|
-
const errors: string[] = [];
|
|
1848
|
-
|
|
1849
|
-
for (let i = 0; i < imageItems.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
|
|
1850
|
-
const item = imageItems[i];
|
|
1851
|
-
const file = item.getAsFile();
|
|
1852
|
-
|
|
1853
|
-
if (!file) {
|
|
1854
|
-
errors.push('Failed to get image from clipboard');
|
|
1855
|
-
continue;
|
|
1856
|
-
}
|
|
1857
|
-
|
|
998
|
+
dispatch({ type: 'SET_ERROR', payload: 'Please drop image files only' });
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const errors: string[] = [];
|
|
1002
|
+
for (let i = 0; i < imageFiles.length && (state.attachedImages.length + i) < MAX_IMAGES; i++) {
|
|
1003
|
+
const file = imageFiles[i];
|
|
1858
1004
|
if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
|
|
1859
|
-
errors.push(
|
|
1005
|
+
errors.push(`${file.name}: File too large`);
|
|
1860
1006
|
continue;
|
|
1861
1007
|
}
|
|
1862
|
-
|
|
1863
1008
|
try {
|
|
1864
1009
|
const base64 = await fileToBase64(file);
|
|
1865
1010
|
const preview = URL.createObjectURL(file);
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
const renamedFile = new File([file], `pasted-image-${timestamp}.${file.type.split('/')[1] || 'png'}`, { type: file.type });
|
|
1870
|
-
|
|
1871
|
-
newImages.push({
|
|
1872
|
-
id: `paste-${Date.now()}-${i}`,
|
|
1873
|
-
file: renamedFile,
|
|
1874
|
-
preview,
|
|
1875
|
-
base64,
|
|
1876
|
-
mediaType: file.type || 'image/png',
|
|
1011
|
+
dispatch({
|
|
1012
|
+
type: 'ADD_ATTACHED_IMAGE',
|
|
1013
|
+
payload: { id: `${Date.now()}-${i}`, file, preview, base64, mediaType: file.type || 'image/png' },
|
|
1877
1014
|
});
|
|
1878
|
-
} catch
|
|
1879
|
-
errors.push(
|
|
1015
|
+
} catch {
|
|
1016
|
+
errors.push(`${file.name}: Failed to process`);
|
|
1880
1017
|
}
|
|
1881
1018
|
}
|
|
1019
|
+
if (errors.length > 0) dispatch({ type: 'SET_ERROR', payload: errors.join('\n') });
|
|
1020
|
+
}, [state.attachedImages.length]);
|
|
1882
1021
|
|
|
1883
|
-
|
|
1884
|
-
|
|
1022
|
+
// Paste handler
|
|
1023
|
+
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
|
1024
|
+
const items = e.clipboardData?.items;
|
|
1025
|
+
if (!items) return;
|
|
1026
|
+
const imageItems: DataTransferItem[] = [];
|
|
1027
|
+
for (let i = 0; i < items.length; i++) {
|
|
1028
|
+
if (items[i].type.startsWith('image/')) imageItems.push(items[i]);
|
|
1885
1029
|
}
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
setError(null);
|
|
1892
|
-
}
|
|
1030
|
+
if (imageItems.length === 0) return;
|
|
1031
|
+
e.preventDefault();
|
|
1032
|
+
if (state.attachedImages.length >= MAX_IMAGES) {
|
|
1033
|
+
dispatch({ type: 'SET_ERROR', payload: `Maximum ${MAX_IMAGES} images allowed` });
|
|
1034
|
+
return;
|
|
1893
1035
|
}
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
setSelectedModel(providersData.current.model);
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
} catch (e) {
|
|
1917
|
-
console.error('Failed to fetch providers:', e);
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
// Fetch design system considerations for environment parity
|
|
1921
|
-
// This ensures production gets the same considerations as local development
|
|
1922
|
-
try {
|
|
1923
|
-
const considerationsRes = await fetch(CONSIDERATIONS_API);
|
|
1924
|
-
if (considerationsRes.ok) {
|
|
1925
|
-
const considerationsData = await considerationsRes.json();
|
|
1926
|
-
if (considerationsData.hasConsiderations && considerationsData.considerations) {
|
|
1927
|
-
setConsiderations(considerationsData.considerations);
|
|
1928
|
-
console.log(`Loaded considerations from ${considerationsData.source}`);
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
} catch (e) {
|
|
1932
|
-
console.error('Failed to fetch considerations:', e);
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
const syncedChats = await syncWithActualStories();
|
|
1936
|
-
const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
|
|
1937
|
-
setRecentChats(sortedChats);
|
|
1938
|
-
|
|
1939
|
-
if (sortedChats.length > 0) {
|
|
1940
|
-
setConversation(sortedChats[0].conversation);
|
|
1941
|
-
setActiveChatId(sortedChats[0].id);
|
|
1942
|
-
setActiveTitle(sortedChats[0].title);
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
// Fetch orphan stories (stories on disk without chat history)
|
|
1946
|
-
const orphans = await fetchOrphanStories();
|
|
1947
|
-
setOrphanStories(orphans);
|
|
1948
|
-
} else {
|
|
1949
|
-
// Load from local storage if server is not available
|
|
1950
|
-
const localChats = loadChats();
|
|
1951
|
-
setRecentChats(localChats);
|
|
1036
|
+
for (let i = 0; i < imageItems.length && (state.attachedImages.length + i) < MAX_IMAGES; i++) {
|
|
1037
|
+
const file = imageItems[i].getAsFile();
|
|
1038
|
+
if (!file) continue;
|
|
1039
|
+
try {
|
|
1040
|
+
const base64 = await fileToBase64(file);
|
|
1041
|
+
const preview = URL.createObjectURL(file);
|
|
1042
|
+
const timestamp = new Date().toISOString().slice(11, 19).replace(/:/g, '-');
|
|
1043
|
+
dispatch({
|
|
1044
|
+
type: 'ADD_ATTACHED_IMAGE',
|
|
1045
|
+
payload: {
|
|
1046
|
+
id: `paste-${Date.now()}-${i}`,
|
|
1047
|
+
file: new File([file], `pasted-image-${timestamp}.png`, { type: file.type }),
|
|
1048
|
+
preview,
|
|
1049
|
+
base64,
|
|
1050
|
+
mediaType: file.type || 'image/png',
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
} catch {
|
|
1054
|
+
dispatch({ type: 'SET_ERROR', payload: 'Failed to process pasted image' });
|
|
1952
1055
|
}
|
|
1953
|
-
};
|
|
1954
|
-
|
|
1955
|
-
initializeChats();
|
|
1956
|
-
}, []);
|
|
1957
|
-
|
|
1958
|
-
// Scroll to bottom on new message
|
|
1959
|
-
useEffect(() => {
|
|
1960
|
-
if (chatEndRef.current) {
|
|
1961
|
-
chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
1962
|
-
}
|
|
1963
|
-
}, [conversation, loading]);
|
|
1964
|
-
|
|
1965
|
-
// Helper function for non-streaming fallback
|
|
1966
|
-
const handleSendNonStreaming = async (userInput: string, newConversation: Message[]) => {
|
|
1967
|
-
const res = await fetch(MCP_API, {
|
|
1968
|
-
method: 'POST',
|
|
1969
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1970
|
-
body: JSON.stringify({
|
|
1971
|
-
prompt: userInput,
|
|
1972
|
-
conversation: newConversation,
|
|
1973
|
-
fileName: activeChatId || undefined,
|
|
1974
|
-
provider: selectedProvider || undefined,
|
|
1975
|
-
model: selectedModel || undefined,
|
|
1976
|
-
considerations: considerations || undefined,
|
|
1977
|
-
}),
|
|
1978
|
-
});
|
|
1979
|
-
|
|
1980
|
-
const contentType = res.headers.get('content-type');
|
|
1981
|
-
if (!contentType || !contentType.includes('application/json')) {
|
|
1982
|
-
const text = await res.text();
|
|
1983
|
-
throw new Error(`Server returned non-JSON response. Response: ${text.substring(0, 200)}...`);
|
|
1984
1056
|
}
|
|
1057
|
+
}, [state.attachedImages.length]);
|
|
1985
1058
|
|
|
1986
|
-
|
|
1987
|
-
if (!res.ok || !data.success) throw new Error(data.error || 'Story generation failed');
|
|
1988
|
-
|
|
1989
|
-
return data;
|
|
1990
|
-
};
|
|
1991
|
-
|
|
1992
|
-
// Helper function to build a conversational response from completion data
|
|
1993
|
-
// Uses special markers [SUCCESS], [ERROR], [TIP], [WRENCH] that renderMarkdown converts to icons
|
|
1994
|
-
// Track whether we've shown the refresh hint in this session
|
|
1995
|
-
const hasShownRefreshHint = useRef(false);
|
|
1996
|
-
|
|
1059
|
+
// Build response message
|
|
1997
1060
|
const buildConversationalResponse = (completion: CompletionFeedback, isUpdate: boolean): string => {
|
|
1998
1061
|
const parts: string[] = [];
|
|
1999
1062
|
const statusMarker = completion.success ? '[SUCCESS]' : '[ERROR]';
|
|
2000
|
-
|
|
2001
|
-
// Lead with the result - more conversational
|
|
2002
|
-
if (isUpdate) {
|
|
2003
|
-
parts.push(`${statusMarker} **Updated: "${completion.title}"**`);
|
|
2004
|
-
} else {
|
|
2005
|
-
parts.push(`${statusMarker} **Created: "${completion.title}"**`);
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
|
-
// Build component insights with reasons when available
|
|
1063
|
+
parts.push(isUpdate ? `${statusMarker} **Updated: "${completion.title}"**` : `${statusMarker} **Created: "${completion.title}"**`);
|
|
2009
1064
|
const componentCount = completion.componentsUsed?.length || 0;
|
|
2010
1065
|
if (componentCount > 0) {
|
|
2011
|
-
const
|
|
2012
|
-
|
|
2013
|
-
// Check if we have meaningful reasons (not just "Used in composition")
|
|
2014
|
-
const componentsWithReasons = componentList.filter(c =>
|
|
2015
|
-
c.reason && c.reason !== 'Used in composition'
|
|
2016
|
-
);
|
|
2017
|
-
|
|
2018
|
-
if (componentsWithReasons.length > 0) {
|
|
2019
|
-
// Show components with their reasons
|
|
2020
|
-
const insights = componentsWithReasons
|
|
2021
|
-
.slice(0, 3)
|
|
2022
|
-
.map(c => `\`${c.name}\` - ${c.reason?.toLowerCase()}`)
|
|
2023
|
-
.join(', ');
|
|
2024
|
-
parts.push(`\nUsed ${insights}${componentCount > 3 ? ` and ${componentCount - 3} more` : ''}.`);
|
|
2025
|
-
} else {
|
|
2026
|
-
// Fallback to simple list
|
|
2027
|
-
const names = componentList.map(c => `\`${c.name}\``).join(', ');
|
|
2028
|
-
parts.push(`\nBuilt with ${names}${componentCount > 5 ? '...' : ''}.`);
|
|
2029
|
-
}
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
// Add layout decisions with educational context
|
|
2033
|
-
if (completion.layoutChoices && completion.layoutChoices.length > 0) {
|
|
2034
|
-
const primaryLayout = completion.layoutChoices[0];
|
|
2035
|
-
parts.push(`\n\n**Layout:** ${primaryLayout.pattern} - ${primaryLayout.reason.charAt(0).toLowerCase()}${primaryLayout.reason.slice(1)}.`);
|
|
1066
|
+
const names = completion.componentsUsed.slice(0, 5).map(c => `\`${c.name}\``).join(', ');
|
|
1067
|
+
parts.push(`\nBuilt with ${names}${componentCount > 5 ? '...' : ''}.`);
|
|
2036
1068
|
}
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
const notableStyles = completion.styleChoices.filter(s =>
|
|
2041
|
-
s.reason && s.reason !== 'Semantic color from design system'
|
|
2042
|
-
);
|
|
2043
|
-
if (notableStyles.length > 0) {
|
|
2044
|
-
const styleInfo = notableStyles[0];
|
|
2045
|
-
parts.push(` Applied \`${styleInfo.value}\` for ${styleInfo.reason?.toLowerCase() || 'visual consistency'}.`);
|
|
2046
|
-
}
|
|
1069
|
+
if (completion.layoutChoices?.length > 0) {
|
|
1070
|
+
const layout = completion.layoutChoices[0];
|
|
1071
|
+
parts.push(`\n\n**Layout:** ${layout.pattern} - ${layout.reason}.`);
|
|
2047
1072
|
}
|
|
2048
|
-
|
|
2049
|
-
// Add validation fixes notice
|
|
2050
1073
|
if (completion.validation?.autoFixApplied) {
|
|
2051
1074
|
parts.push(`\n\n[WRENCH] **Auto-fixed:** Minor syntax issues were automatically corrected.`);
|
|
2052
1075
|
}
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
if (completion.suggestions && completion.suggestions.length > 0) {
|
|
2056
|
-
const suggestion = completion.suggestions[0];
|
|
2057
|
-
// Only show if it's not the generic "review the generated code" message
|
|
2058
|
-
if (!suggestion.toLowerCase().includes('review the generated code')) {
|
|
2059
|
-
parts.push(`\n\n[TIP] **Tip:** ${suggestion}`);
|
|
2060
|
-
}
|
|
1076
|
+
if (completion.suggestions && completion.suggestions.length > 0 && !completion.suggestions[0].toLowerCase().includes('review the generated code')) {
|
|
1077
|
+
parts.push(`\n\n[TIP] **Tip:** ${completion.suggestions[0]}`);
|
|
2061
1078
|
}
|
|
2062
|
-
|
|
2063
|
-
// Show refresh hint only once per session for new stories (local mode only)
|
|
2064
|
-
// In Edge mode, stories are stored in Durable Objects, not on filesystem
|
|
2065
1079
|
if (!isUpdate && !hasShownRefreshHint.current) {
|
|
2066
|
-
|
|
2067
|
-
parts.push(`\n\n_Story saved to cloud. View code in chat history recent chats navigation._`);
|
|
2068
|
-
} else {
|
|
2069
|
-
parts.push(`\n\n_Might need toefresh Storybook (Cmd/Ctrl + R) to see new stories in the sidebar._`);
|
|
2070
|
-
}
|
|
1080
|
+
parts.push(isEdgeMode() ? `\n\n_Story saved to cloud._` : `\n\n_Might need to refresh Storybook (Cmd/Ctrl + R) to see new stories._`);
|
|
2071
1081
|
hasShownRefreshHint.current = true;
|
|
2072
1082
|
}
|
|
2073
|
-
|
|
2074
|
-
// Add metrics in a subtle way (if available)
|
|
2075
1083
|
if (completion.metrics?.totalTimeMs) {
|
|
2076
|
-
|
|
2077
|
-
parts.push(`\n\n_${seconds}s_`);
|
|
1084
|
+
parts.push(`\n\n_${(completion.metrics.totalTimeMs / 1000).toFixed(1)}s_`);
|
|
2078
1085
|
}
|
|
2079
|
-
|
|
2080
1086
|
return parts.join('');
|
|
2081
1087
|
};
|
|
2082
1088
|
|
|
2083
|
-
//
|
|
2084
|
-
const finalizeStreamingConversation = useCallback((
|
|
2085
|
-
newConversation: Message[],
|
|
2086
|
-
completion: CompletionFeedback,
|
|
2087
|
-
userInput: string
|
|
2088
|
-
) => {
|
|
2089
|
-
// Build conversational response using rich completion data
|
|
1089
|
+
// Finalize streaming
|
|
1090
|
+
const finalizeStreamingConversation = useCallback((newConversation: Message[], completion: CompletionFeedback, userInput: string) => {
|
|
2090
1091
|
const isUpdate = completion.summary.action === 'updated';
|
|
2091
1092
|
const responseMessage = buildConversationalResponse(completion, isUpdate);
|
|
2092
|
-
|
|
2093
1093
|
const aiMsg: Message = { role: 'ai', content: responseMessage };
|
|
2094
1094
|
const updatedConversation = [...newConversation, aiMsg];
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
const isExistingSession = activeChatId && conversation.length > 0;
|
|
2099
|
-
|
|
2100
|
-
if (isExistingSession && activeChatId) {
|
|
1095
|
+
dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
|
|
1096
|
+
const isExistingSession = state.activeChatId && state.conversation.length > 0;
|
|
1097
|
+
if (isExistingSession && state.activeChatId) {
|
|
2101
1098
|
const updatedSession: ChatSession = {
|
|
2102
|
-
id: activeChatId,
|
|
2103
|
-
title: activeTitle,
|
|
2104
|
-
fileName: completion.fileName || activeChatId,
|
|
1099
|
+
id: state.activeChatId,
|
|
1100
|
+
title: state.activeTitle,
|
|
1101
|
+
fileName: completion.fileName || state.activeChatId,
|
|
2105
1102
|
conversation: updatedConversation,
|
|
2106
1103
|
lastUpdated: Date.now(),
|
|
2107
1104
|
};
|
|
2108
|
-
|
|
2109
1105
|
const chats = loadChats();
|
|
2110
|
-
const chatIndex = chats.findIndex(c => c.id === activeChatId);
|
|
2111
|
-
if (chatIndex !== -1)
|
|
2112
|
-
chats[chatIndex] = updatedSession;
|
|
2113
|
-
}
|
|
1106
|
+
const chatIndex = chats.findIndex(c => c.id === state.activeChatId);
|
|
1107
|
+
if (chatIndex !== -1) chats[chatIndex] = updatedSession;
|
|
2114
1108
|
saveChats(chats);
|
|
2115
|
-
|
|
1109
|
+
dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
|
|
2116
1110
|
} else {
|
|
2117
1111
|
const chatId = completion.storyId || completion.fileName || Date.now().toString();
|
|
2118
1112
|
const chatTitle = completion.title || userInput;
|
|
2119
|
-
|
|
2120
|
-
setActiveTitle(chatTitle);
|
|
2121
|
-
|
|
1113
|
+
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chatId, title: chatTitle } });
|
|
2122
1114
|
const newSession: ChatSession = {
|
|
2123
1115
|
id: chatId,
|
|
2124
1116
|
title: chatTitle,
|
|
@@ -2126,332 +1118,176 @@ function StoryUIPanel() {
|
|
|
2126
1118
|
conversation: updatedConversation,
|
|
2127
1119
|
lastUpdated: Date.now(),
|
|
2128
1120
|
};
|
|
2129
|
-
|
|
2130
1121
|
const chats = loadChats().filter(c => c.id !== chatId);
|
|
2131
1122
|
chats.unshift(newSession);
|
|
2132
|
-
if (chats.length > MAX_RECENT_CHATS)
|
|
2133
|
-
chats.splice(MAX_RECENT_CHATS);
|
|
2134
|
-
}
|
|
1123
|
+
if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
|
|
2135
1124
|
saveChats(chats);
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
// Auto-navigate to the newly created story after HMR processes the file
|
|
2139
|
-
// This prevents the "Couldn't find story after HMR" error by refreshing
|
|
2140
|
-
// after the file system has been updated and HMR has processed the change
|
|
2141
|
-
navigateToNewStory(chatTitle, completion.code);
|
|
1125
|
+
dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
|
|
2142
1126
|
}
|
|
2143
|
-
}, [activeChatId, activeTitle, conversation.length]);
|
|
1127
|
+
}, [state.activeChatId, state.activeTitle, state.conversation.length]);
|
|
2144
1128
|
|
|
1129
|
+
// Handle send
|
|
2145
1130
|
const handleSend = async (e?: React.FormEvent) => {
|
|
2146
1131
|
if (e) e.preventDefault();
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
setError(null);
|
|
2153
|
-
setLoading(true);
|
|
2154
|
-
setStreamingState(null);
|
|
2155
|
-
|
|
2156
|
-
// Test connection before sending
|
|
1132
|
+
if (!state.input.trim() && state.attachedImages.length === 0) return;
|
|
1133
|
+
const userInput = state.input.trim() || (state.attachedImages.length > 0 ? 'Create a component that matches this design' : '');
|
|
1134
|
+
dispatch({ type: 'SET_ERROR', payload: null });
|
|
1135
|
+
dispatch({ type: 'SET_LOADING', payload: true });
|
|
1136
|
+
dispatch({ type: 'SET_STREAMING_STATE', payload: null });
|
|
2157
1137
|
const connectionTest = await testMCPConnection();
|
|
2158
|
-
|
|
2159
|
-
|
|
1138
|
+
dispatch({ type: 'SET_CONNECTION_STATUS', payload: connectionTest });
|
|
2160
1139
|
if (!connectionTest.connected) {
|
|
2161
|
-
|
|
2162
|
-
|
|
1140
|
+
dispatch({ type: 'SET_ERROR', payload: `Cannot connect to MCP server: ${connectionTest.error || 'Server not running'}` });
|
|
1141
|
+
dispatch({ type: 'SET_LOADING', payload: false });
|
|
2163
1142
|
return;
|
|
2164
1143
|
}
|
|
2165
|
-
|
|
2166
|
-
// Capture images before clearing
|
|
2167
|
-
const imagesToSend = [...attachedImages];
|
|
1144
|
+
const imagesToSend = [...state.attachedImages];
|
|
2168
1145
|
const hasImages = imagesToSend.length > 0;
|
|
2169
|
-
|
|
2170
|
-
// Create user message with images
|
|
2171
1146
|
const userMessage: Message = {
|
|
2172
1147
|
role: 'user',
|
|
2173
1148
|
content: userInput,
|
|
2174
|
-
attachedImages: hasImages ? imagesToSend : undefined
|
|
1149
|
+
attachedImages: hasImages ? imagesToSend : undefined,
|
|
2175
1150
|
};
|
|
2176
|
-
const newConversation: Message[] = [...conversation, userMessage];
|
|
2177
|
-
|
|
2178
|
-
|
|
1151
|
+
const newConversation: Message[] = [...state.conversation, userMessage];
|
|
1152
|
+
dispatch({ type: 'SET_CONVERSATION', payload: newConversation });
|
|
1153
|
+
dispatch({ type: 'SET_INPUT', payload: '' });
|
|
2179
1154
|
clearAttachedImages();
|
|
2180
1155
|
|
|
2181
|
-
// Use streaming if enabled
|
|
2182
1156
|
if (USE_STREAMING) {
|
|
2183
1157
|
try {
|
|
2184
|
-
|
|
2185
|
-
if (abortControllerRef.current) {
|
|
2186
|
-
abortControllerRef.current.abort();
|
|
2187
|
-
}
|
|
1158
|
+
if (abortControllerRef.current) abortControllerRef.current.abort();
|
|
2188
1159
|
abortControllerRef.current = new AbortController();
|
|
2189
|
-
|
|
2190
|
-
// Initialize streaming state
|
|
2191
|
-
setStreamingState({});
|
|
2192
|
-
|
|
2193
|
-
// Prepare images for API request
|
|
1160
|
+
dispatch({ type: 'SET_STREAMING_STATE', payload: {} });
|
|
2194
1161
|
const imagePayload = hasImages
|
|
2195
|
-
? imagesToSend.map(img => ({
|
|
2196
|
-
type: 'base64' as const,
|
|
2197
|
-
data: img.base64,
|
|
2198
|
-
mediaType: img.file.type,
|
|
2199
|
-
}))
|
|
1162
|
+
? imagesToSend.map(img => ({ type: 'base64' as const, data: img.base64, mediaType: img.file.type }))
|
|
2200
1163
|
: undefined;
|
|
2201
|
-
|
|
2202
1164
|
const response = await fetch(MCP_STREAM_API, {
|
|
2203
1165
|
method: 'POST',
|
|
2204
1166
|
headers: { 'Content-Type': 'application/json' },
|
|
2205
1167
|
body: JSON.stringify({
|
|
2206
1168
|
prompt: userInput,
|
|
2207
1169
|
conversation: newConversation,
|
|
2208
|
-
fileName: activeChatId || undefined,
|
|
2209
|
-
isUpdate: activeChatId && conversation.length > 0,
|
|
2210
|
-
originalTitle: activeTitle || undefined,
|
|
2211
|
-
storyId: activeChatId || undefined,
|
|
1170
|
+
fileName: state.activeChatId || undefined,
|
|
1171
|
+
isUpdate: state.activeChatId && state.conversation.length > 0,
|
|
1172
|
+
originalTitle: state.activeTitle || undefined,
|
|
1173
|
+
storyId: state.activeChatId || undefined,
|
|
2212
1174
|
images: imagePayload,
|
|
2213
1175
|
visionMode: hasImages ? 'screenshot_to_story' : undefined,
|
|
2214
|
-
provider: selectedProvider || undefined,
|
|
2215
|
-
model: selectedModel || undefined,
|
|
2216
|
-
considerations: considerations || undefined,
|
|
1176
|
+
provider: state.selectedProvider || undefined,
|
|
1177
|
+
model: state.selectedModel || undefined,
|
|
1178
|
+
considerations: state.considerations || undefined,
|
|
2217
1179
|
}),
|
|
2218
1180
|
signal: abortControllerRef.current.signal,
|
|
2219
1181
|
});
|
|
2220
|
-
|
|
2221
|
-
if (!response.ok) {
|
|
2222
|
-
throw new Error(`Streaming request failed: ${response.status}`);
|
|
2223
|
-
}
|
|
2224
|
-
|
|
1182
|
+
if (!response.ok) throw new Error(`Streaming request failed: ${response.status}`);
|
|
2225
1183
|
const reader = response.body?.getReader();
|
|
2226
|
-
if (!reader)
|
|
2227
|
-
throw new Error('No response body');
|
|
2228
|
-
}
|
|
2229
|
-
|
|
1184
|
+
if (!reader) throw new Error('No response body');
|
|
2230
1185
|
const decoder = new TextDecoder();
|
|
2231
1186
|
let buffer = '';
|
|
2232
1187
|
let completionData: CompletionFeedback | null = null;
|
|
2233
1188
|
let errorData: ErrorFeedback | null = null;
|
|
2234
|
-
|
|
2235
1189
|
while (true) {
|
|
2236
1190
|
const { done, value } = await reader.read();
|
|
2237
1191
|
if (done) break;
|
|
2238
|
-
|
|
2239
1192
|
buffer += decoder.decode(value, { stream: true });
|
|
2240
|
-
|
|
2241
|
-
// Parse SSE events from buffer
|
|
2242
1193
|
const lines = buffer.split('\n');
|
|
2243
|
-
buffer = lines.pop() || '';
|
|
2244
|
-
|
|
1194
|
+
buffer = lines.pop() || '';
|
|
2245
1195
|
for (const line of lines) {
|
|
2246
1196
|
if (line.startsWith('data: ')) {
|
|
2247
1197
|
try {
|
|
2248
1198
|
const event: StreamEvent = JSON.parse(line.slice(6));
|
|
2249
|
-
|
|
2250
|
-
// Update streaming state based on event type
|
|
2251
1199
|
switch (event.type) {
|
|
2252
1200
|
case 'intent':
|
|
2253
|
-
|
|
1201
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { intent: event.data as IntentPreview } });
|
|
2254
1202
|
break;
|
|
2255
1203
|
case 'progress':
|
|
2256
|
-
|
|
1204
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { progress: event.data as ProgressUpdate } });
|
|
2257
1205
|
break;
|
|
2258
1206
|
case 'validation':
|
|
2259
|
-
|
|
1207
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { validation: event.data as ValidationFeedback } });
|
|
2260
1208
|
break;
|
|
2261
1209
|
case 'retry':
|
|
2262
|
-
|
|
1210
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { retry: event.data as RetryInfo } });
|
|
2263
1211
|
break;
|
|
2264
1212
|
case 'completion':
|
|
2265
1213
|
completionData = event.data as CompletionFeedback;
|
|
2266
|
-
|
|
1214
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { completion: completionData } });
|
|
2267
1215
|
break;
|
|
2268
1216
|
case 'error':
|
|
2269
1217
|
errorData = event.data as ErrorFeedback;
|
|
2270
|
-
|
|
1218
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { error: errorData } });
|
|
2271
1219
|
break;
|
|
2272
1220
|
}
|
|
2273
|
-
} catch
|
|
2274
|
-
console.warn('Failed to parse SSE event:', line
|
|
1221
|
+
} catch {
|
|
1222
|
+
console.warn('Failed to parse SSE event:', line);
|
|
2275
1223
|
}
|
|
2276
1224
|
}
|
|
2277
1225
|
}
|
|
2278
1226
|
}
|
|
2279
|
-
|
|
2280
|
-
// Handle completion or error
|
|
2281
1227
|
if (completionData) {
|
|
2282
1228
|
finalizeStreamingConversation(newConversation, completionData, userInput);
|
|
2283
1229
|
} else if (errorData) {
|
|
2284
|
-
|
|
1230
|
+
dispatch({ type: 'SET_ERROR', payload: errorData.message });
|
|
2285
1231
|
const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorData.message}\n\n${errorData.suggestion || ''}` }];
|
|
2286
|
-
|
|
1232
|
+
dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
|
|
2287
1233
|
}
|
|
2288
|
-
|
|
2289
1234
|
} catch (err: unknown) {
|
|
2290
|
-
if ((err as Error).name === 'AbortError')
|
|
2291
|
-
console.log('Request aborted');
|
|
2292
|
-
return;
|
|
2293
|
-
}
|
|
2294
|
-
|
|
2295
|
-
// Fall back to non-streaming on error
|
|
1235
|
+
if ((err as Error).name === 'AbortError') return;
|
|
2296
1236
|
console.warn('Streaming failed, falling back to non-streaming:', err);
|
|
2297
|
-
|
|
2298
|
-
|
|
1237
|
+
dispatch({ type: 'SET_STREAMING_STATE', payload: null });
|
|
2299
1238
|
try {
|
|
2300
|
-
const
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
}
|
|
2312
|
-
|
|
1239
|
+
const res = await fetch(MCP_API, {
|
|
1240
|
+
method: 'POST',
|
|
1241
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1242
|
+
body: JSON.stringify({
|
|
1243
|
+
prompt: userInput,
|
|
1244
|
+
conversation: newConversation,
|
|
1245
|
+
fileName: state.activeChatId || undefined,
|
|
1246
|
+
provider: state.selectedProvider || undefined,
|
|
1247
|
+
model: state.selectedModel || undefined,
|
|
1248
|
+
considerations: state.considerations || undefined,
|
|
1249
|
+
}),
|
|
1250
|
+
});
|
|
1251
|
+
const data = await res.json();
|
|
1252
|
+
if (!res.ok || !data.success) throw new Error(data.error || 'Story generation failed');
|
|
1253
|
+
const responseMessage = `[SUCCESS] **Created: "${data.title}"**\n\nStory generated successfully.`;
|
|
2313
1254
|
const aiMsg: Message = { role: 'ai', content: responseMessage };
|
|
2314
1255
|
const updatedConversation = [...newConversation, aiMsg];
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
// Update chat session
|
|
2318
|
-
const isUpdate = activeChatId && conversation.length > 0;
|
|
2319
|
-
if (isUpdate && activeChatId) {
|
|
2320
|
-
const updatedSession: ChatSession = {
|
|
2321
|
-
id: activeChatId,
|
|
2322
|
-
title: activeTitle,
|
|
2323
|
-
fileName: data.fileName || activeChatId,
|
|
2324
|
-
conversation: updatedConversation,
|
|
2325
|
-
lastUpdated: Date.now(),
|
|
2326
|
-
};
|
|
2327
|
-
const chats = loadChats();
|
|
2328
|
-
const chatIndex = chats.findIndex(c => c.id === activeChatId);
|
|
2329
|
-
if (chatIndex !== -1) chats[chatIndex] = updatedSession;
|
|
2330
|
-
saveChats(chats);
|
|
2331
|
-
setRecentChats(chats);
|
|
2332
|
-
} else {
|
|
2333
|
-
const chatId = data.storyId || data.fileName || Date.now().toString();
|
|
2334
|
-
const chatTitle = data.title || userInput;
|
|
2335
|
-
setActiveChatId(chatId);
|
|
2336
|
-
setActiveTitle(chatTitle);
|
|
2337
|
-
const newSession: ChatSession = {
|
|
2338
|
-
id: chatId,
|
|
2339
|
-
title: chatTitle,
|
|
2340
|
-
fileName: data.fileName || '',
|
|
2341
|
-
conversation: updatedConversation,
|
|
2342
|
-
lastUpdated: Date.now(),
|
|
2343
|
-
};
|
|
2344
|
-
const chats = loadChats().filter(c => c.id !== chatId);
|
|
2345
|
-
chats.unshift(newSession);
|
|
2346
|
-
if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
|
|
2347
|
-
saveChats(chats);
|
|
2348
|
-
setRecentChats(chats);
|
|
2349
|
-
|
|
2350
|
-
// Auto-navigate to the newly created story
|
|
2351
|
-
navigateToNewStory(chatTitle, data.code);
|
|
2352
|
-
}
|
|
1256
|
+
dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
|
|
2353
1257
|
} catch (fallbackErr: unknown) {
|
|
2354
1258
|
const errorMessage = fallbackErr instanceof Error ? fallbackErr.message : 'Unknown error';
|
|
2355
|
-
|
|
1259
|
+
dispatch({ type: 'SET_ERROR', payload: errorMessage });
|
|
2356
1260
|
const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
|
|
2357
|
-
|
|
1261
|
+
dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
|
|
2358
1262
|
}
|
|
2359
1263
|
} finally {
|
|
2360
|
-
|
|
2361
|
-
|
|
1264
|
+
dispatch({ type: 'SET_LOADING', payload: false });
|
|
1265
|
+
dispatch({ type: 'SET_STREAMING_STATE', payload: null });
|
|
2362
1266
|
abortControllerRef.current = null;
|
|
2363
1267
|
}
|
|
2364
|
-
} else {
|
|
2365
|
-
// Non-streaming mode (original implementation)
|
|
2366
|
-
try {
|
|
2367
|
-
const data = await handleSendNonStreaming(userInput, newConversation);
|
|
2368
|
-
|
|
2369
|
-
let responseMessage: string;
|
|
2370
|
-
const statusMarker = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '[WRENCH]' : '[TIP]') : '[SUCCESS]';
|
|
2371
|
-
|
|
2372
|
-
// Build conversational response for non-streaming mode
|
|
2373
|
-
if (data.isUpdate) {
|
|
2374
|
-
responseMessage = `${statusMarker} **Updated: "${data.title}"**\n\nI've made the requested changes to your component. You can view the updated version in Storybook.\n\n_Check the Docs tab to see both the rendered component and its code._`;
|
|
2375
|
-
} else {
|
|
2376
|
-
responseMessage = `${statusMarker} **Created: "${data.title}"**\n\nI've generated the component with the requested features. You can view it in Storybook where you'll see both the rendered component and its markup.\n\n[TIP] **Note**: If you don't see the story immediately, you may need to refresh your Storybook page (Cmd/Ctrl + R).`;
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
|
-
const aiMsg: Message = { role: 'ai', content: responseMessage };
|
|
2380
|
-
const updatedConversation = [...newConversation, aiMsg];
|
|
2381
|
-
setConversation(updatedConversation);
|
|
2382
|
-
|
|
2383
|
-
const isUpdate = activeChatId && conversation.length > 0;
|
|
2384
|
-
if (isUpdate && activeChatId) {
|
|
2385
|
-
const updatedSession: ChatSession = {
|
|
2386
|
-
id: activeChatId,
|
|
2387
|
-
title: activeTitle,
|
|
2388
|
-
fileName: data.fileName || activeChatId,
|
|
2389
|
-
conversation: updatedConversation,
|
|
2390
|
-
lastUpdated: Date.now(),
|
|
2391
|
-
};
|
|
2392
|
-
const chats = loadChats();
|
|
2393
|
-
const chatIndex = chats.findIndex(c => c.id === activeChatId);
|
|
2394
|
-
if (chatIndex !== -1) chats[chatIndex] = updatedSession;
|
|
2395
|
-
saveChats(chats);
|
|
2396
|
-
setRecentChats(chats);
|
|
2397
|
-
} else {
|
|
2398
|
-
const chatId = data.storyId || data.fileName || Date.now().toString();
|
|
2399
|
-
const chatTitle = data.title || userInput;
|
|
2400
|
-
setActiveChatId(chatId);
|
|
2401
|
-
setActiveTitle(chatTitle);
|
|
2402
|
-
const newSession: ChatSession = {
|
|
2403
|
-
id: chatId,
|
|
2404
|
-
title: chatTitle,
|
|
2405
|
-
fileName: data.fileName || '',
|
|
2406
|
-
conversation: updatedConversation,
|
|
2407
|
-
lastUpdated: Date.now(),
|
|
2408
|
-
};
|
|
2409
|
-
const chats = loadChats().filter(c => c.id !== chatId);
|
|
2410
|
-
chats.unshift(newSession);
|
|
2411
|
-
if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
|
|
2412
|
-
saveChats(chats);
|
|
2413
|
-
setRecentChats(chats);
|
|
2414
|
-
}
|
|
2415
|
-
} catch (err: unknown) {
|
|
2416
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2417
|
-
setError(errorMessage);
|
|
2418
|
-
const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
|
|
2419
|
-
setConversation(errorConversation);
|
|
2420
|
-
} finally {
|
|
2421
|
-
setLoading(false);
|
|
2422
|
-
}
|
|
2423
1268
|
}
|
|
2424
1269
|
};
|
|
2425
1270
|
|
|
1271
|
+
// Chat management
|
|
2426
1272
|
const handleSelectChat = (chat: ChatSession) => {
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
setActiveTitle(chat.title);
|
|
2430
|
-
};
|
|
2431
|
-
|
|
2432
|
-
const handleNewChat = () => {
|
|
2433
|
-
setConversation([]);
|
|
2434
|
-
setActiveChatId(null);
|
|
2435
|
-
setActiveTitle('');
|
|
1273
|
+
dispatch({ type: 'SET_CONVERSATION', payload: chat.conversation });
|
|
1274
|
+
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
|
|
2436
1275
|
};
|
|
2437
1276
|
|
|
2438
|
-
const
|
|
2439
|
-
e.stopPropagation(); // Prevent selecting the chat
|
|
1277
|
+
const handleNewChat = () => dispatch({ type: 'NEW_CHAT' });
|
|
2440
1278
|
|
|
1279
|
+
const handleDeleteChat = async (chatId: string, e?: React.MouseEvent) => {
|
|
1280
|
+
if (e) e.stopPropagation();
|
|
1281
|
+
setContextMenuId(null);
|
|
2441
1282
|
if (confirm('Delete this story and chat? This action cannot be undone.')) {
|
|
2442
1283
|
const success = await deleteStoryAndChat(chatId);
|
|
2443
|
-
|
|
2444
1284
|
if (success) {
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
// If we deleted the active chat, switch to another or clear
|
|
2450
|
-
if (activeChatId === chatId) {
|
|
1285
|
+
const updatedChats = state.recentChats.filter(chat => chat.id !== chatId);
|
|
1286
|
+
dispatch({ type: 'SET_RECENT_CHATS', payload: updatedChats });
|
|
1287
|
+
if (state.activeChatId === chatId) {
|
|
2451
1288
|
if (updatedChats.length > 0) {
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
setActiveTitle(updatedChats[0].title);
|
|
1289
|
+
dispatch({ type: 'SET_CONVERSATION', payload: updatedChats[0].conversation });
|
|
1290
|
+
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: updatedChats[0].id, title: updatedChats[0].title } });
|
|
2455
1291
|
} else {
|
|
2456
1292
|
handleNewChat();
|
|
2457
1293
|
}
|
|
@@ -2462,500 +1298,360 @@ function StoryUIPanel() {
|
|
|
2462
1298
|
}
|
|
2463
1299
|
};
|
|
2464
1300
|
|
|
1301
|
+
const handleStartRename = (chatId: string, currentTitle: string, e?: React.MouseEvent) => {
|
|
1302
|
+
if (e) e.stopPropagation();
|
|
1303
|
+
setContextMenuId(null);
|
|
1304
|
+
setRenamingChatId(chatId);
|
|
1305
|
+
setRenameValue(currentTitle);
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
const handleConfirmRename = (chatId: string) => {
|
|
1309
|
+
if (!renameValue.trim()) {
|
|
1310
|
+
setRenamingChatId(null);
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
const chats = loadChats();
|
|
1314
|
+
const chatIndex = chats.findIndex(c => c.id === chatId);
|
|
1315
|
+
if (chatIndex !== -1) {
|
|
1316
|
+
chats[chatIndex].title = renameValue.trim();
|
|
1317
|
+
saveChats(chats);
|
|
1318
|
+
dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
|
|
1319
|
+
if (state.activeChatId === chatId) {
|
|
1320
|
+
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chatId, title: renameValue.trim() } });
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
setRenamingChatId(null);
|
|
1324
|
+
setRenameValue('');
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
const handleCancelRename = () => {
|
|
1328
|
+
setRenamingChatId(null);
|
|
1329
|
+
setRenameValue('');
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
// Orphan story handlers
|
|
1333
|
+
const toggleSelectAll = () => {
|
|
1334
|
+
if (state.selectedStoryIds.size === state.orphanStories.length) {
|
|
1335
|
+
dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
|
|
1336
|
+
} else {
|
|
1337
|
+
dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set(state.orphanStories.map(s => s.id)) });
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
const handleBulkDelete = async () => {
|
|
1342
|
+
if (state.selectedStoryIds.size === 0) return;
|
|
1343
|
+
const count = state.selectedStoryIds.size;
|
|
1344
|
+
if (!confirm(`Delete ${count} selected ${count === 1 ? 'story' : 'stories'}?`)) return;
|
|
1345
|
+
dispatch({ type: 'SET_BULK_DELETING', payload: true });
|
|
1346
|
+
try {
|
|
1347
|
+
const response = await fetch(`${STORIES_API}/delete-bulk`, {
|
|
1348
|
+
method: 'POST',
|
|
1349
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1350
|
+
body: JSON.stringify({ ids: Array.from(state.selectedStoryIds) }),
|
|
1351
|
+
});
|
|
1352
|
+
if (response.ok) {
|
|
1353
|
+
dispatch({ type: 'SET_ORPHAN_STORIES', payload: state.orphanStories.filter(s => !state.selectedStoryIds.has(s.id)) });
|
|
1354
|
+
dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
|
|
1355
|
+
} else {
|
|
1356
|
+
alert('Failed to delete some stories.');
|
|
1357
|
+
}
|
|
1358
|
+
} catch {
|
|
1359
|
+
alert('Failed to delete stories.');
|
|
1360
|
+
} finally {
|
|
1361
|
+
dispatch({ type: 'SET_BULK_DELETING', payload: false });
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
const handleClearAll = async () => {
|
|
1366
|
+
if (state.orphanStories.length === 0) return;
|
|
1367
|
+
if (!confirm(`Delete ALL ${state.orphanStories.length} generated stories?`)) return;
|
|
1368
|
+
dispatch({ type: 'SET_BULK_DELETING', payload: true });
|
|
1369
|
+
try {
|
|
1370
|
+
const response = await fetch(STORIES_API, { method: 'DELETE' });
|
|
1371
|
+
if (response.ok) {
|
|
1372
|
+
dispatch({ type: 'SET_ORPHAN_STORIES', payload: [] });
|
|
1373
|
+
dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
|
|
1374
|
+
} else {
|
|
1375
|
+
alert('Failed to clear stories.');
|
|
1376
|
+
}
|
|
1377
|
+
} catch {
|
|
1378
|
+
alert('Failed to clear stories.');
|
|
1379
|
+
} finally {
|
|
1380
|
+
dispatch({ type: 'SET_BULK_DELETING', payload: false });
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
const handleDeleteOrphan = async (storyId: string) => {
|
|
1385
|
+
try {
|
|
1386
|
+
const response = await fetch(`${STORIES_API}/${storyId}`, { method: 'DELETE' });
|
|
1387
|
+
if (response.ok) {
|
|
1388
|
+
dispatch({ type: 'SET_ORPHAN_STORIES', payload: state.orphanStories.filter(s => s.id !== storyId) });
|
|
1389
|
+
const newSet = new Set(state.selectedStoryIds);
|
|
1390
|
+
newSet.delete(storyId);
|
|
1391
|
+
dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: newSet });
|
|
1392
|
+
}
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
console.error('Error deleting orphan story:', err);
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
// ============================================
|
|
1399
|
+
// Render
|
|
1400
|
+
// ============================================
|
|
1401
|
+
|
|
2465
1402
|
return (
|
|
2466
|
-
<div className=
|
|
1403
|
+
<div className={`sui-root ${state.isDarkMode ? 'dark' : ''}`}>
|
|
2467
1404
|
{/* Sidebar */}
|
|
2468
|
-
<
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
{sidebarOpen && (
|
|
2473
|
-
<div style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
|
|
1405
|
+
<aside className={`sui-sidebar ${state.sidebarOpen ? '' : 'collapsed'}`} aria-label="Chat history">
|
|
1406
|
+
{state.sidebarOpen && (
|
|
1407
|
+
<div className="sui-sidebar-content">
|
|
1408
|
+
{/* Toggle */}
|
|
2474
1409
|
<button
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
onMouseEnter={(e) => {
|
|
2479
|
-
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.25)';
|
|
2480
|
-
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
|
|
2481
|
-
}}
|
|
2482
|
-
onMouseLeave={(e) => {
|
|
2483
|
-
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
|
|
2484
|
-
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.3)';
|
|
2485
|
-
}}
|
|
1410
|
+
className="sui-button sui-button-ghost"
|
|
1411
|
+
onClick={() => dispatch({ type: 'TOGGLE_SIDEBAR' })}
|
|
1412
|
+
style={{ width: '100%', marginBottom: '12px', justifyContent: 'flex-start' }}
|
|
2486
1413
|
>
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
</svg>
|
|
2490
|
-
<span>Chats</span>
|
|
1414
|
+
{Icons.panelLeft}
|
|
1415
|
+
<span style={{ marginLeft: '8px' }}>Hide sidebar</span>
|
|
2491
1416
|
</button>
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
e.currentTarget.style.background = '#2563eb';
|
|
2497
|
-
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4)';
|
|
2498
|
-
}}
|
|
2499
|
-
onMouseLeave={(e) => {
|
|
2500
|
-
e.currentTarget.style.background = '#3b82f6';
|
|
2501
|
-
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.25)';
|
|
2502
|
-
}}
|
|
2503
|
-
>
|
|
2504
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
2505
|
-
<line x1="12" y1="5" x2="12" y2="19"/>
|
|
2506
|
-
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
2507
|
-
</svg>
|
|
1417
|
+
|
|
1418
|
+
{/* New Chat */}
|
|
1419
|
+
<button className="sui-button sui-button-default" onClick={handleNewChat} style={{ width: '100%', marginBottom: '16px' }}>
|
|
1420
|
+
{Icons.plus}
|
|
2508
1421
|
<span>New Chat</span>
|
|
2509
1422
|
</button>
|
|
2510
|
-
{recentChats.length > 0 && (
|
|
2511
|
-
<div style={{
|
|
2512
|
-
color: '#64748b',
|
|
2513
|
-
fontSize: '12px',
|
|
2514
|
-
marginBottom: '8px',
|
|
2515
|
-
fontWeight: '500',
|
|
2516
|
-
textTransform: 'uppercase',
|
|
2517
|
-
letterSpacing: '0.05em',
|
|
2518
|
-
}}>
|
|
2519
|
-
Recent Chats
|
|
2520
|
-
</div>
|
|
2521
|
-
)}
|
|
2522
|
-
{recentChats.map(chat => (
|
|
2523
|
-
<div
|
|
2524
|
-
key={chat.id}
|
|
2525
|
-
onClick={() => handleSelectChat(chat)}
|
|
2526
|
-
style={{
|
|
2527
|
-
...STYLES.chatItem,
|
|
2528
|
-
...(activeChatId === chat.id ? STYLES.chatItemActive : {}),
|
|
2529
|
-
}}
|
|
2530
|
-
onMouseEnter={(e) => {
|
|
2531
|
-
if (activeChatId !== chat.id) {
|
|
2532
|
-
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.12)';
|
|
2533
|
-
}
|
|
2534
|
-
const deleteBtn = e.currentTarget.querySelector('.delete-btn') as HTMLElement;
|
|
2535
|
-
if (deleteBtn) deleteBtn.style.opacity = '1';
|
|
2536
|
-
}}
|
|
2537
|
-
onMouseLeave={(e) => {
|
|
2538
|
-
if (activeChatId !== chat.id) {
|
|
2539
|
-
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)';
|
|
2540
|
-
}
|
|
2541
|
-
const deleteBtn = e.currentTarget.querySelector('.delete-btn') as HTMLElement;
|
|
2542
|
-
if (deleteBtn) deleteBtn.style.opacity = '0';
|
|
2543
|
-
}}
|
|
2544
|
-
>
|
|
2545
|
-
<div style={STYLES.chatItemTitle}>{chat.title}</div>
|
|
2546
|
-
<div style={STYLES.chatItemTime}>{formatTime(chat.lastUpdated)}</div>
|
|
2547
|
-
<button
|
|
2548
|
-
className="delete-btn"
|
|
2549
|
-
onClick={(e) => handleDeleteChat(chat.id, e)}
|
|
2550
|
-
style={STYLES.deleteButton}
|
|
2551
|
-
title="Delete chat"
|
|
2552
|
-
>
|
|
2553
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
2554
|
-
</button>
|
|
2555
|
-
</div>
|
|
2556
|
-
))}
|
|
2557
1423
|
|
|
2558
|
-
{/*
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
<div
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
}}
|
|
2590
|
-
>
|
|
2591
|
-
<div style={STYLES.chatItemTitle}>{story.title}</div>
|
|
2592
|
-
<div style={{ ...STYLES.chatItemTime, fontSize: '11px' }}>
|
|
2593
|
-
{story.fileName}
|
|
1424
|
+
{/* Chat history */}
|
|
1425
|
+
<div className="sui-sidebar-chats">
|
|
1426
|
+
{state.recentChats.map(chat => (
|
|
1427
|
+
<div
|
|
1428
|
+
key={chat.id}
|
|
1429
|
+
className={`sui-chat-item ${state.activeChatId === chat.id ? 'active' : ''} ${contextMenuId === chat.id ? 'menu-open' : ''}`}
|
|
1430
|
+
onClick={() => renamingChatId !== chat.id && handleSelectChat(chat)}
|
|
1431
|
+
role="button"
|
|
1432
|
+
tabIndex={0}
|
|
1433
|
+
onKeyDown={e => e.key === 'Enter' && renamingChatId !== chat.id && handleSelectChat(chat)}
|
|
1434
|
+
>
|
|
1435
|
+
{renamingChatId === chat.id ? (
|
|
1436
|
+
<div className="sui-chat-item-rename">
|
|
1437
|
+
<input
|
|
1438
|
+
type="text"
|
|
1439
|
+
className="sui-rename-input"
|
|
1440
|
+
value={renameValue}
|
|
1441
|
+
onChange={e => setRenameValue(e.target.value)}
|
|
1442
|
+
onKeyDown={e => {
|
|
1443
|
+
if (e.key === 'Enter') handleConfirmRename(chat.id);
|
|
1444
|
+
if (e.key === 'Escape') handleCancelRename();
|
|
1445
|
+
}}
|
|
1446
|
+
onClick={e => e.stopPropagation()}
|
|
1447
|
+
autoFocus
|
|
1448
|
+
/>
|
|
1449
|
+
<button className="sui-button sui-button-icon sui-button-sm" onClick={e => { e.stopPropagation(); handleConfirmRename(chat.id); }} aria-label="Save">
|
|
1450
|
+
{Icons.check}
|
|
1451
|
+
</button>
|
|
1452
|
+
<button className="sui-button sui-button-icon sui-button-sm" onClick={e => { e.stopPropagation(); handleCancelRename(); }} aria-label="Cancel">
|
|
1453
|
+
{Icons.x}
|
|
1454
|
+
</button>
|
|
2594
1455
|
</div>
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
1456
|
+
) : (
|
|
1457
|
+
<>
|
|
1458
|
+
<div className="sui-chat-item-title">{chat.title}</div>
|
|
1459
|
+
<div className="sui-chat-item-actions">
|
|
1460
|
+
<button
|
|
1461
|
+
className="sui-chat-item-menu sui-button sui-button-icon sui-button-sm"
|
|
1462
|
+
onClick={e => { e.stopPropagation(); setContextMenuId(contextMenuId === chat.id ? null : chat.id); }}
|
|
1463
|
+
aria-label="More options"
|
|
1464
|
+
>
|
|
1465
|
+
{Icons.moreVertical}
|
|
1466
|
+
</button>
|
|
1467
|
+
{contextMenuId === chat.id && (
|
|
1468
|
+
<div className="sui-context-menu">
|
|
1469
|
+
<button className="sui-context-menu-item" onClick={e => handleStartRename(chat.id, chat.title, e)}>
|
|
1470
|
+
{Icons.pencil}
|
|
1471
|
+
<span>Rename</span>
|
|
1472
|
+
</button>
|
|
1473
|
+
<button className="sui-context-menu-item sui-context-menu-item-danger" onClick={e => handleDeleteChat(chat.id, e)}>
|
|
1474
|
+
{Icons.trash}
|
|
1475
|
+
<span>Delete</span>
|
|
1476
|
+
</button>
|
|
1477
|
+
</div>
|
|
1478
|
+
)}
|
|
1479
|
+
</div>
|
|
1480
|
+
</>
|
|
1481
|
+
)}
|
|
1482
|
+
</div>
|
|
1483
|
+
))}
|
|
1484
|
+
</div>
|
|
1485
|
+
|
|
2621
1486
|
</div>
|
|
2622
1487
|
)}
|
|
2623
|
-
{!sidebarOpen && (
|
|
2624
|
-
<div style={{ padding: '
|
|
2625
|
-
<button
|
|
2626
|
-
|
|
2627
|
-
style={{
|
|
2628
|
-
...STYLES.sidebarToggle,
|
|
2629
|
-
width: '38px',
|
|
2630
|
-
height: '38px',
|
|
2631
|
-
padding: '0',
|
|
2632
|
-
fontSize: '16px',
|
|
2633
|
-
borderRadius: '8px',
|
|
2634
|
-
}}
|
|
2635
|
-
title="Expand sidebar"
|
|
2636
|
-
onMouseEnter={(e) => {
|
|
2637
|
-
e.currentTarget.style.transform = 'scale(1.05)';
|
|
2638
|
-
e.currentTarget.style.background = '#2563eb';
|
|
2639
|
-
}}
|
|
2640
|
-
onMouseLeave={(e) => {
|
|
2641
|
-
e.currentTarget.style.transform = 'scale(1)';
|
|
2642
|
-
e.currentTarget.style.background = '#3b82f6';
|
|
2643
|
-
}}
|
|
2644
|
-
>
|
|
2645
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
|
1488
|
+
{!state.sidebarOpen && (
|
|
1489
|
+
<div style={{ padding: '12px', display: 'flex', justifyContent: 'center' }}>
|
|
1490
|
+
<button className="sui-button sui-button-ghost sui-button-icon" onClick={() => dispatch({ type: 'SET_SIDEBAR', payload: true })} aria-label="Show sidebar">
|
|
1491
|
+
{Icons.panelLeft}
|
|
2646
1492
|
</button>
|
|
2647
1493
|
</div>
|
|
2648
1494
|
)}
|
|
2649
|
-
</
|
|
2650
|
-
|
|
2651
|
-
{/* Main
|
|
2652
|
-
<
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
{/* Drop zone overlay */}
|
|
2660
|
-
{isDragging && (
|
|
2661
|
-
<div style={STYLES.dropOverlay}>
|
|
2662
|
-
<div style={STYLES.dropOverlayText}>
|
|
2663
|
-
<svg width={24} height={24} viewBox="0 0 24 24" fill="currentColor">
|
|
2664
|
-
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
2665
|
-
</svg>
|
|
2666
|
-
Drop images here
|
|
1495
|
+
</aside>
|
|
1496
|
+
|
|
1497
|
+
{/* Main */}
|
|
1498
|
+
<main className="sui-main" onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop}>
|
|
1499
|
+
{/* Drop overlay */}
|
|
1500
|
+
{state.isDragging && (
|
|
1501
|
+
<div className="sui-drop-overlay">
|
|
1502
|
+
<div className="sui-drop-overlay-text">
|
|
1503
|
+
{Icons.image}
|
|
1504
|
+
<span>Drop images here</span>
|
|
2667
1505
|
</div>
|
|
2668
1506
|
</div>
|
|
2669
1507
|
)}
|
|
2670
1508
|
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
display: 'inline-block',
|
|
2680
|
-
letterSpacing: '-0.02em'
|
|
2681
|
-
}}>
|
|
2682
|
-
Story UI
|
|
2683
|
-
</h1>
|
|
2684
|
-
<p style={{ fontSize: '14px', margin: '6px 0 0 0', color: '#94a3b8', fontWeight: '500' }}>
|
|
2685
|
-
Generate Storybook stories with AI
|
|
2686
|
-
</p>
|
|
2687
|
-
<div style={{
|
|
2688
|
-
display: 'flex',
|
|
2689
|
-
alignItems: 'center',
|
|
2690
|
-
gap: '6px',
|
|
2691
|
-
marginTop: '10px',
|
|
2692
|
-
fontSize: '11px'
|
|
2693
|
-
}}>
|
|
2694
|
-
<div style={{
|
|
2695
|
-
width: '6px',
|
|
2696
|
-
height: '6px',
|
|
2697
|
-
borderRadius: '50%',
|
|
2698
|
-
backgroundColor: connectionStatus.connected ? '#10b981' : '#f87171'
|
|
2699
|
-
}}></div>
|
|
2700
|
-
<span
|
|
2701
|
-
className={`story-ui-status ${connectionStatus.connected ? 'story-ui-status-connected' : 'story-ui-status-disconnected'}`}
|
|
2702
|
-
style={{ color: connectionStatus.connected ? '#10b981' : '#ef4444', fontWeight: '400' }}
|
|
2703
|
-
>
|
|
2704
|
-
{connectionStatus.connected
|
|
2705
|
-
? `Connected to ${getConnectionDisplayText()}`
|
|
2706
|
-
: `Disconnected: ${connectionStatus.error || 'Server not running'}`
|
|
2707
|
-
}
|
|
2708
|
-
</span>
|
|
1509
|
+
{/* Header */}
|
|
1510
|
+
<header className="sui-header">
|
|
1511
|
+
<div className="sui-header-left">
|
|
1512
|
+
<span className="sui-header-title">Story UI</span>
|
|
1513
|
+
<Badge variant={state.connectionStatus.connected ? 'success' : 'destructive'}>
|
|
1514
|
+
<span className="sui-badge-dot" />
|
|
1515
|
+
{state.connectionStatus.connected ? getConnectionDisplayText() : 'Disconnected'}
|
|
1516
|
+
</Badge>
|
|
2709
1517
|
</div>
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
))}
|
|
2745
|
-
</select>
|
|
2746
|
-
</div>
|
|
2747
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
2748
|
-
<label style={{ fontSize: '12px', color: '#94a3b8', fontWeight: '500' }}>Model:</label>
|
|
2749
|
-
<select
|
|
2750
|
-
value={selectedModel}
|
|
2751
|
-
onChange={(e) => setSelectedModel(e.target.value)}
|
|
2752
|
-
style={{
|
|
2753
|
-
background: '#1e293b',
|
|
2754
|
-
border: '1px solid #334155',
|
|
2755
|
-
borderRadius: '6px',
|
|
2756
|
-
color: '#e2e8f0',
|
|
2757
|
-
padding: '4px 8px',
|
|
2758
|
-
fontSize: '12px',
|
|
2759
|
-
cursor: 'pointer',
|
|
2760
|
-
maxWidth: '200px'
|
|
2761
|
-
}}
|
|
2762
|
-
>
|
|
2763
|
-
{availableProviders
|
|
2764
|
-
.find(p => p.type === selectedProvider)
|
|
2765
|
-
?.models.map(model => (
|
|
1518
|
+
<div className="sui-header-right">
|
|
1519
|
+
{state.connectionStatus.connected && state.availableProviders.length > 0 && (
|
|
1520
|
+
<>
|
|
1521
|
+
<div className="sui-select">
|
|
1522
|
+
<div className="sui-select-trigger">
|
|
1523
|
+
<span>{state.availableProviders.find(p => p.type === state.selectedProvider)?.name || 'Provider'}</span>
|
|
1524
|
+
{Icons.chevronDown}
|
|
1525
|
+
</div>
|
|
1526
|
+
<select
|
|
1527
|
+
className="sui-select-native"
|
|
1528
|
+
value={state.selectedProvider}
|
|
1529
|
+
onChange={e => {
|
|
1530
|
+
const newProvider = e.target.value;
|
|
1531
|
+
dispatch({ type: 'SET_SELECTED_PROVIDER', payload: newProvider });
|
|
1532
|
+
const provider = state.availableProviders.find(p => p.type === newProvider);
|
|
1533
|
+
if (provider?.models.length) dispatch({ type: 'SET_SELECTED_MODEL', payload: provider.models[0] });
|
|
1534
|
+
}}
|
|
1535
|
+
aria-label="Select provider"
|
|
1536
|
+
>
|
|
1537
|
+
{state.availableProviders.map(p => <option key={p.type} value={p.type}>{p.name}</option>)}
|
|
1538
|
+
</select>
|
|
1539
|
+
</div>
|
|
1540
|
+
<div className="sui-select">
|
|
1541
|
+
<div className="sui-select-trigger">
|
|
1542
|
+
<span>{getModelDisplayName(state.selectedModel)}</span>
|
|
1543
|
+
{Icons.chevronDown}
|
|
1544
|
+
</div>
|
|
1545
|
+
<select
|
|
1546
|
+
className="sui-select-native"
|
|
1547
|
+
value={state.selectedModel}
|
|
1548
|
+
onChange={e => dispatch({ type: 'SET_SELECTED_MODEL', payload: e.target.value })}
|
|
1549
|
+
aria-label="Select model"
|
|
1550
|
+
>
|
|
1551
|
+
{state.availableProviders.find(p => p.type === state.selectedProvider)?.models.map(model => (
|
|
2766
1552
|
<option key={model} value={model}>{getModelDisplayName(model)}</option>
|
|
2767
1553
|
))}
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
<div
|
|
2784
|
-
|
|
2785
|
-
|
|
1554
|
+
</select>
|
|
1555
|
+
</div>
|
|
1556
|
+
</>
|
|
1557
|
+
)}
|
|
1558
|
+
</div>
|
|
1559
|
+
</header>
|
|
1560
|
+
|
|
1561
|
+
{/* Chat area */}
|
|
1562
|
+
<section className="sui-chat-area" role="log" aria-live="polite">
|
|
1563
|
+
{state.error && <div className="sui-error" role="alert" style={{ margin: '24px' }}>{state.error}</div>}
|
|
1564
|
+
|
|
1565
|
+
{state.conversation.length === 0 && !state.loading ? (
|
|
1566
|
+
<div className="sui-welcome">
|
|
1567
|
+
<h2 className="sui-welcome-greeting">What would you like to create?</h2>
|
|
1568
|
+
<p className="sui-welcome-subtitle">Describe any UI component and I'll generate a Storybook story</p>
|
|
1569
|
+
<div className="sui-welcome-chips">
|
|
1570
|
+
<button className="sui-chip" onClick={() => dispatch({ type: 'SET_INPUT', payload: 'Create a responsive card with image, title, and description' })}>
|
|
1571
|
+
Card
|
|
1572
|
+
</button>
|
|
1573
|
+
<button className="sui-chip" onClick={() => dispatch({ type: 'SET_INPUT', payload: 'Create a navigation bar with logo and menu links' })}>
|
|
1574
|
+
Navbar
|
|
1575
|
+
</button>
|
|
1576
|
+
<button className="sui-chip" onClick={() => dispatch({ type: 'SET_INPUT', payload: 'Create a form with input fields and validation' })}>
|
|
1577
|
+
Form
|
|
1578
|
+
</button>
|
|
1579
|
+
<button className="sui-chip" onClick={() => dispatch({ type: 'SET_INPUT', payload: 'Create a hero section with headline and call-to-action' })}>
|
|
1580
|
+
Hero
|
|
1581
|
+
</button>
|
|
1582
|
+
<button className="sui-chip" onClick={() => dispatch({ type: 'SET_INPUT', payload: 'Create a button group with primary and secondary actions' })}>
|
|
1583
|
+
Buttons
|
|
1584
|
+
</button>
|
|
1585
|
+
<button className="sui-chip" onClick={() => dispatch({ type: 'SET_INPUT', payload: 'Create a modal dialog with header, content, and footer' })}>
|
|
1586
|
+
Modal
|
|
1587
|
+
</button>
|
|
2786
1588
|
</div>
|
|
2787
1589
|
</div>
|
|
2788
|
-
)
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
<img
|
|
2802
|
-
key={img.id}
|
|
2803
|
-
src={img.base64
|
|
2804
|
-
? `data:${img.mediaType || 'image/png'};base64,${img.base64}`
|
|
2805
|
-
: img.preview}
|
|
2806
|
-
alt="attached"
|
|
2807
|
-
style={STYLES.userMessageImage}
|
|
2808
|
-
/>
|
|
2809
|
-
))}
|
|
1590
|
+
) : (
|
|
1591
|
+
<div className="sui-chat-messages">
|
|
1592
|
+
{state.conversation.map((msg, i) => (
|
|
1593
|
+
<article key={i} className={`sui-message ${msg.role === 'user' ? 'sui-message-user' : 'sui-message-ai'}`}>
|
|
1594
|
+
<div className="sui-message-bubble">
|
|
1595
|
+
{msg.role === 'ai' ? renderMarkdown(msg.content) : msg.content}
|
|
1596
|
+
{msg.role === 'user' && msg.attachedImages && msg.attachedImages.length > 0 && (
|
|
1597
|
+
<div className="sui-message-images">
|
|
1598
|
+
{msg.attachedImages.map(img => (
|
|
1599
|
+
<img key={img.id} src={img.base64 ? `data:${img.mediaType};base64,${img.base64}` : img.preview} alt="attached" className="sui-message-image" />
|
|
1600
|
+
))}
|
|
1601
|
+
</div>
|
|
1602
|
+
)}
|
|
2810
1603
|
</div>
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
) : (
|
|
2821
|
-
<div style={STYLES.loadingMessage}>
|
|
2822
|
-
<span>Generating story</span>
|
|
2823
|
-
<span className="loading-dots"></span>
|
|
1604
|
+
</article>
|
|
1605
|
+
))}
|
|
1606
|
+
{state.loading && (
|
|
1607
|
+
<div className="sui-message sui-message-ai">
|
|
1608
|
+
{state.streamingState ? <ProgressIndicator streamingState={state.streamingState} /> : (
|
|
1609
|
+
<div className="sui-progress">
|
|
1610
|
+
<span className="sui-progress-label">Generating story<span className="sui-loading" /></span>
|
|
1611
|
+
</div>
|
|
1612
|
+
)}
|
|
2824
1613
|
</div>
|
|
2825
1614
|
)}
|
|
1615
|
+
<div ref={chatEndRef} />
|
|
2826
1616
|
</div>
|
|
2827
1617
|
)}
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
{attachedImages.length > 0 && (
|
|
2844
|
-
<div style={STYLES.imagePreviewContainer}>
|
|
2845
|
-
<span style={STYLES.imagePreviewLabel}>
|
|
2846
|
-
<svg width={14} height={14} viewBox="0 0 24 24" fill="currentColor">
|
|
2847
|
-
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
2848
|
-
</svg>
|
|
2849
|
-
{attachedImages.length} image{attachedImages.length > 1 ? 's' : ''} attached
|
|
2850
|
-
</span>
|
|
2851
|
-
{attachedImages.map((img) => (
|
|
2852
|
-
<div key={img.id} style={STYLES.imagePreviewItem}>
|
|
2853
|
-
<img src={img.preview} alt="preview" style={STYLES.imagePreviewImg} />
|
|
2854
|
-
<button
|
|
2855
|
-
type="button"
|
|
2856
|
-
style={STYLES.imageRemoveButton}
|
|
2857
|
-
onClick={() => removeAttachedImage(img.id)}
|
|
2858
|
-
title="Remove image"
|
|
2859
|
-
>
|
|
2860
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
2861
|
-
</button>
|
|
1618
|
+
</section>
|
|
1619
|
+
|
|
1620
|
+
{/* Input area */}
|
|
1621
|
+
<div className="sui-input-area">
|
|
1622
|
+
<div className="sui-input-container">
|
|
1623
|
+
<input ref={fileInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={handleFileSelect} />
|
|
1624
|
+
{state.attachedImages.length > 0 && (
|
|
1625
|
+
<div className="sui-image-previews">
|
|
1626
|
+
<span className="sui-image-preview-label">{Icons.image} {state.attachedImages.length} image{state.attachedImages.length > 1 ? 's' : ''}</span>
|
|
1627
|
+
{state.attachedImages.map(img => (
|
|
1628
|
+
<div key={img.id} className="sui-image-preview-item">
|
|
1629
|
+
<img src={img.preview} alt="preview" className="sui-image-preview-thumb" />
|
|
1630
|
+
<button className="sui-image-preview-remove" onClick={() => removeAttachedImage(img.id)} aria-label="Remove">{Icons.x}</button>
|
|
1631
|
+
</div>
|
|
1632
|
+
))}
|
|
2862
1633
|
</div>
|
|
2863
|
-
)
|
|
1634
|
+
)}
|
|
1635
|
+
<form onSubmit={handleSend} className="sui-input-form" style={state.attachedImages.length > 0 ? { borderTopLeftRadius: 0, borderTopRightRadius: 0 } : undefined}>
|
|
1636
|
+
<button type="button" className="sui-input-form-upload" onClick={() => fileInputRef.current?.click()} disabled={state.loading || state.attachedImages.length >= MAX_IMAGES} aria-label="Attach images">
|
|
1637
|
+
{Icons.image}
|
|
1638
|
+
</button>
|
|
1639
|
+
<input
|
|
1640
|
+
ref={inputRef}
|
|
1641
|
+
type="text"
|
|
1642
|
+
className="sui-input-form-field"
|
|
1643
|
+
value={state.input}
|
|
1644
|
+
onChange={e => dispatch({ type: 'SET_INPUT', payload: e.target.value })}
|
|
1645
|
+
onPaste={handlePaste}
|
|
1646
|
+
placeholder={state.attachedImages.length > 0 ? 'Describe what to create from these images...' : 'Describe a UI component...'}
|
|
1647
|
+
/>
|
|
1648
|
+
<button type="submit" className="sui-input-form-send" disabled={state.loading || (!state.input.trim() && state.attachedImages.length === 0)} aria-label="Send">
|
|
1649
|
+
{Icons.send}
|
|
1650
|
+
</button>
|
|
1651
|
+
</form>
|
|
2864
1652
|
</div>
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
<form onSubmit={handleSend} style={{
|
|
2868
|
-
...STYLES.inputForm,
|
|
2869
|
-
...(attachedImages.length > 0 ? {
|
|
2870
|
-
marginTop: 0,
|
|
2871
|
-
borderTopLeftRadius: 0,
|
|
2872
|
-
borderTopRightRadius: 0,
|
|
2873
|
-
} : {})
|
|
2874
|
-
}}>
|
|
2875
|
-
{/* Upload button */}
|
|
2876
|
-
<button
|
|
2877
|
-
type="button"
|
|
2878
|
-
onClick={() => fileInputRef.current?.click()}
|
|
2879
|
-
disabled={loading || attachedImages.length >= MAX_IMAGES}
|
|
2880
|
-
style={{
|
|
2881
|
-
...STYLES.uploadButton,
|
|
2882
|
-
...(attachedImages.length >= MAX_IMAGES ? {
|
|
2883
|
-
opacity: 0.5,
|
|
2884
|
-
cursor: 'not-allowed',
|
|
2885
|
-
} : {})
|
|
2886
|
-
}}
|
|
2887
|
-
title={attachedImages.length >= MAX_IMAGES
|
|
2888
|
-
? `Maximum ${MAX_IMAGES} images`
|
|
2889
|
-
: 'Attach images (screenshots, designs)'
|
|
2890
|
-
}
|
|
2891
|
-
onMouseEnter={(e) => {
|
|
2892
|
-
if (attachedImages.length < MAX_IMAGES && !loading) {
|
|
2893
|
-
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)';
|
|
2894
|
-
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
|
|
2895
|
-
}
|
|
2896
|
-
}}
|
|
2897
|
-
onMouseLeave={(e) => {
|
|
2898
|
-
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
|
2899
|
-
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
|
|
2900
|
-
}}
|
|
2901
|
-
>
|
|
2902
|
-
<svg width={20} height={20} viewBox="0 0 24 24" fill="currentColor">
|
|
2903
|
-
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
2904
|
-
</svg>
|
|
2905
|
-
</button>
|
|
2906
|
-
|
|
2907
|
-
<input
|
|
2908
|
-
ref={inputRef}
|
|
2909
|
-
type="text"
|
|
2910
|
-
value={input}
|
|
2911
|
-
onChange={e => setInput(e.target.value)}
|
|
2912
|
-
onPaste={handlePaste}
|
|
2913
|
-
placeholder={attachedImages.length > 0
|
|
2914
|
-
? "Describe what to create from these images..."
|
|
2915
|
-
: "Describe a UI component..."
|
|
2916
|
-
}
|
|
2917
|
-
style={STYLES.textInput}
|
|
2918
|
-
onFocus={(e) => {
|
|
2919
|
-
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
|
|
2920
|
-
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(59, 130, 246, 0.1)';
|
|
2921
|
-
}}
|
|
2922
|
-
onBlur={(e) => {
|
|
2923
|
-
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
|
|
2924
|
-
e.currentTarget.style.boxShadow = 'none';
|
|
2925
|
-
}}
|
|
2926
|
-
/>
|
|
2927
|
-
<button
|
|
2928
|
-
type="submit"
|
|
2929
|
-
disabled={loading || (!input.trim() && attachedImages.length === 0)}
|
|
2930
|
-
style={{
|
|
2931
|
-
...STYLES.sendButton,
|
|
2932
|
-
...(loading || (!input.trim() && attachedImages.length === 0) ? {
|
|
2933
|
-
opacity: 0.4,
|
|
2934
|
-
cursor: 'not-allowed',
|
|
2935
|
-
background: '#64748b',
|
|
2936
|
-
boxShadow: 'none'
|
|
2937
|
-
} : {})
|
|
2938
|
-
}}
|
|
2939
|
-
onMouseEnter={(e) => {
|
|
2940
|
-
if (!loading && (input.trim() || attachedImages.length > 0)) {
|
|
2941
|
-
e.currentTarget.style.background = '#2563eb';
|
|
2942
|
-
e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.5)';
|
|
2943
|
-
}
|
|
2944
|
-
}}
|
|
2945
|
-
onMouseLeave={(e) => {
|
|
2946
|
-
if (!loading && (input.trim() || attachedImages.length > 0)) {
|
|
2947
|
-
e.currentTarget.style.background = '#3b82f6';
|
|
2948
|
-
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.35)';
|
|
2949
|
-
}
|
|
2950
|
-
}}
|
|
2951
|
-
>
|
|
2952
|
-
<svg width={18} height={18} viewBox="0 0 24 24" fill="currentColor">
|
|
2953
|
-
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
|
2954
|
-
</svg>
|
|
2955
|
-
<span>Send</span>
|
|
2956
|
-
</button>
|
|
2957
|
-
</form>
|
|
2958
|
-
</div>
|
|
1653
|
+
</div>
|
|
1654
|
+
</main>
|
|
2959
1655
|
</div>
|
|
2960
1656
|
);
|
|
2961
1657
|
}
|