@tpitre/story-ui 3.9.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/templates/StoryUI/StoryUIPanel.d.ts +12 -1
- package/dist/templates/StoryUI/StoryUIPanel.d.ts.map +1 -1
- package/dist/templates/StoryUI/StoryUIPanel.js +751 -1997
- package/package.json +1 -1
- package/templates/StoryUI/StoryUIPanel.css +1440 -0
- package/templates/StoryUI/StoryUIPanel.tsx +1224 -2754
|
@@ -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,1538 +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 [selectedStoryIds, setSelectedStoryIds] = useState<Set<string>>(new Set());
|
|
1660
|
-
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
|
1661
|
-
const chatEndRef = useRef<HTMLDivElement | null>(null);
|
|
1662
|
-
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
1663
|
-
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);
|
|
1664
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
|
+
}, []);
|
|
1665
914
|
|
|
1666
|
-
//
|
|
1667
|
-
|
|
1668
|
-
|
|
915
|
+
// Scroll to bottom on new messages
|
|
916
|
+
useEffect(() => {
|
|
917
|
+
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
918
|
+
}, [state.conversation, state.loading]);
|
|
1669
919
|
|
|
1670
|
-
//
|
|
920
|
+
// File handling
|
|
1671
921
|
const fileToBase64 = (file: File): Promise<string> => {
|
|
1672
922
|
return new Promise((resolve, reject) => {
|
|
1673
923
|
const reader = new FileReader();
|
|
1674
924
|
reader.readAsDataURL(file);
|
|
1675
925
|
reader.onload = () => {
|
|
1676
926
|
const result = reader.result as string;
|
|
1677
|
-
|
|
1678
|
-
const base64 = result.split(',')[1];
|
|
1679
|
-
resolve(base64);
|
|
927
|
+
resolve(result.split(',')[1]);
|
|
1680
928
|
};
|
|
1681
929
|
reader.onerror = error => reject(error);
|
|
1682
930
|
});
|
|
1683
931
|
};
|
|
1684
932
|
|
|
1685
|
-
// Handle file selection
|
|
1686
933
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
1687
934
|
const files = e.target.files;
|
|
1688
935
|
if (!files) return;
|
|
1689
|
-
|
|
1690
|
-
const newImages: AttachedImage[] = [];
|
|
1691
936
|
const errors: string[] = [];
|
|
1692
|
-
|
|
1693
|
-
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++) {
|
|
1694
938
|
const file = files[i];
|
|
1695
|
-
|
|
1696
|
-
// Validate file type
|
|
1697
939
|
if (!file.type.startsWith('image/')) {
|
|
1698
940
|
errors.push(`${file.name}: Not an image file`);
|
|
1699
941
|
continue;
|
|
1700
942
|
}
|
|
1701
|
-
|
|
1702
|
-
// Validate file size
|
|
1703
943
|
if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
|
|
1704
944
|
errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
|
|
1705
945
|
continue;
|
|
1706
946
|
}
|
|
1707
|
-
|
|
1708
947
|
try {
|
|
1709
948
|
const base64 = await fileToBase64(file);
|
|
1710
949
|
const preview = URL.createObjectURL(file);
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
id: `${Date.now()}-${i}`,
|
|
1714
|
-
file,
|
|
1715
|
-
preview,
|
|
1716
|
-
base64,
|
|
1717
|
-
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' },
|
|
1718
953
|
});
|
|
1719
|
-
} catch
|
|
954
|
+
} catch {
|
|
1720
955
|
errors.push(`${file.name}: Failed to process`);
|
|
1721
956
|
}
|
|
1722
957
|
}
|
|
1723
|
-
|
|
1724
|
-
if (
|
|
1725
|
-
setError(errors.join('\n'));
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
setAttachedImages(prev => [...prev, ...newImages]);
|
|
1729
|
-
|
|
1730
|
-
// Reset file input
|
|
1731
|
-
if (fileInputRef.current) {
|
|
1732
|
-
fileInputRef.current.value = '';
|
|
1733
|
-
}
|
|
958
|
+
if (errors.length > 0) dispatch({ type: 'SET_ERROR', payload: errors.join('\n') });
|
|
959
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
1734
960
|
};
|
|
1735
961
|
|
|
1736
|
-
// Remove attached image
|
|
1737
962
|
const removeAttachedImage = (id: string) => {
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
URL.revokeObjectURL(removed.preview);
|
|
1742
|
-
}
|
|
1743
|
-
return prev.filter(img => img.id !== id);
|
|
1744
|
-
});
|
|
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 });
|
|
1745
966
|
};
|
|
1746
967
|
|
|
1747
|
-
// Clear all attached images
|
|
1748
968
|
const clearAttachedImages = () => {
|
|
1749
|
-
attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
|
|
1750
|
-
|
|
969
|
+
state.attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
|
|
970
|
+
dispatch({ type: 'CLEAR_ATTACHED_IMAGES' });
|
|
1751
971
|
};
|
|
1752
972
|
|
|
1753
|
-
// Drag and drop
|
|
1754
|
-
const [isDragging, setIsDragging] = useState(false);
|
|
1755
|
-
|
|
1756
|
-
// Handle drag events
|
|
973
|
+
// Drag and drop handlers
|
|
1757
974
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
|
1758
975
|
e.preventDefault();
|
|
1759
976
|
e.stopPropagation();
|
|
1760
|
-
if (e.dataTransfer.types.includes('Files')) {
|
|
1761
|
-
setIsDragging(true);
|
|
1762
|
-
}
|
|
977
|
+
if (e.dataTransfer.types.includes('Files')) dispatch({ type: 'SET_DRAGGING', payload: true });
|
|
1763
978
|
}, []);
|
|
1764
979
|
|
|
1765
980
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
1766
981
|
e.preventDefault();
|
|
1767
982
|
e.stopPropagation();
|
|
1768
|
-
|
|
1769
|
-
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
1770
|
-
setIsDragging(false);
|
|
1771
|
-
}
|
|
983
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node)) dispatch({ type: 'SET_DRAGGING', payload: false });
|
|
1772
984
|
}, []);
|
|
1773
985
|
|
|
1774
986
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
@@ -1779,348 +991,126 @@ function StoryUIPanel() {
|
|
|
1779
991
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
|
1780
992
|
e.preventDefault();
|
|
1781
993
|
e.stopPropagation();
|
|
1782
|
-
|
|
1783
|
-
|
|
994
|
+
dispatch({ type: 'SET_DRAGGING', payload: false });
|
|
1784
995
|
const files = Array.from(e.dataTransfer.files);
|
|
1785
996
|
const imageFiles = files.filter(f => f.type.startsWith('image/'));
|
|
1786
|
-
|
|
1787
997
|
if (imageFiles.length === 0) {
|
|
1788
|
-
|
|
998
|
+
dispatch({ type: 'SET_ERROR', payload: 'Please drop image files only' });
|
|
1789
999
|
return;
|
|
1790
1000
|
}
|
|
1791
|
-
|
|
1792
|
-
const newImages: AttachedImage[] = [];
|
|
1793
1001
|
const errors: string[] = [];
|
|
1794
|
-
|
|
1795
|
-
for (let i = 0; i < imageFiles.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
|
|
1002
|
+
for (let i = 0; i < imageFiles.length && (state.attachedImages.length + i) < MAX_IMAGES; i++) {
|
|
1796
1003
|
const file = imageFiles[i];
|
|
1797
|
-
|
|
1798
1004
|
if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
|
|
1799
|
-
errors.push(`${file.name}: File too large
|
|
1005
|
+
errors.push(`${file.name}: File too large`);
|
|
1800
1006
|
continue;
|
|
1801
1007
|
}
|
|
1802
|
-
|
|
1803
1008
|
try {
|
|
1804
1009
|
const base64 = await fileToBase64(file);
|
|
1805
|
-
const preview = URL.createObjectURL(file);
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
id: `${Date.now()}-${i}`,
|
|
1809
|
-
file,
|
|
1810
|
-
preview,
|
|
1811
|
-
base64,
|
|
1812
|
-
mediaType: file.type || 'image/png',
|
|
1813
|
-
});
|
|
1814
|
-
} catch (err) {
|
|
1815
|
-
errors.push(`${file.name}: Failed to process`);
|
|
1816
|
-
}
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
if (errors.length > 0) {
|
|
1820
|
-
setError(errors.join('\n'));
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
setAttachedImages(prev => [...prev, ...newImages]);
|
|
1824
|
-
}, [attachedImages.length, fileToBase64]);
|
|
1825
|
-
|
|
1826
|
-
// Handle clipboard paste for images
|
|
1827
|
-
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
|
1828
|
-
const items = e.clipboardData?.items;
|
|
1829
|
-
if (!items) return;
|
|
1830
|
-
|
|
1831
|
-
const imageItems: DataTransferItem[] = [];
|
|
1832
|
-
for (let i = 0; i < items.length; i++) {
|
|
1833
|
-
if (items[i].type.startsWith('image/')) {
|
|
1834
|
-
imageItems.push(items[i]);
|
|
1835
|
-
}
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
if (imageItems.length === 0) return;
|
|
1839
|
-
|
|
1840
|
-
// Prevent default text paste behavior when pasting images
|
|
1841
|
-
e.preventDefault();
|
|
1842
|
-
|
|
1843
|
-
if (attachedImages.length >= MAX_IMAGES) {
|
|
1844
|
-
setError(`Maximum ${MAX_IMAGES} images allowed`);
|
|
1845
|
-
return;
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
const newImages: AttachedImage[] = [];
|
|
1849
|
-
const errors: string[] = [];
|
|
1850
|
-
|
|
1851
|
-
for (let i = 0; i < imageItems.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
|
|
1852
|
-
const item = imageItems[i];
|
|
1853
|
-
const file = item.getAsFile();
|
|
1854
|
-
|
|
1855
|
-
if (!file) {
|
|
1856
|
-
errors.push('Failed to get image from clipboard');
|
|
1857
|
-
continue;
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
|
|
1861
|
-
errors.push(`Pasted image too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
|
|
1862
|
-
continue;
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
try {
|
|
1866
|
-
const base64 = await fileToBase64(file);
|
|
1867
|
-
const preview = URL.createObjectURL(file);
|
|
1868
|
-
|
|
1869
|
-
// Create a meaningful name for pasted images
|
|
1870
|
-
const timestamp = new Date().toISOString().slice(11, 19).replace(/:/g, '-');
|
|
1871
|
-
const renamedFile = new File([file], `pasted-image-${timestamp}.${file.type.split('/')[1] || 'png'}`, { type: file.type });
|
|
1872
|
-
|
|
1873
|
-
newImages.push({
|
|
1874
|
-
id: `paste-${Date.now()}-${i}`,
|
|
1875
|
-
file: renamedFile,
|
|
1876
|
-
preview,
|
|
1877
|
-
base64,
|
|
1878
|
-
mediaType: file.type || 'image/png',
|
|
1010
|
+
const preview = URL.createObjectURL(file);
|
|
1011
|
+
dispatch({
|
|
1012
|
+
type: 'ADD_ATTACHED_IMAGE',
|
|
1013
|
+
payload: { id: `${Date.now()}-${i}`, file, preview, base64, mediaType: file.type || 'image/png' },
|
|
1879
1014
|
});
|
|
1880
|
-
} catch
|
|
1881
|
-
errors.push(
|
|
1015
|
+
} catch {
|
|
1016
|
+
errors.push(`${file.name}: Failed to process`);
|
|
1882
1017
|
}
|
|
1883
1018
|
}
|
|
1019
|
+
if (errors.length > 0) dispatch({ type: 'SET_ERROR', payload: errors.join('\n') });
|
|
1020
|
+
}, [state.attachedImages.length]);
|
|
1884
1021
|
|
|
1885
|
-
|
|
1886
|
-
|
|
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]);
|
|
1887
1029
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
setError(null);
|
|
1894
|
-
}
|
|
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;
|
|
1895
1035
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
setSelectedModel(providersData.current.model);
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
} catch (e) {
|
|
1919
|
-
console.error('Failed to fetch providers:', e);
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
// Fetch design system considerations for environment parity
|
|
1923
|
-
// This ensures production gets the same considerations as local development
|
|
1924
|
-
try {
|
|
1925
|
-
const considerationsRes = await fetch(CONSIDERATIONS_API);
|
|
1926
|
-
if (considerationsRes.ok) {
|
|
1927
|
-
const considerationsData = await considerationsRes.json();
|
|
1928
|
-
if (considerationsData.hasConsiderations && considerationsData.considerations) {
|
|
1929
|
-
setConsiderations(considerationsData.considerations);
|
|
1930
|
-
console.log(`Loaded considerations from ${considerationsData.source}`);
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
|
-
} catch (e) {
|
|
1934
|
-
console.error('Failed to fetch considerations:', e);
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
const syncedChats = await syncWithActualStories();
|
|
1938
|
-
const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
|
|
1939
|
-
setRecentChats(sortedChats);
|
|
1940
|
-
|
|
1941
|
-
if (sortedChats.length > 0) {
|
|
1942
|
-
setConversation(sortedChats[0].conversation);
|
|
1943
|
-
setActiveChatId(sortedChats[0].id);
|
|
1944
|
-
setActiveTitle(sortedChats[0].title);
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
// Fetch orphan stories (stories on disk without chat history)
|
|
1948
|
-
const orphans = await fetchOrphanStories();
|
|
1949
|
-
setOrphanStories(orphans);
|
|
1950
|
-
} else {
|
|
1951
|
-
// Load from local storage if server is not available
|
|
1952
|
-
const localChats = loadChats();
|
|
1953
|
-
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' });
|
|
1954
1055
|
}
|
|
1955
|
-
};
|
|
1956
|
-
|
|
1957
|
-
initializeChats();
|
|
1958
|
-
}, []);
|
|
1959
|
-
|
|
1960
|
-
// Scroll to bottom on new message
|
|
1961
|
-
useEffect(() => {
|
|
1962
|
-
if (chatEndRef.current) {
|
|
1963
|
-
chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
1964
|
-
}
|
|
1965
|
-
}, [conversation, loading]);
|
|
1966
|
-
|
|
1967
|
-
// Helper function for non-streaming fallback
|
|
1968
|
-
const handleSendNonStreaming = async (userInput: string, newConversation: Message[]) => {
|
|
1969
|
-
const res = await fetch(MCP_API, {
|
|
1970
|
-
method: 'POST',
|
|
1971
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1972
|
-
body: JSON.stringify({
|
|
1973
|
-
prompt: userInput,
|
|
1974
|
-
conversation: newConversation,
|
|
1975
|
-
fileName: activeChatId || undefined,
|
|
1976
|
-
provider: selectedProvider || undefined,
|
|
1977
|
-
model: selectedModel || undefined,
|
|
1978
|
-
considerations: considerations || undefined,
|
|
1979
|
-
}),
|
|
1980
|
-
});
|
|
1981
|
-
|
|
1982
|
-
const contentType = res.headers.get('content-type');
|
|
1983
|
-
if (!contentType || !contentType.includes('application/json')) {
|
|
1984
|
-
const text = await res.text();
|
|
1985
|
-
throw new Error(`Server returned non-JSON response. Response: ${text.substring(0, 200)}...`);
|
|
1986
1056
|
}
|
|
1057
|
+
}, [state.attachedImages.length]);
|
|
1987
1058
|
|
|
1988
|
-
|
|
1989
|
-
if (!res.ok || !data.success) throw new Error(data.error || 'Story generation failed');
|
|
1990
|
-
|
|
1991
|
-
return data;
|
|
1992
|
-
};
|
|
1993
|
-
|
|
1994
|
-
// Helper function to build a conversational response from completion data
|
|
1995
|
-
// Uses special markers [SUCCESS], [ERROR], [TIP], [WRENCH] that renderMarkdown converts to icons
|
|
1996
|
-
// Track whether we've shown the refresh hint in this session
|
|
1997
|
-
const hasShownRefreshHint = useRef(false);
|
|
1998
|
-
|
|
1059
|
+
// Build response message
|
|
1999
1060
|
const buildConversationalResponse = (completion: CompletionFeedback, isUpdate: boolean): string => {
|
|
2000
1061
|
const parts: string[] = [];
|
|
2001
1062
|
const statusMarker = completion.success ? '[SUCCESS]' : '[ERROR]';
|
|
2002
|
-
|
|
2003
|
-
// Lead with the result - more conversational
|
|
2004
|
-
if (isUpdate) {
|
|
2005
|
-
parts.push(`${statusMarker} **Updated: "${completion.title}"**`);
|
|
2006
|
-
} else {
|
|
2007
|
-
parts.push(`${statusMarker} **Created: "${completion.title}"**`);
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
// Build component insights with reasons when available
|
|
1063
|
+
parts.push(isUpdate ? `${statusMarker} **Updated: "${completion.title}"**` : `${statusMarker} **Created: "${completion.title}"**`);
|
|
2011
1064
|
const componentCount = completion.componentsUsed?.length || 0;
|
|
2012
1065
|
if (componentCount > 0) {
|
|
2013
|
-
const
|
|
2014
|
-
|
|
2015
|
-
// Check if we have meaningful reasons (not just "Used in composition")
|
|
2016
|
-
const componentsWithReasons = componentList.filter(c =>
|
|
2017
|
-
c.reason && c.reason !== 'Used in composition'
|
|
2018
|
-
);
|
|
2019
|
-
|
|
2020
|
-
if (componentsWithReasons.length > 0) {
|
|
2021
|
-
// Show components with their reasons
|
|
2022
|
-
const insights = componentsWithReasons
|
|
2023
|
-
.slice(0, 3)
|
|
2024
|
-
.map(c => `\`${c.name}\` - ${c.reason?.toLowerCase()}`)
|
|
2025
|
-
.join(', ');
|
|
2026
|
-
parts.push(`\nUsed ${insights}${componentCount > 3 ? ` and ${componentCount - 3} more` : ''}.`);
|
|
2027
|
-
} else {
|
|
2028
|
-
// Fallback to simple list
|
|
2029
|
-
const names = componentList.map(c => `\`${c.name}\``).join(', ');
|
|
2030
|
-
parts.push(`\nBuilt with ${names}${componentCount > 5 ? '...' : ''}.`);
|
|
2031
|
-
}
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
// Add layout decisions with educational context
|
|
2035
|
-
if (completion.layoutChoices && completion.layoutChoices.length > 0) {
|
|
2036
|
-
const primaryLayout = completion.layoutChoices[0];
|
|
2037
|
-
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 ? '...' : ''}.`);
|
|
2038
1068
|
}
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
const notableStyles = completion.styleChoices.filter(s =>
|
|
2043
|
-
s.reason && s.reason !== 'Semantic color from design system'
|
|
2044
|
-
);
|
|
2045
|
-
if (notableStyles.length > 0) {
|
|
2046
|
-
const styleInfo = notableStyles[0];
|
|
2047
|
-
parts.push(` Applied \`${styleInfo.value}\` for ${styleInfo.reason?.toLowerCase() || 'visual consistency'}.`);
|
|
2048
|
-
}
|
|
1069
|
+
if (completion.layoutChoices?.length > 0) {
|
|
1070
|
+
const layout = completion.layoutChoices[0];
|
|
1071
|
+
parts.push(`\n\n**Layout:** ${layout.pattern} - ${layout.reason}.`);
|
|
2049
1072
|
}
|
|
2050
|
-
|
|
2051
|
-
// Add validation fixes notice
|
|
2052
1073
|
if (completion.validation?.autoFixApplied) {
|
|
2053
1074
|
parts.push(`\n\n[WRENCH] **Auto-fixed:** Minor syntax issues were automatically corrected.`);
|
|
2054
1075
|
}
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
if (completion.suggestions && completion.suggestions.length > 0) {
|
|
2058
|
-
const suggestion = completion.suggestions[0];
|
|
2059
|
-
// Only show if it's not the generic "review the generated code" message
|
|
2060
|
-
if (!suggestion.toLowerCase().includes('review the generated code')) {
|
|
2061
|
-
parts.push(`\n\n[TIP] **Tip:** ${suggestion}`);
|
|
2062
|
-
}
|
|
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]}`);
|
|
2063
1078
|
}
|
|
2064
|
-
|
|
2065
|
-
// Show refresh hint only once per session for new stories (local mode only)
|
|
2066
|
-
// In Edge mode, stories are stored in Durable Objects, not on filesystem
|
|
2067
1079
|
if (!isUpdate && !hasShownRefreshHint.current) {
|
|
2068
|
-
|
|
2069
|
-
parts.push(`\n\n_Story saved to cloud. View code in chat history recent chats navigation._`);
|
|
2070
|
-
} else {
|
|
2071
|
-
parts.push(`\n\n_Might need toefresh Storybook (Cmd/Ctrl + R) to see new stories in the sidebar._`);
|
|
2072
|
-
}
|
|
1080
|
+
parts.push(isEdgeMode() ? `\n\n_Story saved to cloud._` : `\n\n_Might need to refresh Storybook (Cmd/Ctrl + R) to see new stories._`);
|
|
2073
1081
|
hasShownRefreshHint.current = true;
|
|
2074
1082
|
}
|
|
2075
|
-
|
|
2076
|
-
// Add metrics in a subtle way (if available)
|
|
2077
1083
|
if (completion.metrics?.totalTimeMs) {
|
|
2078
|
-
|
|
2079
|
-
parts.push(`\n\n_${seconds}s_`);
|
|
1084
|
+
parts.push(`\n\n_${(completion.metrics.totalTimeMs / 1000).toFixed(1)}s_`);
|
|
2080
1085
|
}
|
|
2081
|
-
|
|
2082
1086
|
return parts.join('');
|
|
2083
1087
|
};
|
|
2084
1088
|
|
|
2085
|
-
//
|
|
2086
|
-
const finalizeStreamingConversation = useCallback((
|
|
2087
|
-
newConversation: Message[],
|
|
2088
|
-
completion: CompletionFeedback,
|
|
2089
|
-
userInput: string
|
|
2090
|
-
) => {
|
|
2091
|
-
// Build conversational response using rich completion data
|
|
1089
|
+
// Finalize streaming
|
|
1090
|
+
const finalizeStreamingConversation = useCallback((newConversation: Message[], completion: CompletionFeedback, userInput: string) => {
|
|
2092
1091
|
const isUpdate = completion.summary.action === 'updated';
|
|
2093
1092
|
const responseMessage = buildConversationalResponse(completion, isUpdate);
|
|
2094
|
-
|
|
2095
1093
|
const aiMsg: Message = { role: 'ai', content: responseMessage };
|
|
2096
1094
|
const updatedConversation = [...newConversation, aiMsg];
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
const isExistingSession = activeChatId && conversation.length > 0;
|
|
2101
|
-
|
|
2102
|
-
if (isExistingSession && activeChatId) {
|
|
1095
|
+
dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
|
|
1096
|
+
const isExistingSession = state.activeChatId && state.conversation.length > 0;
|
|
1097
|
+
if (isExistingSession && state.activeChatId) {
|
|
2103
1098
|
const updatedSession: ChatSession = {
|
|
2104
|
-
id: activeChatId,
|
|
2105
|
-
title: activeTitle,
|
|
2106
|
-
fileName: completion.fileName || activeChatId,
|
|
1099
|
+
id: state.activeChatId,
|
|
1100
|
+
title: state.activeTitle,
|
|
1101
|
+
fileName: completion.fileName || state.activeChatId,
|
|
2107
1102
|
conversation: updatedConversation,
|
|
2108
1103
|
lastUpdated: Date.now(),
|
|
2109
1104
|
};
|
|
2110
|
-
|
|
2111
1105
|
const chats = loadChats();
|
|
2112
|
-
const chatIndex = chats.findIndex(c => c.id === activeChatId);
|
|
2113
|
-
if (chatIndex !== -1)
|
|
2114
|
-
chats[chatIndex] = updatedSession;
|
|
2115
|
-
}
|
|
1106
|
+
const chatIndex = chats.findIndex(c => c.id === state.activeChatId);
|
|
1107
|
+
if (chatIndex !== -1) chats[chatIndex] = updatedSession;
|
|
2116
1108
|
saveChats(chats);
|
|
2117
|
-
|
|
1109
|
+
dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
|
|
2118
1110
|
} else {
|
|
2119
1111
|
const chatId = completion.storyId || completion.fileName || Date.now().toString();
|
|
2120
1112
|
const chatTitle = completion.title || userInput;
|
|
2121
|
-
|
|
2122
|
-
setActiveTitle(chatTitle);
|
|
2123
|
-
|
|
1113
|
+
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chatId, title: chatTitle } });
|
|
2124
1114
|
const newSession: ChatSession = {
|
|
2125
1115
|
id: chatId,
|
|
2126
1116
|
title: chatTitle,
|
|
@@ -2128,332 +1118,176 @@ function StoryUIPanel() {
|
|
|
2128
1118
|
conversation: updatedConversation,
|
|
2129
1119
|
lastUpdated: Date.now(),
|
|
2130
1120
|
};
|
|
2131
|
-
|
|
2132
1121
|
const chats = loadChats().filter(c => c.id !== chatId);
|
|
2133
1122
|
chats.unshift(newSession);
|
|
2134
|
-
if (chats.length > MAX_RECENT_CHATS)
|
|
2135
|
-
chats.splice(MAX_RECENT_CHATS);
|
|
2136
|
-
}
|
|
1123
|
+
if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
|
|
2137
1124
|
saveChats(chats);
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
// Auto-navigate to the newly created story after HMR processes the file
|
|
2141
|
-
// This prevents the "Couldn't find story after HMR" error by refreshing
|
|
2142
|
-
// after the file system has been updated and HMR has processed the change
|
|
2143
|
-
navigateToNewStory(chatTitle, completion.code);
|
|
1125
|
+
dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
|
|
2144
1126
|
}
|
|
2145
|
-
}, [activeChatId, activeTitle, conversation.length]);
|
|
1127
|
+
}, [state.activeChatId, state.activeTitle, state.conversation.length]);
|
|
2146
1128
|
|
|
1129
|
+
// Handle send
|
|
2147
1130
|
const handleSend = async (e?: React.FormEvent) => {
|
|
2148
1131
|
if (e) e.preventDefault();
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
setError(null);
|
|
2155
|
-
setLoading(true);
|
|
2156
|
-
setStreamingState(null);
|
|
2157
|
-
|
|
2158
|
-
// 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 });
|
|
2159
1137
|
const connectionTest = await testMCPConnection();
|
|
2160
|
-
|
|
2161
|
-
|
|
1138
|
+
dispatch({ type: 'SET_CONNECTION_STATUS', payload: connectionTest });
|
|
2162
1139
|
if (!connectionTest.connected) {
|
|
2163
|
-
|
|
2164
|
-
|
|
1140
|
+
dispatch({ type: 'SET_ERROR', payload: `Cannot connect to MCP server: ${connectionTest.error || 'Server not running'}` });
|
|
1141
|
+
dispatch({ type: 'SET_LOADING', payload: false });
|
|
2165
1142
|
return;
|
|
2166
1143
|
}
|
|
2167
|
-
|
|
2168
|
-
// Capture images before clearing
|
|
2169
|
-
const imagesToSend = [...attachedImages];
|
|
1144
|
+
const imagesToSend = [...state.attachedImages];
|
|
2170
1145
|
const hasImages = imagesToSend.length > 0;
|
|
2171
|
-
|
|
2172
|
-
// Create user message with images
|
|
2173
1146
|
const userMessage: Message = {
|
|
2174
1147
|
role: 'user',
|
|
2175
1148
|
content: userInput,
|
|
2176
|
-
attachedImages: hasImages ? imagesToSend : undefined
|
|
1149
|
+
attachedImages: hasImages ? imagesToSend : undefined,
|
|
2177
1150
|
};
|
|
2178
|
-
const newConversation: Message[] = [...conversation, userMessage];
|
|
2179
|
-
|
|
2180
|
-
|
|
1151
|
+
const newConversation: Message[] = [...state.conversation, userMessage];
|
|
1152
|
+
dispatch({ type: 'SET_CONVERSATION', payload: newConversation });
|
|
1153
|
+
dispatch({ type: 'SET_INPUT', payload: '' });
|
|
2181
1154
|
clearAttachedImages();
|
|
2182
1155
|
|
|
2183
|
-
// Use streaming if enabled
|
|
2184
1156
|
if (USE_STREAMING) {
|
|
2185
1157
|
try {
|
|
2186
|
-
|
|
2187
|
-
if (abortControllerRef.current) {
|
|
2188
|
-
abortControllerRef.current.abort();
|
|
2189
|
-
}
|
|
1158
|
+
if (abortControllerRef.current) abortControllerRef.current.abort();
|
|
2190
1159
|
abortControllerRef.current = new AbortController();
|
|
2191
|
-
|
|
2192
|
-
// Initialize streaming state
|
|
2193
|
-
setStreamingState({});
|
|
2194
|
-
|
|
2195
|
-
// Prepare images for API request
|
|
1160
|
+
dispatch({ type: 'SET_STREAMING_STATE', payload: {} });
|
|
2196
1161
|
const imagePayload = hasImages
|
|
2197
|
-
? imagesToSend.map(img => ({
|
|
2198
|
-
type: 'base64' as const,
|
|
2199
|
-
data: img.base64,
|
|
2200
|
-
mediaType: img.file.type,
|
|
2201
|
-
}))
|
|
1162
|
+
? imagesToSend.map(img => ({ type: 'base64' as const, data: img.base64, mediaType: img.file.type }))
|
|
2202
1163
|
: undefined;
|
|
2203
|
-
|
|
2204
1164
|
const response = await fetch(MCP_STREAM_API, {
|
|
2205
1165
|
method: 'POST',
|
|
2206
1166
|
headers: { 'Content-Type': 'application/json' },
|
|
2207
1167
|
body: JSON.stringify({
|
|
2208
1168
|
prompt: userInput,
|
|
2209
1169
|
conversation: newConversation,
|
|
2210
|
-
fileName: activeChatId || undefined,
|
|
2211
|
-
isUpdate: activeChatId && conversation.length > 0,
|
|
2212
|
-
originalTitle: activeTitle || undefined,
|
|
2213
|
-
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,
|
|
2214
1174
|
images: imagePayload,
|
|
2215
1175
|
visionMode: hasImages ? 'screenshot_to_story' : undefined,
|
|
2216
|
-
provider: selectedProvider || undefined,
|
|
2217
|
-
model: selectedModel || undefined,
|
|
2218
|
-
considerations: considerations || undefined,
|
|
1176
|
+
provider: state.selectedProvider || undefined,
|
|
1177
|
+
model: state.selectedModel || undefined,
|
|
1178
|
+
considerations: state.considerations || undefined,
|
|
2219
1179
|
}),
|
|
2220
1180
|
signal: abortControllerRef.current.signal,
|
|
2221
1181
|
});
|
|
2222
|
-
|
|
2223
|
-
if (!response.ok) {
|
|
2224
|
-
throw new Error(`Streaming request failed: ${response.status}`);
|
|
2225
|
-
}
|
|
2226
|
-
|
|
1182
|
+
if (!response.ok) throw new Error(`Streaming request failed: ${response.status}`);
|
|
2227
1183
|
const reader = response.body?.getReader();
|
|
2228
|
-
if (!reader)
|
|
2229
|
-
throw new Error('No response body');
|
|
2230
|
-
}
|
|
2231
|
-
|
|
1184
|
+
if (!reader) throw new Error('No response body');
|
|
2232
1185
|
const decoder = new TextDecoder();
|
|
2233
1186
|
let buffer = '';
|
|
2234
1187
|
let completionData: CompletionFeedback | null = null;
|
|
2235
1188
|
let errorData: ErrorFeedback | null = null;
|
|
2236
|
-
|
|
2237
1189
|
while (true) {
|
|
2238
1190
|
const { done, value } = await reader.read();
|
|
2239
1191
|
if (done) break;
|
|
2240
|
-
|
|
2241
1192
|
buffer += decoder.decode(value, { stream: true });
|
|
2242
|
-
|
|
2243
|
-
// Parse SSE events from buffer
|
|
2244
1193
|
const lines = buffer.split('\n');
|
|
2245
|
-
buffer = lines.pop() || '';
|
|
2246
|
-
|
|
1194
|
+
buffer = lines.pop() || '';
|
|
2247
1195
|
for (const line of lines) {
|
|
2248
1196
|
if (line.startsWith('data: ')) {
|
|
2249
1197
|
try {
|
|
2250
1198
|
const event: StreamEvent = JSON.parse(line.slice(6));
|
|
2251
|
-
|
|
2252
|
-
// Update streaming state based on event type
|
|
2253
1199
|
switch (event.type) {
|
|
2254
1200
|
case 'intent':
|
|
2255
|
-
|
|
1201
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { intent: event.data as IntentPreview } });
|
|
2256
1202
|
break;
|
|
2257
1203
|
case 'progress':
|
|
2258
|
-
|
|
1204
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { progress: event.data as ProgressUpdate } });
|
|
2259
1205
|
break;
|
|
2260
1206
|
case 'validation':
|
|
2261
|
-
|
|
1207
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { validation: event.data as ValidationFeedback } });
|
|
2262
1208
|
break;
|
|
2263
1209
|
case 'retry':
|
|
2264
|
-
|
|
1210
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { retry: event.data as RetryInfo } });
|
|
2265
1211
|
break;
|
|
2266
1212
|
case 'completion':
|
|
2267
1213
|
completionData = event.data as CompletionFeedback;
|
|
2268
|
-
|
|
1214
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { completion: completionData } });
|
|
2269
1215
|
break;
|
|
2270
1216
|
case 'error':
|
|
2271
1217
|
errorData = event.data as ErrorFeedback;
|
|
2272
|
-
|
|
1218
|
+
dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { error: errorData } });
|
|
2273
1219
|
break;
|
|
2274
1220
|
}
|
|
2275
|
-
} catch
|
|
2276
|
-
console.warn('Failed to parse SSE event:', line
|
|
1221
|
+
} catch {
|
|
1222
|
+
console.warn('Failed to parse SSE event:', line);
|
|
2277
1223
|
}
|
|
2278
1224
|
}
|
|
2279
1225
|
}
|
|
2280
1226
|
}
|
|
2281
|
-
|
|
2282
|
-
// Handle completion or error
|
|
2283
1227
|
if (completionData) {
|
|
2284
1228
|
finalizeStreamingConversation(newConversation, completionData, userInput);
|
|
2285
1229
|
} else if (errorData) {
|
|
2286
|
-
|
|
1230
|
+
dispatch({ type: 'SET_ERROR', payload: errorData.message });
|
|
2287
1231
|
const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorData.message}\n\n${errorData.suggestion || ''}` }];
|
|
2288
|
-
|
|
1232
|
+
dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
|
|
2289
1233
|
}
|
|
2290
|
-
|
|
2291
1234
|
} catch (err: unknown) {
|
|
2292
|
-
if ((err as Error).name === 'AbortError')
|
|
2293
|
-
console.log('Request aborted');
|
|
2294
|
-
return;
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
// Fall back to non-streaming on error
|
|
1235
|
+
if ((err as Error).name === 'AbortError') return;
|
|
2298
1236
|
console.warn('Streaming failed, falling back to non-streaming:', err);
|
|
2299
|
-
|
|
2300
|
-
|
|
1237
|
+
dispatch({ type: 'SET_STREAMING_STATE', payload: null });
|
|
2301
1238
|
try {
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
}
|
|
2314
|
-
|
|
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.`;
|
|
2315
1254
|
const aiMsg: Message = { role: 'ai', content: responseMessage };
|
|
2316
1255
|
const updatedConversation = [...newConversation, aiMsg];
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
// Update chat session
|
|
2320
|
-
const isUpdate = activeChatId && conversation.length > 0;
|
|
2321
|
-
if (isUpdate && activeChatId) {
|
|
2322
|
-
const updatedSession: ChatSession = {
|
|
2323
|
-
id: activeChatId,
|
|
2324
|
-
title: activeTitle,
|
|
2325
|
-
fileName: data.fileName || activeChatId,
|
|
2326
|
-
conversation: updatedConversation,
|
|
2327
|
-
lastUpdated: Date.now(),
|
|
2328
|
-
};
|
|
2329
|
-
const chats = loadChats();
|
|
2330
|
-
const chatIndex = chats.findIndex(c => c.id === activeChatId);
|
|
2331
|
-
if (chatIndex !== -1) chats[chatIndex] = updatedSession;
|
|
2332
|
-
saveChats(chats);
|
|
2333
|
-
setRecentChats(chats);
|
|
2334
|
-
} else {
|
|
2335
|
-
const chatId = data.storyId || data.fileName || Date.now().toString();
|
|
2336
|
-
const chatTitle = data.title || userInput;
|
|
2337
|
-
setActiveChatId(chatId);
|
|
2338
|
-
setActiveTitle(chatTitle);
|
|
2339
|
-
const newSession: ChatSession = {
|
|
2340
|
-
id: chatId,
|
|
2341
|
-
title: chatTitle,
|
|
2342
|
-
fileName: data.fileName || '',
|
|
2343
|
-
conversation: updatedConversation,
|
|
2344
|
-
lastUpdated: Date.now(),
|
|
2345
|
-
};
|
|
2346
|
-
const chats = loadChats().filter(c => c.id !== chatId);
|
|
2347
|
-
chats.unshift(newSession);
|
|
2348
|
-
if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
|
|
2349
|
-
saveChats(chats);
|
|
2350
|
-
setRecentChats(chats);
|
|
2351
|
-
|
|
2352
|
-
// Auto-navigate to the newly created story
|
|
2353
|
-
navigateToNewStory(chatTitle, data.code);
|
|
2354
|
-
}
|
|
1256
|
+
dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
|
|
2355
1257
|
} catch (fallbackErr: unknown) {
|
|
2356
1258
|
const errorMessage = fallbackErr instanceof Error ? fallbackErr.message : 'Unknown error';
|
|
2357
|
-
|
|
1259
|
+
dispatch({ type: 'SET_ERROR', payload: errorMessage });
|
|
2358
1260
|
const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
|
|
2359
|
-
|
|
1261
|
+
dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
|
|
2360
1262
|
}
|
|
2361
1263
|
} finally {
|
|
2362
|
-
|
|
2363
|
-
|
|
1264
|
+
dispatch({ type: 'SET_LOADING', payload: false });
|
|
1265
|
+
dispatch({ type: 'SET_STREAMING_STATE', payload: null });
|
|
2364
1266
|
abortControllerRef.current = null;
|
|
2365
1267
|
}
|
|
2366
|
-
} else {
|
|
2367
|
-
// Non-streaming mode (original implementation)
|
|
2368
|
-
try {
|
|
2369
|
-
const data = await handleSendNonStreaming(userInput, newConversation);
|
|
2370
|
-
|
|
2371
|
-
let responseMessage: string;
|
|
2372
|
-
const statusMarker = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '[WRENCH]' : '[TIP]') : '[SUCCESS]';
|
|
2373
|
-
|
|
2374
|
-
// Build conversational response for non-streaming mode
|
|
2375
|
-
if (data.isUpdate) {
|
|
2376
|
-
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._`;
|
|
2377
|
-
} else {
|
|
2378
|
-
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).`;
|
|
2379
|
-
}
|
|
2380
|
-
|
|
2381
|
-
const aiMsg: Message = { role: 'ai', content: responseMessage };
|
|
2382
|
-
const updatedConversation = [...newConversation, aiMsg];
|
|
2383
|
-
setConversation(updatedConversation);
|
|
2384
|
-
|
|
2385
|
-
const isUpdate = activeChatId && conversation.length > 0;
|
|
2386
|
-
if (isUpdate && activeChatId) {
|
|
2387
|
-
const updatedSession: ChatSession = {
|
|
2388
|
-
id: activeChatId,
|
|
2389
|
-
title: activeTitle,
|
|
2390
|
-
fileName: data.fileName || activeChatId,
|
|
2391
|
-
conversation: updatedConversation,
|
|
2392
|
-
lastUpdated: Date.now(),
|
|
2393
|
-
};
|
|
2394
|
-
const chats = loadChats();
|
|
2395
|
-
const chatIndex = chats.findIndex(c => c.id === activeChatId);
|
|
2396
|
-
if (chatIndex !== -1) chats[chatIndex] = updatedSession;
|
|
2397
|
-
saveChats(chats);
|
|
2398
|
-
setRecentChats(chats);
|
|
2399
|
-
} else {
|
|
2400
|
-
const chatId = data.storyId || data.fileName || Date.now().toString();
|
|
2401
|
-
const chatTitle = data.title || userInput;
|
|
2402
|
-
setActiveChatId(chatId);
|
|
2403
|
-
setActiveTitle(chatTitle);
|
|
2404
|
-
const newSession: ChatSession = {
|
|
2405
|
-
id: chatId,
|
|
2406
|
-
title: chatTitle,
|
|
2407
|
-
fileName: data.fileName || '',
|
|
2408
|
-
conversation: updatedConversation,
|
|
2409
|
-
lastUpdated: Date.now(),
|
|
2410
|
-
};
|
|
2411
|
-
const chats = loadChats().filter(c => c.id !== chatId);
|
|
2412
|
-
chats.unshift(newSession);
|
|
2413
|
-
if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
|
|
2414
|
-
saveChats(chats);
|
|
2415
|
-
setRecentChats(chats);
|
|
2416
|
-
}
|
|
2417
|
-
} catch (err: unknown) {
|
|
2418
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2419
|
-
setError(errorMessage);
|
|
2420
|
-
const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
|
|
2421
|
-
setConversation(errorConversation);
|
|
2422
|
-
} finally {
|
|
2423
|
-
setLoading(false);
|
|
2424
|
-
}
|
|
2425
1268
|
}
|
|
2426
1269
|
};
|
|
2427
1270
|
|
|
1271
|
+
// Chat management
|
|
2428
1272
|
const handleSelectChat = (chat: ChatSession) => {
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
setActiveTitle(chat.title);
|
|
2432
|
-
};
|
|
2433
|
-
|
|
2434
|
-
const handleNewChat = () => {
|
|
2435
|
-
setConversation([]);
|
|
2436
|
-
setActiveChatId(null);
|
|
2437
|
-
setActiveTitle('');
|
|
1273
|
+
dispatch({ type: 'SET_CONVERSATION', payload: chat.conversation });
|
|
1274
|
+
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
|
|
2438
1275
|
};
|
|
2439
1276
|
|
|
2440
|
-
const
|
|
2441
|
-
e.stopPropagation(); // Prevent selecting the chat
|
|
1277
|
+
const handleNewChat = () => dispatch({ type: 'NEW_CHAT' });
|
|
2442
1278
|
|
|
1279
|
+
const handleDeleteChat = async (chatId: string, e?: React.MouseEvent) => {
|
|
1280
|
+
if (e) e.stopPropagation();
|
|
1281
|
+
setContextMenuId(null);
|
|
2443
1282
|
if (confirm('Delete this story and chat? This action cannot be undone.')) {
|
|
2444
1283
|
const success = await deleteStoryAndChat(chatId);
|
|
2445
|
-
|
|
2446
1284
|
if (success) {
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
// If we deleted the active chat, switch to another or clear
|
|
2452
|
-
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) {
|
|
2453
1288
|
if (updatedChats.length > 0) {
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
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 } });
|
|
2457
1291
|
} else {
|
|
2458
1292
|
handleNewChat();
|
|
2459
1293
|
}
|
|
@@ -2464,724 +1298,360 @@ function StoryUIPanel() {
|
|
|
2464
1298
|
}
|
|
2465
1299
|
};
|
|
2466
1300
|
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
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() } });
|
|
2475
1321
|
}
|
|
2476
|
-
|
|
2477
|
-
|
|
1322
|
+
}
|
|
1323
|
+
setRenamingChatId(null);
|
|
1324
|
+
setRenameValue('');
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
const handleCancelRename = () => {
|
|
1328
|
+
setRenamingChatId(null);
|
|
1329
|
+
setRenameValue('');
|
|
2478
1330
|
};
|
|
2479
1331
|
|
|
2480
|
-
//
|
|
1332
|
+
// Orphan story handlers
|
|
2481
1333
|
const toggleSelectAll = () => {
|
|
2482
|
-
if (selectedStoryIds.size === orphanStories.length) {
|
|
2483
|
-
|
|
1334
|
+
if (state.selectedStoryIds.size === state.orphanStories.length) {
|
|
1335
|
+
dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
|
|
2484
1336
|
} else {
|
|
2485
|
-
|
|
1337
|
+
dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set(state.orphanStories.map(s => s.id)) });
|
|
2486
1338
|
}
|
|
2487
1339
|
};
|
|
2488
1340
|
|
|
2489
|
-
// Bulk delete selected stories
|
|
2490
1341
|
const handleBulkDelete = async () => {
|
|
2491
|
-
if (selectedStoryIds.size === 0) return;
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
return;
|
|
2496
|
-
}
|
|
2497
|
-
|
|
2498
|
-
setIsBulkDeleting(true);
|
|
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 });
|
|
2499
1346
|
try {
|
|
2500
1347
|
const response = await fetch(`${STORIES_API}/delete-bulk`, {
|
|
2501
1348
|
method: 'POST',
|
|
2502
1349
|
headers: { 'Content-Type': 'application/json' },
|
|
2503
|
-
body: JSON.stringify({ ids: Array.from(selectedStoryIds) }),
|
|
1350
|
+
body: JSON.stringify({ ids: Array.from(state.selectedStoryIds) }),
|
|
2504
1351
|
});
|
|
2505
|
-
|
|
2506
1352
|
if (response.ok) {
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
setOrphanStories(prev => prev.filter(s => !selectedStoryIds.has(s.id)));
|
|
2510
|
-
setSelectedStoryIds(new Set());
|
|
2511
|
-
console.log(`Deleted ${result.deleted?.length || count} stories`);
|
|
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() });
|
|
2512
1355
|
} else {
|
|
2513
|
-
alert('Failed to delete some stories.
|
|
1356
|
+
alert('Failed to delete some stories.');
|
|
2514
1357
|
}
|
|
2515
|
-
} catch
|
|
2516
|
-
|
|
2517
|
-
alert('Failed to delete stories. Please try again.');
|
|
1358
|
+
} catch {
|
|
1359
|
+
alert('Failed to delete stories.');
|
|
2518
1360
|
} finally {
|
|
2519
|
-
|
|
1361
|
+
dispatch({ type: 'SET_BULK_DELETING', payload: false });
|
|
2520
1362
|
}
|
|
2521
1363
|
};
|
|
2522
1364
|
|
|
2523
|
-
// Clear all generated stories
|
|
2524
1365
|
const handleClearAll = async () => {
|
|
2525
|
-
if (orphanStories.length === 0) return;
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
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 });
|
|
2529
1381
|
}
|
|
1382
|
+
};
|
|
2530
1383
|
|
|
2531
|
-
|
|
1384
|
+
const handleDeleteOrphan = async (storyId: string) => {
|
|
2532
1385
|
try {
|
|
2533
|
-
const response = await fetch(STORIES_API
|
|
2534
|
-
method: 'DELETE',
|
|
2535
|
-
});
|
|
2536
|
-
|
|
1386
|
+
const response = await fetch(`${STORIES_API}/${storyId}`, { method: 'DELETE' });
|
|
2537
1387
|
if (response.ok) {
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
} else {
|
|
2543
|
-
alert('Failed to clear stories. Please try again.');
|
|
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 });
|
|
2544
1392
|
}
|
|
2545
1393
|
} catch (err) {
|
|
2546
|
-
console.error('Error
|
|
2547
|
-
alert('Failed to clear stories. Please try again.');
|
|
2548
|
-
} finally {
|
|
2549
|
-
setIsBulkDeleting(false);
|
|
1394
|
+
console.error('Error deleting orphan story:', err);
|
|
2550
1395
|
}
|
|
2551
1396
|
};
|
|
2552
1397
|
|
|
1398
|
+
// ============================================
|
|
1399
|
+
// Render
|
|
1400
|
+
// ============================================
|
|
1401
|
+
|
|
2553
1402
|
return (
|
|
2554
|
-
<div className=
|
|
1403
|
+
<div className={`sui-root ${state.isDarkMode ? 'dark' : ''}`}>
|
|
2555
1404
|
{/* Sidebar */}
|
|
2556
|
-
<
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
{sidebarOpen && (
|
|
2561
|
-
<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 */}
|
|
2562
1409
|
<button
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
onMouseEnter={(e) => {
|
|
2567
|
-
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.25)';
|
|
2568
|
-
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
|
|
2569
|
-
}}
|
|
2570
|
-
onMouseLeave={(e) => {
|
|
2571
|
-
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
|
|
2572
|
-
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.3)';
|
|
2573
|
-
}}
|
|
1410
|
+
className="sui-button sui-button-ghost"
|
|
1411
|
+
onClick={() => dispatch({ type: 'TOGGLE_SIDEBAR' })}
|
|
1412
|
+
style={{ width: '100%', marginBottom: '12px', justifyContent: 'flex-start' }}
|
|
2574
1413
|
>
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
</svg>
|
|
2578
|
-
<span>Chats</span>
|
|
1414
|
+
{Icons.panelLeft}
|
|
1415
|
+
<span style={{ marginLeft: '8px' }}>Hide sidebar</span>
|
|
2579
1416
|
</button>
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
e.currentTarget.style.background = '#2563eb';
|
|
2585
|
-
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4)';
|
|
2586
|
-
}}
|
|
2587
|
-
onMouseLeave={(e) => {
|
|
2588
|
-
e.currentTarget.style.background = '#3b82f6';
|
|
2589
|
-
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.25)';
|
|
2590
|
-
}}
|
|
2591
|
-
>
|
|
2592
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
2593
|
-
<line x1="12" y1="5" x2="12" y2="19"/>
|
|
2594
|
-
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
2595
|
-
</svg>
|
|
1417
|
+
|
|
1418
|
+
{/* New Chat */}
|
|
1419
|
+
<button className="sui-button sui-button-default" onClick={handleNewChat} style={{ width: '100%', marginBottom: '16px' }}>
|
|
1420
|
+
{Icons.plus}
|
|
2596
1421
|
<span>New Chat</span>
|
|
2597
1422
|
</button>
|
|
2598
|
-
{recentChats.length > 0 && (
|
|
2599
|
-
<div style={{
|
|
2600
|
-
color: '#64748b',
|
|
2601
|
-
fontSize: '12px',
|
|
2602
|
-
marginBottom: '8px',
|
|
2603
|
-
fontWeight: '500',
|
|
2604
|
-
textTransform: 'uppercase',
|
|
2605
|
-
letterSpacing: '0.05em',
|
|
2606
|
-
}}>
|
|
2607
|
-
Recent Chats
|
|
2608
|
-
</div>
|
|
2609
|
-
)}
|
|
2610
|
-
{recentChats.map(chat => (
|
|
2611
|
-
<div
|
|
2612
|
-
key={chat.id}
|
|
2613
|
-
onClick={() => handleSelectChat(chat)}
|
|
2614
|
-
style={{
|
|
2615
|
-
...STYLES.chatItem,
|
|
2616
|
-
...(activeChatId === chat.id ? STYLES.chatItemActive : {}),
|
|
2617
|
-
}}
|
|
2618
|
-
onMouseEnter={(e) => {
|
|
2619
|
-
if (activeChatId !== chat.id) {
|
|
2620
|
-
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.12)';
|
|
2621
|
-
}
|
|
2622
|
-
const deleteBtn = e.currentTarget.querySelector('.delete-btn') as HTMLElement;
|
|
2623
|
-
if (deleteBtn) deleteBtn.style.opacity = '1';
|
|
2624
|
-
}}
|
|
2625
|
-
onMouseLeave={(e) => {
|
|
2626
|
-
if (activeChatId !== chat.id) {
|
|
2627
|
-
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)';
|
|
2628
|
-
}
|
|
2629
|
-
const deleteBtn = e.currentTarget.querySelector('.delete-btn') as HTMLElement;
|
|
2630
|
-
if (deleteBtn) deleteBtn.style.opacity = '0';
|
|
2631
|
-
}}
|
|
2632
|
-
>
|
|
2633
|
-
<div style={STYLES.chatItemTitle}>{chat.title}</div>
|
|
2634
|
-
<div style={STYLES.chatItemTime}>{formatTime(chat.lastUpdated)}</div>
|
|
2635
|
-
<button
|
|
2636
|
-
className="delete-btn"
|
|
2637
|
-
onClick={(e) => handleDeleteChat(chat.id, e)}
|
|
2638
|
-
style={STYLES.deleteButton}
|
|
2639
|
-
title="Delete chat"
|
|
2640
|
-
>
|
|
2641
|
-
<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>
|
|
2642
|
-
</button>
|
|
2643
|
-
</div>
|
|
2644
|
-
))}
|
|
2645
|
-
|
|
2646
|
-
{/* Generated Files Section - orphan stories without chat history */}
|
|
2647
|
-
{orphanStories.length > 0 && (
|
|
2648
|
-
<>
|
|
2649
|
-
{/* Header with Select All and Count */}
|
|
2650
|
-
<div style={{
|
|
2651
|
-
display: 'flex',
|
|
2652
|
-
alignItems: 'center',
|
|
2653
|
-
justifyContent: 'space-between',
|
|
2654
|
-
marginTop: '16px',
|
|
2655
|
-
marginBottom: '8px',
|
|
2656
|
-
}}>
|
|
2657
|
-
<div style={{
|
|
2658
|
-
display: 'flex',
|
|
2659
|
-
alignItems: 'center',
|
|
2660
|
-
gap: '8px',
|
|
2661
|
-
}}>
|
|
2662
|
-
<input
|
|
2663
|
-
type="checkbox"
|
|
2664
|
-
checked={selectedStoryIds.size === orphanStories.length && orphanStories.length > 0}
|
|
2665
|
-
onChange={toggleSelectAll}
|
|
2666
|
-
style={{
|
|
2667
|
-
width: '14px',
|
|
2668
|
-
height: '14px',
|
|
2669
|
-
cursor: 'pointer',
|
|
2670
|
-
accentColor: '#3b82f6',
|
|
2671
|
-
}}
|
|
2672
|
-
title={selectedStoryIds.size === orphanStories.length ? 'Deselect all' : 'Select all'}
|
|
2673
|
-
/>
|
|
2674
|
-
<span style={{
|
|
2675
|
-
color: '#64748b',
|
|
2676
|
-
fontSize: '12px',
|
|
2677
|
-
fontWeight: '500',
|
|
2678
|
-
textTransform: 'uppercase',
|
|
2679
|
-
letterSpacing: '0.05em',
|
|
2680
|
-
}}>
|
|
2681
|
-
Generated Files ({orphanStories.length})
|
|
2682
|
-
</span>
|
|
2683
|
-
</div>
|
|
2684
|
-
</div>
|
|
2685
1423
|
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
}
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
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
|
-
onMouseEnter={(e) => {
|
|
2746
|
-
if (!isBulkDeleting && orphanStories.length > 0) {
|
|
2747
|
-
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
|
|
2748
|
-
e.currentTarget.style.color = '#f87171';
|
|
2749
|
-
e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.3)';
|
|
2750
|
-
}
|
|
2751
|
-
}}
|
|
2752
|
-
onMouseLeave={(e) => {
|
|
2753
|
-
e.currentTarget.style.background = 'rgba(100, 116, 139, 0.15)';
|
|
2754
|
-
e.currentTarget.style.color = '#94a3b8';
|
|
2755
|
-
e.currentTarget.style.borderColor = 'rgba(100, 116, 139, 0.3)';
|
|
2756
|
-
}}
|
|
2757
|
-
>
|
|
2758
|
-
Clear All Stories
|
|
2759
|
-
</button>
|
|
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>
|
|
1455
|
+
</div>
|
|
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
|
+
)}
|
|
2760
1482
|
</div>
|
|
1483
|
+
))}
|
|
1484
|
+
</div>
|
|
2761
1485
|
|
|
2762
|
-
{/* Story List */}
|
|
2763
|
-
{orphanStories.map(story => (
|
|
2764
|
-
<div
|
|
2765
|
-
key={story.id}
|
|
2766
|
-
style={{
|
|
2767
|
-
...STYLES.chatItem,
|
|
2768
|
-
background: selectedStoryIds.has(story.id)
|
|
2769
|
-
? 'rgba(59, 130, 246, 0.15)'
|
|
2770
|
-
: 'rgba(251, 191, 36, 0.1)',
|
|
2771
|
-
borderLeft: selectedStoryIds.has(story.id)
|
|
2772
|
-
? '3px solid rgba(59, 130, 246, 0.5)'
|
|
2773
|
-
: '3px solid rgba(251, 191, 36, 0.5)',
|
|
2774
|
-
display: 'flex',
|
|
2775
|
-
alignItems: 'flex-start',
|
|
2776
|
-
gap: '8px',
|
|
2777
|
-
}}
|
|
2778
|
-
onMouseEnter={(e) => {
|
|
2779
|
-
if (!selectedStoryIds.has(story.id)) {
|
|
2780
|
-
e.currentTarget.style.background = 'rgba(251, 191, 36, 0.15)';
|
|
2781
|
-
}
|
|
2782
|
-
const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn') as HTMLElement;
|
|
2783
|
-
if (deleteBtn) deleteBtn.style.opacity = '1';
|
|
2784
|
-
}}
|
|
2785
|
-
onMouseLeave={(e) => {
|
|
2786
|
-
if (!selectedStoryIds.has(story.id)) {
|
|
2787
|
-
e.currentTarget.style.background = 'rgba(251, 191, 36, 0.1)';
|
|
2788
|
-
}
|
|
2789
|
-
const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn') as HTMLElement;
|
|
2790
|
-
if (deleteBtn) deleteBtn.style.opacity = '0';
|
|
2791
|
-
}}
|
|
2792
|
-
>
|
|
2793
|
-
{/* Checkbox */}
|
|
2794
|
-
<input
|
|
2795
|
-
type="checkbox"
|
|
2796
|
-
checked={selectedStoryIds.has(story.id)}
|
|
2797
|
-
onChange={() => toggleStorySelection(story.id)}
|
|
2798
|
-
onClick={(e) => e.stopPropagation()}
|
|
2799
|
-
style={{
|
|
2800
|
-
width: '14px',
|
|
2801
|
-
height: '14px',
|
|
2802
|
-
cursor: 'pointer',
|
|
2803
|
-
accentColor: '#3b82f6',
|
|
2804
|
-
marginTop: '2px',
|
|
2805
|
-
flexShrink: 0,
|
|
2806
|
-
}}
|
|
2807
|
-
/>
|
|
2808
|
-
{/* Story Info */}
|
|
2809
|
-
<div style={{ flex: 1, minWidth: 0 }}>
|
|
2810
|
-
<div style={STYLES.chatItemTitle}>{story.title}</div>
|
|
2811
|
-
<div style={{ ...STYLES.chatItemTime, fontSize: '11px' }}>
|
|
2812
|
-
{story.fileName}
|
|
2813
|
-
</div>
|
|
2814
|
-
</div>
|
|
2815
|
-
{/* Delete Button */}
|
|
2816
|
-
<button
|
|
2817
|
-
className="delete-orphan-btn"
|
|
2818
|
-
onClick={async (e) => {
|
|
2819
|
-
e.stopPropagation();
|
|
2820
|
-
try {
|
|
2821
|
-
const response = await fetch(`${STORIES_API}/${story.id}`, {
|
|
2822
|
-
method: 'DELETE',
|
|
2823
|
-
});
|
|
2824
|
-
if (response.ok) {
|
|
2825
|
-
setOrphanStories(prev => prev.filter(s => s.id !== story.id));
|
|
2826
|
-
setSelectedStoryIds(prev => {
|
|
2827
|
-
const newSet = new Set(prev);
|
|
2828
|
-
newSet.delete(story.id);
|
|
2829
|
-
return newSet;
|
|
2830
|
-
});
|
|
2831
|
-
} else {
|
|
2832
|
-
console.error('Failed to delete orphan story');
|
|
2833
|
-
}
|
|
2834
|
-
} catch (err) {
|
|
2835
|
-
console.error('Error deleting orphan story:', err);
|
|
2836
|
-
}
|
|
2837
|
-
}}
|
|
2838
|
-
style={STYLES.deleteButton}
|
|
2839
|
-
title="Delete generated file"
|
|
2840
|
-
>
|
|
2841
|
-
<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>
|
|
2842
|
-
</button>
|
|
2843
|
-
</div>
|
|
2844
|
-
))}
|
|
2845
|
-
</>
|
|
2846
|
-
)}
|
|
2847
1486
|
</div>
|
|
2848
1487
|
)}
|
|
2849
|
-
{!sidebarOpen && (
|
|
2850
|
-
<div style={{ padding: '
|
|
2851
|
-
<button
|
|
2852
|
-
|
|
2853
|
-
style={{
|
|
2854
|
-
...STYLES.sidebarToggle,
|
|
2855
|
-
width: '38px',
|
|
2856
|
-
height: '38px',
|
|
2857
|
-
padding: '0',
|
|
2858
|
-
fontSize: '16px',
|
|
2859
|
-
borderRadius: '8px',
|
|
2860
|
-
}}
|
|
2861
|
-
title="Expand sidebar"
|
|
2862
|
-
onMouseEnter={(e) => {
|
|
2863
|
-
e.currentTarget.style.transform = 'scale(1.05)';
|
|
2864
|
-
e.currentTarget.style.background = '#2563eb';
|
|
2865
|
-
}}
|
|
2866
|
-
onMouseLeave={(e) => {
|
|
2867
|
-
e.currentTarget.style.transform = 'scale(1)';
|
|
2868
|
-
e.currentTarget.style.background = '#3b82f6';
|
|
2869
|
-
}}
|
|
2870
|
-
>
|
|
2871
|
-
<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}
|
|
2872
1492
|
</button>
|
|
2873
1493
|
</div>
|
|
2874
1494
|
)}
|
|
2875
|
-
</
|
|
2876
|
-
|
|
2877
|
-
{/* Main
|
|
2878
|
-
<
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
{/* Drop zone overlay */}
|
|
2886
|
-
{isDragging && (
|
|
2887
|
-
<div style={STYLES.dropOverlay}>
|
|
2888
|
-
<div style={STYLES.dropOverlayText}>
|
|
2889
|
-
<svg width={24} height={24} viewBox="0 0 24 24" fill="currentColor">
|
|
2890
|
-
<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"/>
|
|
2891
|
-
</svg>
|
|
2892
|
-
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>
|
|
2893
1505
|
</div>
|
|
2894
1506
|
</div>
|
|
2895
1507
|
)}
|
|
2896
1508
|
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
display: 'inline-block',
|
|
2906
|
-
letterSpacing: '-0.02em'
|
|
2907
|
-
}}>
|
|
2908
|
-
Story UI
|
|
2909
|
-
</h1>
|
|
2910
|
-
<p style={{ fontSize: '14px', margin: '6px 0 0 0', color: '#94a3b8', fontWeight: '500' }}>
|
|
2911
|
-
Generate Storybook stories with AI
|
|
2912
|
-
</p>
|
|
2913
|
-
<div style={{
|
|
2914
|
-
display: 'flex',
|
|
2915
|
-
alignItems: 'center',
|
|
2916
|
-
gap: '6px',
|
|
2917
|
-
marginTop: '10px',
|
|
2918
|
-
fontSize: '11px'
|
|
2919
|
-
}}>
|
|
2920
|
-
<div style={{
|
|
2921
|
-
width: '6px',
|
|
2922
|
-
height: '6px',
|
|
2923
|
-
borderRadius: '50%',
|
|
2924
|
-
backgroundColor: connectionStatus.connected ? '#10b981' : '#f87171'
|
|
2925
|
-
}}></div>
|
|
2926
|
-
<span
|
|
2927
|
-
className={`story-ui-status ${connectionStatus.connected ? 'story-ui-status-connected' : 'story-ui-status-disconnected'}`}
|
|
2928
|
-
style={{ color: connectionStatus.connected ? '#10b981' : '#ef4444', fontWeight: '400' }}
|
|
2929
|
-
>
|
|
2930
|
-
{connectionStatus.connected
|
|
2931
|
-
? `Connected to ${getConnectionDisplayText()}`
|
|
2932
|
-
: `Disconnected: ${connectionStatus.error || 'Server not running'}`
|
|
2933
|
-
}
|
|
2934
|
-
</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>
|
|
2935
1517
|
</div>
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
))}
|
|
2971
|
-
</select>
|
|
2972
|
-
</div>
|
|
2973
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
2974
|
-
<label style={{ fontSize: '12px', color: '#94a3b8', fontWeight: '500' }}>Model:</label>
|
|
2975
|
-
<select
|
|
2976
|
-
value={selectedModel}
|
|
2977
|
-
onChange={(e) => setSelectedModel(e.target.value)}
|
|
2978
|
-
style={{
|
|
2979
|
-
background: '#1e293b',
|
|
2980
|
-
border: '1px solid #334155',
|
|
2981
|
-
borderRadius: '6px',
|
|
2982
|
-
color: '#e2e8f0',
|
|
2983
|
-
padding: '4px 8px',
|
|
2984
|
-
fontSize: '12px',
|
|
2985
|
-
cursor: 'pointer',
|
|
2986
|
-
maxWidth: '200px'
|
|
2987
|
-
}}
|
|
2988
|
-
>
|
|
2989
|
-
{availableProviders
|
|
2990
|
-
.find(p => p.type === selectedProvider)
|
|
2991
|
-
?.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 => (
|
|
2992
1552
|
<option key={model} value={model}>{getModelDisplayName(model)}</option>
|
|
2993
1553
|
))}
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
<div
|
|
3010
|
-
|
|
3011
|
-
|
|
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>
|
|
3012
1588
|
</div>
|
|
3013
1589
|
</div>
|
|
3014
|
-
)
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
<img
|
|
3028
|
-
key={img.id}
|
|
3029
|
-
src={img.base64
|
|
3030
|
-
? `data:${img.mediaType || 'image/png'};base64,${img.base64}`
|
|
3031
|
-
: img.preview}
|
|
3032
|
-
alt="attached"
|
|
3033
|
-
style={STYLES.userMessageImage}
|
|
3034
|
-
/>
|
|
3035
|
-
))}
|
|
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
|
+
)}
|
|
3036
1603
|
</div>
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
) : (
|
|
3047
|
-
<div style={STYLES.loadingMessage}>
|
|
3048
|
-
<span>Generating story</span>
|
|
3049
|
-
<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
|
+
)}
|
|
3050
1613
|
</div>
|
|
3051
1614
|
)}
|
|
1615
|
+
<div ref={chatEndRef} />
|
|
3052
1616
|
</div>
|
|
3053
1617
|
)}
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
{attachedImages.length > 0 && (
|
|
3070
|
-
<div style={STYLES.imagePreviewContainer}>
|
|
3071
|
-
<span style={STYLES.imagePreviewLabel}>
|
|
3072
|
-
<svg width={14} height={14} viewBox="0 0 24 24" fill="currentColor">
|
|
3073
|
-
<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"/>
|
|
3074
|
-
</svg>
|
|
3075
|
-
{attachedImages.length} image{attachedImages.length > 1 ? 's' : ''} attached
|
|
3076
|
-
</span>
|
|
3077
|
-
{attachedImages.map((img) => (
|
|
3078
|
-
<div key={img.id} style={STYLES.imagePreviewItem}>
|
|
3079
|
-
<img src={img.preview} alt="preview" style={STYLES.imagePreviewImg} />
|
|
3080
|
-
<button
|
|
3081
|
-
type="button"
|
|
3082
|
-
style={STYLES.imageRemoveButton}
|
|
3083
|
-
onClick={() => removeAttachedImage(img.id)}
|
|
3084
|
-
title="Remove image"
|
|
3085
|
-
>
|
|
3086
|
-
<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>
|
|
3087
|
-
</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
|
+
))}
|
|
3088
1633
|
</div>
|
|
3089
|
-
)
|
|
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>
|
|
3090
1652
|
</div>
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
<form onSubmit={handleSend} style={{
|
|
3094
|
-
...STYLES.inputForm,
|
|
3095
|
-
...(attachedImages.length > 0 ? {
|
|
3096
|
-
marginTop: 0,
|
|
3097
|
-
borderTopLeftRadius: 0,
|
|
3098
|
-
borderTopRightRadius: 0,
|
|
3099
|
-
} : {})
|
|
3100
|
-
}}>
|
|
3101
|
-
{/* Upload button */}
|
|
3102
|
-
<button
|
|
3103
|
-
type="button"
|
|
3104
|
-
onClick={() => fileInputRef.current?.click()}
|
|
3105
|
-
disabled={loading || attachedImages.length >= MAX_IMAGES}
|
|
3106
|
-
style={{
|
|
3107
|
-
...STYLES.uploadButton,
|
|
3108
|
-
...(attachedImages.length >= MAX_IMAGES ? {
|
|
3109
|
-
opacity: 0.5,
|
|
3110
|
-
cursor: 'not-allowed',
|
|
3111
|
-
} : {})
|
|
3112
|
-
}}
|
|
3113
|
-
title={attachedImages.length >= MAX_IMAGES
|
|
3114
|
-
? `Maximum ${MAX_IMAGES} images`
|
|
3115
|
-
: 'Attach images (screenshots, designs)'
|
|
3116
|
-
}
|
|
3117
|
-
onMouseEnter={(e) => {
|
|
3118
|
-
if (attachedImages.length < MAX_IMAGES && !loading) {
|
|
3119
|
-
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)';
|
|
3120
|
-
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
|
|
3121
|
-
}
|
|
3122
|
-
}}
|
|
3123
|
-
onMouseLeave={(e) => {
|
|
3124
|
-
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
|
3125
|
-
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
|
|
3126
|
-
}}
|
|
3127
|
-
>
|
|
3128
|
-
<svg width={20} height={20} viewBox="0 0 24 24" fill="currentColor">
|
|
3129
|
-
<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"/>
|
|
3130
|
-
</svg>
|
|
3131
|
-
</button>
|
|
3132
|
-
|
|
3133
|
-
<input
|
|
3134
|
-
ref={inputRef}
|
|
3135
|
-
type="text"
|
|
3136
|
-
value={input}
|
|
3137
|
-
onChange={e => setInput(e.target.value)}
|
|
3138
|
-
onPaste={handlePaste}
|
|
3139
|
-
placeholder={attachedImages.length > 0
|
|
3140
|
-
? "Describe what to create from these images..."
|
|
3141
|
-
: "Describe a UI component..."
|
|
3142
|
-
}
|
|
3143
|
-
style={STYLES.textInput}
|
|
3144
|
-
onFocus={(e) => {
|
|
3145
|
-
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
|
|
3146
|
-
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(59, 130, 246, 0.1)';
|
|
3147
|
-
}}
|
|
3148
|
-
onBlur={(e) => {
|
|
3149
|
-
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
|
|
3150
|
-
e.currentTarget.style.boxShadow = 'none';
|
|
3151
|
-
}}
|
|
3152
|
-
/>
|
|
3153
|
-
<button
|
|
3154
|
-
type="submit"
|
|
3155
|
-
disabled={loading || (!input.trim() && attachedImages.length === 0)}
|
|
3156
|
-
style={{
|
|
3157
|
-
...STYLES.sendButton,
|
|
3158
|
-
...(loading || (!input.trim() && attachedImages.length === 0) ? {
|
|
3159
|
-
opacity: 0.4,
|
|
3160
|
-
cursor: 'not-allowed',
|
|
3161
|
-
background: '#64748b',
|
|
3162
|
-
boxShadow: 'none'
|
|
3163
|
-
} : {})
|
|
3164
|
-
}}
|
|
3165
|
-
onMouseEnter={(e) => {
|
|
3166
|
-
if (!loading && (input.trim() || attachedImages.length > 0)) {
|
|
3167
|
-
e.currentTarget.style.background = '#2563eb';
|
|
3168
|
-
e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.5)';
|
|
3169
|
-
}
|
|
3170
|
-
}}
|
|
3171
|
-
onMouseLeave={(e) => {
|
|
3172
|
-
if (!loading && (input.trim() || attachedImages.length > 0)) {
|
|
3173
|
-
e.currentTarget.style.background = '#3b82f6';
|
|
3174
|
-
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.35)';
|
|
3175
|
-
}
|
|
3176
|
-
}}
|
|
3177
|
-
>
|
|
3178
|
-
<svg width={18} height={18} viewBox="0 0 24 24" fill="currentColor">
|
|
3179
|
-
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
|
3180
|
-
</svg>
|
|
3181
|
-
<span>Send</span>
|
|
3182
|
-
</button>
|
|
3183
|
-
</form>
|
|
3184
|
-
</div>
|
|
1653
|
+
</div>
|
|
1654
|
+
</main>
|
|
3185
1655
|
</div>
|
|
3186
1656
|
);
|
|
3187
1657
|
}
|