-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathwan2-2_PromptGenerator_Lite.html
More file actions
473 lines (433 loc) · 35.9 KB
/
Copy pathwan2-2_PromptGenerator_Lite.html
File metadata and controls
473 lines (433 loc) · 35.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Prompt Crafter</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
body { background-color: #111827; color: #d1d5db; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// --- Type Definitions (for clarity, not enforced in Babel) ---
/*
interface CreativityMode { name: string; description: string; temperature: number; systemPrompt: (videoModel: string) => string; }
interface ServiceConfig { name: string; defaultUrl: string; modelsEndpoint: string | null; pullVisible: boolean; deleteVisible: boolean; }
interface HistoryItem { id: number; timestamp: string; input: string; output: string; negativePrompt: string; service: string; model: string; creativity: string; endpoint: string; videoModel: string; }
*/
// --- Constants ---
const DEFAULT_LM_STUDIO_URL = 'http://localhost:1234/v1';
const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
const VIDEO_MODEL_PROMPTS = {
'wan2.2': `You are an expert prompt engineer for the Wan 2.2 video generation model. Your task is to craft highly detailed, cinematic video prompts that include specific camera movements, lighting, composition, color grading, and emotional elements. Always output only the final prompt without any additional text, explanations, or formatting.`,
'flux': `You are an expert prompt engineer for the Flux Ai Models by Black Forest Labs, an image generation model. Your task is to craft a single, concise, and highly descriptive paragraph for a text-to-image prompt. Focus on visual details, style, and composition. Always output only the final prompt paragraph without any additional text or explanations. Use this structure for consistent results: Subject + Action + Style + Context Example: “Red fox sitting in tall grass, wildlife documentary photography, misty dawn” Subject: The main focus (person, object, character) Action: What the subject is doing or their pose Style: Artistic approach, medium, or aesthetic Context: Setting, lighting, time, mood, or atmospheric conditions. Use Structured Descriptions Use natural language for relationships and descriptions, but direct specifications for technical and atmospheric elements. “Human explorer in futuristic gear walking through cyberpunk forest, dramatic atmospheric lighting, sci-fi fantasy art style, cinematic composition” “An astronaut with a silver spacesuit floating outside the International Space Station, cinematic photography with dramatic lighting, peaceful and awe-inspiring” “Retro game style detective in old school suit, upper body shot, colorful, futuristic design with vibrant glow” Word Order Matters Front-load your most important elements. FLUX pays more attention to what comes first. Priority order: Main subject → Key action → Critical style → Essential context → Secondary details Enhancement Layers Build beyond the basic framework with these optional layers: Foundation: Subject + Action + Style + Context + Visual Layer: Specific lighting, color palette, composition details + Technical Layer: Camera settings, lens specs, quality markers + Atmospheric Layer: Mood, emotional tone, narrative elements Example progression: Foundation: “An astronaut floating outside the space station, cinematic photography” Enhanced: “An astronaut with silver spacesuit floating gracefully outside the International Space Station, cinematic photography with dramatic lighting, bathed in golden sunlight, deep blue Earth tones, shallow depth of field, 85mm lens, conveying wonder and achievement” Optimal Prompt Length Short (10-30 words): Quick concepts and style exploration Medium (30-80 words): Usually ideal for most projects Long (80+ words): Complex scenes requiring detailed specifications Avoiding Negative Prompts in FLUX: Positive Alternatives Instead of “no crowds,” write “peaceful solitude” Instead of “without glasses,” write “clear, unobstructed eyes” Ask: “If this thing wasn’t there, what would I see instead?” Quick Templates Portrait: [Subject description], [pose/expression], [style], [lighting], [background] Product: [Product details], [placement], [lighting setup], [style], [mood] Landscape: [Location/setting], [time/weather], [camera angle], [style], [atmosphere] Architecture: [Building/space], [perspective], [lighting], [style], [mood]`,
'qwen': `You are an expert prompt engineer for Qwen Image, Alibaba's advanced text-to-image model with superior text rendering capabilities. Your task is to craft detailed, structured prompts that leverage Qwen Image's strengths in complex scene generation, native text rendering, and bilingual support. Always output only the final prompt without any additional text or explanations. Core Prompt Structure: Follow this proven template for optimal results: [Main subject] + [Visual style/medium] + [Environment & background details] + [Lighting] + [Extra effects] + ["Exact text if needed"] Example: "A futuristic sports car, photorealistic style, parked under neon city lights, reflections on wet streets, cinematic lighting, 'Night Racer' in metallic chrome text on the hood" Prompt Construction Principles: - Simple and Clear: Use plain language to describe subject, style, and mood - Optimal Length: 1-3 sentences work best; avoid overloading with details - Order Matters: Main subject first → environment second → finer details last - Front-load Priority: Place most important elements at the beginning Text Rendering Excellence (Qwen Image's Specialty): - Enclose exact text in "double quotes" for precise rendering - Specify font style, color, and placement when important - Support both English and Chinese text seamlessly - Perfect for: posters, book covers, infographics, signage, branding materials - Example: "Grand Opening" in glowing gold letters on a neon billboard Subject-Specific Guidelines: For People: Include ethnicity, age, clothing, facial expression, pose, and action For Complex Scenes: Break down as main subject + background + secondary objects + text elements For Products: Detail placement, lighting setup, style, mood, and any text/branding For Artistic Work: Specify medium (photorealistic, anime, impressionist, minimalist), composition, color palette Style Specifications: Qwen Image excels at: Photorealistic imagery, anime/illustration styles, artistic and impressionist renders, professional infographics, poster designs, minimalist compositions Enhancement Layers: - Visual: Specific lighting conditions, color grading, atmospheric effects - Technical: Camera angle, depth of field, composition rules - Atmospheric: Mood, emotion, time of day, weather conditions - Professional: Commercial quality, brand-appropriate styling Quick Templates: Portrait: [Person description], [pose/expression], [clothing details], [style], [lighting], [background] Product: [Product details], [placement/angle], [lighting setup], [style/mood], ["brand text"] Poster/Infographic: [Main visual], [layout description], [style], ["headline text"], ["body text"], [decorative elements] Scene: [Location/setting], [time/atmosphere], [main subjects], [lighting], [style], [mood] Avoiding Negative Prompts: Focus on what you want, not what you don't want Instead of "no crowds" → write "peaceful solitude" or "empty space" Instead of "without blur" → write "sharp focus" or "crystal clear details" Ask: "What should be there instead?" and describe that positively`
};
const CREATIVITY_MODES = {
precise: {
name: 'Precise Mode',
description: 'Strict adherence to input with minimal creative interpretation',
temperature: 0.3,
systemPrompt: (videoModel) => `${VIDEO_MODEL_PROMPTS[videoModel]} Use precise, technical language with minimal creative interpretation. Focus on exact specifications provided by the user.`
},
creative: {
name: 'Creative Mode',
description: 'Enhanced creative interpretation with cinematic flair',
temperature: 0.7,
systemPrompt: (videoModel) => `${VIDEO_MODEL_PROMPTS[videoModel]} Apply creative interpretation to enhance cinematic quality. Add appropriate camera movements, lighting, composition, and emotional elements that complement the user's input while maintaining the core concept.`
}
};
const SERVICE_CONFIGS = {
lmstudio: {
name: 'LM Studio',
defaultUrl: DEFAULT_LM_STUDIO_URL,
modelsEndpoint: '/models',
pullVisible: false,
unloadVisible: false,
},
ollama: {
name: 'Ollama',
defaultUrl: DEFAULT_OLLAMA_URL,
modelsEndpoint: '/api/tags',
pullVisible: true,
unloadVisible: true,
}
};
function App() {
const [service, setService] = useState('lmstudio');
const [endpoint, setEndpoint] = useState(DEFAULT_LM_STUDIO_URL);
const [models, setModels] = useState([]);
const [selectedLLM, setSelectedLLM] = useState('');
const [videoModel, setVideoModel] = useState('wan2.2');
const [creativityMode, setCreativityMode] = useState('precise');
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [negPromptLocked, setNegPromptLocked] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [isInspiring, setIsInspiring] = useState(false);
const [history, setHistory] = useState([]);
const [showHistory, setShowHistory] = useState(false);
const [historySearch, setHistorySearch] = useState('');
const outputRef = useRef(null);
useEffect(() => {
try {
const savedHistory = localStorage.getItem('ai_prompt_crafter_history');
if (savedHistory) setHistory(JSON.parse(savedHistory));
} catch (e) { console.error('Failed to parse history', e); }
}, []);
const filteredHistory = history.filter(item => {
if (!historySearch) return true;
const searchTerm = historySearch.toLowerCase();
return Object.values(item).some(val =>
String(val).toLowerCase().includes(searchTerm)
);
}).sort((a, b) => b.id - a.id);
const updateService = (newService) => {
setService(newService);
const config = SERVICE_CONFIGS[newService];
setEndpoint(config.defaultUrl);
setSelectedLLM('');
setModels([]);
};
const getApiUrl = (path) => {
// The endpoint should be the base URL, e.g., http://localhost:1234/v1 or http://localhost:11434
return `${endpoint}${path}`;
}
const refreshModels = async () => {
if (!endpoint) return;
setIsLoading(true);
try {
if (service === 'ollama') {
const response = await fetch(getApiUrl('/api/tags'));
if (!response.ok) throw new Error(`Ollama API error: ${response.statusText}`);
const data = await response.json();
setModels(data.models?.map(m => m.name) || []);
} else { // lmstudio
const response = await fetch(getApiUrl('/models'));
if (!response.ok) throw new Error(`LM Studio API error: ${response.statusText}`);
const data = await response.json();
// LM Studio returns models in a 'data' array with 'id' field
setModels(data.data?.map(m => m.id) || []);
}
} catch (error) {
console.error('Failed to fetch models:', error);
alert(`Failed to fetch models. Check your endpoint and make sure the service is running.\nError: ${error.message}`);
setModels([]);
} finally {
setIsLoading(false);
}
};
const pullModel = async (modelName) => {
if (service !== 'ollama' || !modelName) return;
setIsLoading(true);
try {
// This can be a long-running process, so we don't await the full stream here.
// We just fire and forget, then refresh the list.
await fetch(getApiUrl('/api/pull'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName, stream: false })
});
alert(`Started pulling model: ${modelName}. Refresh the list in a few moments to see it.`);
await refreshModels();
} catch (error) {
console.error('Failed to pull model:', error);
alert(`Failed to pull model: ${error.message}`);
} finally {
setIsLoading(false);
}
};
const unloadModel = async (modelName) => {
if (service !== 'ollama' || !modelName) return;
if (!confirm(`Are you sure you want to unload the model "${modelName}" from memory? This will not delete the model file.`)) return;
setIsLoading(true);
try {
const response = await fetch(getApiUrl('/api/unload'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName })
});
if (response.ok) {
alert(`Model "${modelName}" has been unloaded from memory.`);
if (selectedLLM === modelName) {
setSelectedLLM('');
}
} else {
const errorText = await response.text().catch(() => 'Could not read error response.');
if (errorText.includes('is not loaded')) {
alert(`Model "${modelName}" was not loaded, so no action was taken.`);
} else {
throw new Error(`Ollama API Error (${response.status}): ${errorText}`);
}
}
} catch (error) {
console.error('Failed to unload model:', error);
alert(`Failed to unload model: ${error.message}`);
} finally {
setIsLoading(false);
}
};
const generatePrompt = async (isInspiration = false) => {
if (!selectedLLM || !endpoint || (!inputText.trim() && !isInspiration)) return;
isInspiration ? setIsInspiring(true) : setIsLoading(true);
if (!isInspiration) setOutputText('');
try {
const creativityConfig = CREATIVITY_MODES[creativityMode];
const systemPrompt = isInspiration
? `You are a creative assistant for generating video and image concepts.`
: creativityConfig.systemPrompt(videoModel);
const userPrompt = isInspiration
? `Generate 3 short, diverse scene ideas for a ${videoModel} prompt. Each should be 1-2 sentences and include different settings, emotions, or visual styles. Number them 1., 2., 3.`
: inputText;
const messages = [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }];
const body = service === 'lmstudio' ? {
model: selectedLLM,
messages,
temperature: creativityConfig.temperature,
max_tokens: 500
} : {
model: selectedLLM,
messages,
stream: false,
options: { temperature: creativityConfig.temperature }
};
const apiUrl = service === 'lmstudio'
? getApiUrl('/chat/completions')
: getApiUrl('/api/chat');
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) throw new Error(`API Error: ${response.status} ${response.statusText}`);
const data = await response.json();
const result = (service === 'lmstudio' ? data.choices[0].message.content : data.message.content).trim();
if (isInspiration) {
setInputText(result);
} else {
setOutputText(result);
const newEntry = {
id: Date.now(),
timestamp: new Date().toISOString(),
input: inputText,
output: result,
negativePrompt: negPromptLocked ? '' : negativePrompt,
service,
model: selectedLLM,
creativity: creativityMode,
videoModel,
endpoint
};
const updatedHistory = [newEntry, ...history];
setHistory(updatedHistory);
localStorage.setItem('ai_prompt_crafter_history', JSON.stringify(updatedHistory));
}
} catch (error) {
console.error('Generation failed:', error);
const errorMsg = `Failed to generate. Check your endpoint and model selection.\nError: ${error.message}`;
isInspiration ? setInputText(errorMsg) : setOutputText(errorMsg);
} finally {
isInspiration ? setIsInspiring(false) : setIsLoading(false);
}
};
const copyToClipboard = () => {
if (outputRef.current) {
navigator.clipboard.writeText(outputRef.current.innerText);
}
};
const clearHistory = () => {
if (confirm('Are you sure you want to clear all history?')) {
setHistory([]);
localStorage.removeItem('ai_prompt_crafter_history');
}
};
const deleteHistoryItem = (id) => {
const updatedHistory = history.filter(item => item.id !== id);
setHistory(updatedHistory);
localStorage.setItem('ai_prompt_crafter_history', JSON.stringify(updatedHistory));
};
const exportHistory = (format) => {
const dataStr = format === 'json'
? JSON.stringify(history, null, 2)
: ['timestamp,service,target_model,llm,creativity,input,output,negative_prompt',
...history.map(item => [
item.timestamp, item.service, item.videoModel, item.model, item.creativity,
`"${item.input.replace(/"/g, '""')}"`,
`"${item.output.replace(/"/g, '""')}"`,
`"${(item.negativePrompt || '').replace(/"/g, '""')}"`
].join(','))
].join('\n');
const blob = new Blob([dataStr], { type: format === 'json' ? 'application/json' : 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `ai_prompt_history.${format}`;
link.click();
URL.revokeObjectURL(url);
};
const config = SERVICE_CONFIGS[service];
return (
<div className="flex flex-col h-screen overflow-hidden bg-gray-900 text-gray-300">
<header className="flex items-center justify-between px-6 py-3 bg-gray-800 border-b border-gray-700">
<h1 className="text-2xl font-bold text-white">AI Prompt Crafter</h1>
<button onClick={() => setShowHistory(!showHistory)} className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors" aria-label="Toggle history">
<span aria-hidden>📜</span> History ({history.length})
</button>
</header>
<div className="flex flex-1 overflow-hidden">
<div className={`flex-1 flex flex-col transition-all duration-300 ${showHistory ? 'w-2/3' : 'w-full'}`}>
<div className="bg-gray-800 border-b border-gray-700 p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-center">
<div>
<label className="text-sm font-medium">Service:</label>
<select value={service} onChange={(e) => updateService(e.target.value)} className="w-full mt-1 bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm">
<option value="lmstudio">LM Studio</option>
<option value="ollama">Ollama</option>
</select>
</div>
<div>
<label className="text-sm font-medium block">Endpoint Base URL:</label>
<input type="text" value={endpoint} onChange={(e) => setEndpoint(e.target.value)} className="w-full mt-1 bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm" placeholder={config.defaultUrl} />
</div>
</div>
<div className="flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium block mb-1">LLM Model:</label>
<select value={selectedLLM} onChange={(e) => setSelectedLLM(e.target.value)} className="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm" disabled={isLoading}>
<option value="">{models.length > 0 ? 'Select a model' : 'Refresh to see models'}</option>
{models.map(model => <option key={model} value={model}>{model}</option>)}
</select>
</div>
<button onClick={refreshModels} disabled={isLoading} className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-sm disabled:opacity-50">
<span aria-hidden>🔄</span> Refresh Models
</button>
{config.pullVisible && (
<button onClick={() => { const modelName = prompt('Enter model name to pull (e.g., llama3:latest):'); if (modelName) pullModel(modelName); }} disabled={isLoading} className="px-3 py-1.5 bg-green-600 hover:bg-green-700 rounded text-sm disabled:opacity-50">Pull Model</button>
)}
{config.unloadVisible && selectedLLM && (
<button onClick={() => unloadModel(selectedLLM)} disabled={isLoading} className="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 rounded text-sm disabled:opacity-50">Unload Selected</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium">Target Model:</label>
<select value={videoModel} onChange={(e) => setVideoModel(e.target.value)} className="w-full mt-1 bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm">
<option value="wan2.2">Wan 2.2 (Video)</option>
<option value="flux">Flux (Image)</option>
<option value="qwen">Qwen (Image)</option>
</select>
</div>
<div className="md:col-span-2 flex gap-2 items-center pt-5">
<button onClick={() => setCreativityMode('precise')} className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${creativityMode === 'precise' ? 'bg-blue-600 text-white' : 'bg-gray-700 hover:bg-gray-600'}`}>Precise</button>
<button onClick={() => setCreativityMode('creative')} className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${creativityMode === 'creative' ? 'bg-purple-600 text-white' : 'bg-gray-700 hover:bg-gray-600'}`}>Creative</button>
</div>
</div>
</div>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 overflow-auto">
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-blue-400">Input</h2>
<button onClick={() => generatePrompt(true)} disabled={isLoading || isInspiring || !selectedLLM} className="flex items-center gap-1 px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-sm disabled:opacity-50">
<span aria-hidden>✨</span> {isInspiring ? 'Inspiring...' : 'Inspire Me'}
</button>
</div>
<textarea value={inputText} onChange={(e) => setInputText(e.target.value)} placeholder="Describe your video or image scene..." className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={isLoading}></textarea>
</div>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-green-400">Output</h2>
<button onClick={copyToClipboard} disabled={!outputText || isLoading} className="flex items-center gap-1 px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-sm disabled:opacity-50">
<span aria-hidden>📋</span> Copy
</button>
</div>
<div ref={outputRef} className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 overflow-auto whitespace-pre-wrap">
{isLoading ? <div className="flex items-center justify-center h-full"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div></div> : (outputText || <span className="text-gray-500">Generated prompt will appear here...</span>)}
</div>
</div>
</div>
<div className="px-4 pb-4 space-y-4">
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-md font-semibold">Negative Prompt</h3>
<label className="flex items-center gap-2 cursor-pointer">
{negPromptLocked ? <span aria-hidden>🔒</span> : <span aria-hidden>🔓</span>}
<span className="text-sm">{negPromptLocked ? 'Locked' : 'Unlocked'}</span>
<input type="checkbox" checked={!negPromptLocked} onChange={(e) => setNegPromptLocked(!e.target.checked)} className="sr-only" />
</label>
</div>
<textarea value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} placeholder="Elements to exclude..." className="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={negPromptLocked} rows={2}></textarea>
</div>
<button onClick={() => generatePrompt(false)} disabled={!inputText.trim() || !selectedLLM || isLoading} className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 rounded-lg font-semibold text-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all">
{isLoading ? 'Generating...' : `Generate ${videoModel.toUpperCase()} Prompt`}
</button>
</div>
</div>
{showHistory && (
<div className="w-1/3 max-w-md bg-gray-800 border-l border-gray-700 flex flex-col">
<div className="p-4 border-b border-gray-700">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">History</h2>
<div className="flex gap-2">
<button onClick={() => exportHistory('json')} className="p-1 hover:bg-gray-700 rounded" title="Export JSON"><span aria-hidden>📦</span></button>
<button onClick={() => exportHistory('csv')} className="p-1 hover:bg-gray-700 rounded" title="Export CSV"><span aria-hidden>📄</span></button>
<button onClick={clearHistory} className="p-1 hover:bg-red-800 rounded text-red-400" title="Clear History"><span aria-hidden>🗑️</span></button>
</div>
</div>
<input type="text" value={historySearch} onChange={(e) => setHistorySearch(e.target.value)} placeholder="Search history..." className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-sm" />
</div>
<div className="flex-1 overflow-auto p-4 space-y-3">
{filteredHistory.length === 0 ? (
<div className="text-gray-500 text-center py-8">{historySearch ? 'No matching entries' : 'No history yet'}</div>
) : (
filteredHistory.map((item) => (
<div key={item.id} className="bg-gray-700 rounded-lg p-3 space-y-2 group">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-400 mb-1">{new Date(item.timestamp).toLocaleString()}</div>
<div className="text-sm font-medium truncate" title={item.input}>{item.input}</div>
</div>
<button onClick={() => deleteHistoryItem(item.id)} className="p-1 hover:bg-red-800 rounded text-red-400 ml-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Delete"><span aria-hidden>🗑️</span></button>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="bg-gray-600 px-2 py-1 rounded">{item.service}</span>
<span className="bg-blue-800 px-2 py-1 rounded">{item.videoModel}</span>
<span className="bg-purple-800 px-2 py-1 rounded truncate max-w-[100px]" title={item.model}>{item.model}</span>
<span className="bg-green-800 px-2 py-1 rounded">{CREATIVITY_MODES[item.creativity].name}</span>
</div>
<button onClick={() => {
setInputText(item.input);
setOutputText(item.output);
setNegativePrompt(item.negativePrompt || '');
setNegPromptLocked(!item.negativePrompt);
setService(item.service);
setEndpoint(item.endpoint);
setSelectedLLM(item.model);
setCreativityMode(item.creativity);
setVideoModel(item.videoModel);
}} className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-sm font-semibold">
<span aria-hidden>🔄</span> Load Prompt
</button>
</div>
))
)}
</div>
</div>
)}
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>