@pheem49/mint 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.github/workflows/ci.yml +14 -0
  2. package/Cargo.lock +3 -3
  3. package/Cargo.toml +1 -1
  4. package/README.md +82 -21
  5. package/assets/Mint_CLI.png +0 -0
  6. package/assets/Mint_Desktop.png +0 -0
  7. package/assets/Mint_Web.png +0 -0
  8. package/assets/Mint_WorkSpace.png +0 -0
  9. package/assets/Setting.png +0 -0
  10. package/assets/bridges.svg +4 -0
  11. package/assets/chat.svg +3 -0
  12. package/assets/code.svg +4 -0
  13. package/assets/features.svg +3 -0
  14. package/assets/imagegen.svg +5 -0
  15. package/assets/live2d.svg +6 -0
  16. package/assets/memory.svg +5 -0
  17. package/assets/screencapture.svg +4 -0
  18. package/assets/setup.svg +3 -0
  19. package/assets/tools.svg +4 -0
  20. package/bin/mint +0 -0
  21. package/crates/mint-cli/src/agent.rs +6 -2
  22. package/crates/mint-cli/src/main.rs +145 -5
  23. package/crates/mint-cli/src/onboard.rs +134 -1
  24. package/crates/mint-core/src/api_server.rs +108 -3
  25. package/crates/mint-core/src/config.rs +20 -1
  26. package/crates/mint-core/src/image_gen.rs +895 -0
  27. package/crates/mint-core/src/lib.rs +4 -0
  28. package/package.json +3 -3
  29. package/src/bin/index.js +0 -0
  30. package/src/renderer/index-web.html +1 -1
  31. package/src/renderer/index.html +1 -1
  32. package/src/renderer/src/components/ChatPanel.tsx +252 -26
  33. package/src/renderer/src/components/DashboardSidebar.tsx +11 -3
  34. package/src/renderer/src/components/ImageStudioPanel.tsx +638 -0
  35. package/src/renderer/src/components/MintDashboard.tsx +14 -5
  36. package/src/renderer/src/components/SettingsWindow.tsx +29 -20
  37. package/src/renderer/src/css/image-studio.css +722 -0
  38. package/src/renderer/src/css/settings.css +13 -3
  39. package/src/renderer/src/css/styles.css +50 -26
  40. package/src/renderer/src/index.css +106 -0
  41. package/src/renderer/src/tauri.ts +123 -0
  42. package/src/renderer/src-web/components/ChatPanel.tsx +273 -25
  43. package/src/renderer/src-web/components/DashboardSidebar.tsx +11 -3
  44. package/src/renderer/src-web/components/ImageStudioPanel.tsx +638 -0
  45. package/src/renderer/src-web/components/MintDashboard.tsx +13 -4
  46. package/src/renderer/src-web/components/SettingsWindow.tsx +29 -20
  47. package/src/renderer/src-web/css/image-studio.css +722 -0
  48. package/src/renderer/src-web/css/settings.css +14 -4
  49. package/src/renderer/src-web/css/styles.css +50 -26
  50. package/src/renderer/src-web/index.css +106 -0
  51. package/src/renderer/src-web/tauri.ts +124 -0
  52. package/src-tauri/Cargo.toml +29 -0
  53. package/src-tauri/build.rs +3 -0
  54. package/src-tauri/capabilities/main.json +7 -0
  55. package/src-tauri/gen/schemas/acl-manifests.json +1 -0
  56. package/src-tauri/gen/schemas/capabilities.json +1 -0
  57. package/src-tauri/gen/schemas/desktop-schema.json +2412 -0
  58. package/src-tauri/gen/schemas/linux-schema.json +2412 -0
  59. package/src-tauri/src/browser.rs +141 -0
  60. package/src-tauri/src/desktop.rs +392 -0
  61. package/src-tauri/src/discord_rpc.rs +99 -0
  62. package/src-tauri/src/events.rs +70 -0
  63. package/src-tauri/src/headless.rs +222 -0
  64. package/src-tauri/src/integrations.rs +126 -0
  65. package/src-tauri/src/lib.rs +1076 -0
  66. package/src-tauri/src/main.rs +3 -0
  67. package/src-tauri/src/plugins.rs +16 -0
  68. package/src-tauri/src/proactive.rs +254 -0
  69. package/src-tauri/src/system.rs +250 -0
  70. package/src-tauri/src/updater.rs +148 -0
  71. package/src-tauri/src/webhooks.rs +255 -0
  72. package/src-tauri/src/workflows.rs +70 -0
  73. package/src-tauri/tauri.conf.json +48 -0
  74. package/tsconfig.json +1 -1
@@ -0,0 +1,638 @@
1
+ import React, { useState, useRef, useCallback, useEffect } from 'react'
2
+ import {
3
+ generateImages,
4
+ getImageGenProviders,
5
+ convertFileSrc,
6
+ listSavedPictures,
7
+ type ImageGenRequest,
8
+ type ImageGenResponse,
9
+ type ImageGenProviders,
10
+ type PictureEntry,
11
+ } from '../tauri'
12
+
13
+ type AspectRatio = '1:1' | '16:9' | '9:16' | '4:3'
14
+
15
+ interface ImageStudioPanelProps {
16
+ view: string
17
+ onRefreshPictures?: () => void
18
+ onSendToChat?: (dataUri: string, prompt: string) => void
19
+ }
20
+
21
+ const ASPECT_OPTIONS: { value: AspectRatio; label: string; icon: string }[] = [
22
+ { value: '1:1', label: '1:1', icon: '⬛' },
23
+ { value: '16:9', label: '16:9', icon: '▬' },
24
+ { value: '9:16', label: '9:16', icon: '▮' },
25
+ { value: '4:3', label: '4:3', icon: '⬜' },
26
+ ]
27
+
28
+ const STYLE_SUGGESTIONS = [
29
+ 'photorealistic', 'anime', 'oil painting', 'watercolor',
30
+ 'cinematic', 'digital art', '3D render', 'sketch',
31
+ ]
32
+
33
+ // Per-provider model presets shown in the model dropdown
34
+ const PROVIDER_MODELS: Record<string, { value: string; label: string }[]> = {
35
+ nanobanana: [
36
+ { value: 'gemini-2.5-flash-image', label: 'Gemini 2.5 Flash Image (Default)' },
37
+ { value: 'gemini-2.0-flash-image', label: 'Gemini 2.0 Flash Image' },
38
+ ],
39
+ dalle: [
40
+ { value: 'dall-e-3', label: 'DALL·E 3 (Default)' },
41
+ { value: 'gpt-image-1', label: 'GPT-Image-1' },
42
+ { value: 'dall-e-2', label: 'DALL·E 2' },
43
+ ],
44
+ stability: [
45
+ { value: 'sd3.5-large', label: 'SD 3.5 Large (Default)' },
46
+ { value: 'sd3.5-large-turbo', label: 'SD 3.5 Large Turbo' },
47
+ { value: 'sd3-medium', label: 'SD 3 Medium' },
48
+ { value: 'core', label: 'Stable Image Core' },
49
+ ],
50
+ ideogram: [
51
+ { value: 'V_3', label: 'Ideogram V3 (Default)' },
52
+ { value: 'V_2', label: 'Ideogram V2' },
53
+ { value: 'V_2_TURBO', label: 'Ideogram V2 Turbo' },
54
+ ],
55
+ replicate: [
56
+ { value: 'black-forest-labs/flux-1.1-pro', label: 'FLUX 1.1 Pro (Default)' },
57
+ { value: 'black-forest-labs/flux-schnell', label: 'FLUX Schnell (fast)' },
58
+ { value: 'stability-ai/sdxl', label: 'SDXL' },
59
+ { value: 'bytedance/sdxl-lightning-4step', label: 'SDXL Lightning' },
60
+ ],
61
+ }
62
+
63
+ const PROVIDER_LABELS: Record<string, string> = {
64
+ nanobanana: '✦ NanoBanana (Gemini)',
65
+ dalle: '⬡ DALL·E (OpenAI)',
66
+ stability: '◈ Stability AI',
67
+ ideogram: '◉ Ideogram',
68
+ replicate: '⬢ Replicate',
69
+ }
70
+
71
+ function providerLabel(key: string) {
72
+ return PROVIDER_LABELS[key] ?? key
73
+ }
74
+
75
+ function defaultModelForProvider(provider: string): string {
76
+ const models = PROVIDER_MODELS[provider]
77
+ return models?.[0]?.value ?? ''
78
+ }
79
+
80
+ function SkeletonCard() {
81
+ return (
82
+ <div className="img-studio-skeleton">
83
+ <div className="img-studio-skeleton-shine" />
84
+ </div>
85
+ )
86
+ }
87
+
88
+ export default function ImageStudioPanel({ view, onRefreshPictures, onSendToChat }: ImageStudioPanelProps) {
89
+ const [prompt, setPrompt] = useState('')
90
+ const [negativePrompt, setNegativePrompt] = useState('')
91
+ const [showNegative, setShowNegative] = useState(false)
92
+ const [aspectRatio, setAspectRatio] = useState<AspectRatio>('1:1')
93
+ const [numImages, setNumImages] = useState(1)
94
+ const [generating, setGenerating] = useState(false)
95
+ const [error, setError] = useState('')
96
+ const [result, setResult] = useState<ImageGenResponse | null>(null)
97
+ const [promptHistory, setPromptHistory] = useState<string[]>([])
98
+ const [historyPictures, setHistoryPictures] = useState<PictureEntry[]>([])
99
+
100
+ const loadHistory = useCallback(async () => {
101
+ try {
102
+ const pics = await listSavedPictures()
103
+ const imageGenSources = ['nanobanana', 'dalle', 'stability', 'ideogram', 'replicate']
104
+ const filtered = pics.filter((pic) =>
105
+ imageGenSources.includes(pic.source?.toLowerCase()),
106
+ )
107
+ const sorted = [...filtered].sort((a, b) => {
108
+ const da = a.createdAt ? new Date(a.createdAt).getTime() : 0
109
+ const db = b.createdAt ? new Date(b.createdAt).getTime() : 0
110
+ return db - da || b.filename.localeCompare(a.filename)
111
+ })
112
+ setHistoryPictures(sorted)
113
+ } catch (err) {
114
+ console.error('Failed to load history pictures:', err)
115
+ }
116
+ }, [])
117
+
118
+ // Provider / model state
119
+ const [providers, setProviders] = useState<ImageGenProviders>({ active: 'nanobanana', available: ['nanobanana'] })
120
+ const [selectedProvider, setSelectedProvider] = useState('nanobanana')
121
+ const [selectedModel, setSelectedModel] = useState(defaultModelForProvider('nanobanana'))
122
+ const [customModel, setCustomModel] = useState('')
123
+
124
+ const promptRef = useRef<HTMLTextAreaElement>(null)
125
+
126
+ // Load available providers and history on mount
127
+ useEffect(() => {
128
+ let cancelled = false
129
+ getImageGenProviders().then((data) => {
130
+ if (cancelled) return
131
+ setProviders(data)
132
+ setSelectedProvider(data.active)
133
+ setSelectedModel(defaultModelForProvider(data.active))
134
+ }).catch(() => { /* keep defaults */ })
135
+ loadHistory()
136
+ return () => { cancelled = true }
137
+ }, [loadHistory])
138
+
139
+ // When provider changes, reset model to provider's default
140
+ const handleProviderChange = (provider: string) => {
141
+ setSelectedProvider(provider)
142
+ setSelectedModel(defaultModelForProvider(provider))
143
+ setCustomModel('')
144
+ }
145
+
146
+ const effectiveModel = selectedModel === 'custom' ? customModel.trim() : selectedModel
147
+
148
+ const handleGenerate = useCallback(async () => {
149
+ const trimmed = prompt.trim()
150
+ if (!trimmed || generating) return
151
+
152
+ setGenerating(true)
153
+ setError('')
154
+ setResult(null)
155
+
156
+ const request: ImageGenRequest = {
157
+ prompt: trimmed,
158
+ negativePrompt: negativePrompt.trim() || undefined,
159
+ aspectRatio,
160
+ numImages,
161
+ model: effectiveModel || undefined,
162
+ provider: selectedProvider,
163
+ }
164
+
165
+ try {
166
+ const response = await generateImages(request)
167
+ setResult(response)
168
+ setPromptHistory((prev) =>
169
+ [trimmed, ...prev.filter((p) => p !== trimmed)].slice(0, 8),
170
+ )
171
+ onRefreshPictures?.()
172
+ loadHistory()
173
+ } catch (err) {
174
+ setError(err instanceof Error ? err.message : String(err))
175
+ } finally {
176
+ setGenerating(false)
177
+ }
178
+ }, [prompt, negativePrompt, aspectRatio, numImages, generating, onRefreshPictures, selectedProvider, effectiveModel])
179
+
180
+ const handleKeyDown = (e: React.KeyboardEvent) => {
181
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
182
+ e.preventDefault()
183
+ handleGenerate()
184
+ }
185
+ }
186
+
187
+ const downloadImage = (entry: PictureEntry, index: number) => {
188
+ const a = document.createElement('a')
189
+ a.href = entry.url || entry.path
190
+ a.download = `mint-imagine-${index + 1}.png`
191
+ document.body.appendChild(a)
192
+ a.click()
193
+ document.body.removeChild(a)
194
+ }
195
+
196
+ if (view !== 'imagine') return null
197
+
198
+ const modelOptions = PROVIDER_MODELS[selectedProvider] ?? []
199
+
200
+ return (
201
+ <div className="img-studio" id="image-studio-panel" role="main" aria-label="Image Studio">
202
+ {/* Header */}
203
+ <header className="img-studio-header">
204
+ <div className="img-studio-header-title">
205
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
206
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
207
+ </svg>
208
+ <h1>Image Studio</h1>
209
+ <span className="img-studio-badge">
210
+ {providerLabel(selectedProvider)}
211
+ </span>
212
+ </div>
213
+ <p className="img-studio-subtitle">
214
+ Generate images with{' '}
215
+ {providers.available.length > 1
216
+ ? `${providers.available.length} configured providers`
217
+ : providerLabel(selectedProvider)}
218
+ </p>
219
+ </header>
220
+
221
+ {/* Main content */}
222
+ <div className="img-studio-content">
223
+ {/* Left: controls */}
224
+ <section className="img-studio-controls" aria-label="Generation settings">
225
+
226
+ {/* Provider & Model side-by-side dropdown selectors */}
227
+ <div className="img-studio-field" style={{ display: 'flex', gap: '10px', width: '100%' }}>
228
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
229
+ <label className="img-studio-label" htmlFor="img-studio-provider" style={{ display: 'flex', alignItems: 'center', width: '100%', height: '18px' }}>
230
+ <span>Provider</span>
231
+ </label>
232
+ <select
233
+ id="img-studio-provider"
234
+ className="img-studio-textarea"
235
+ style={{ padding: '8px 10px', height: '38px', cursor: 'pointer' }}
236
+ value={selectedProvider}
237
+ onChange={(e) => handleProviderChange(e.target.value)}
238
+ disabled={generating}
239
+ >
240
+ {providers.available.map((prov) => (
241
+ <option key={prov} value={prov}>
242
+ {providerLabel(prov).replace(/^[^a-zA-Z0-9]+/, '') /* remove prefix symbol if preferred or keep it */}
243
+ </option>
244
+ ))}
245
+ </select>
246
+ </div>
247
+
248
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
249
+ <label className="img-studio-label" htmlFor="img-studio-model" style={{ display: 'flex', alignItems: 'center', height: '18px' }}>
250
+ Model
251
+ </label>
252
+ <select
253
+ id="img-studio-model"
254
+ className="img-studio-textarea"
255
+ style={{ padding: '8px 10px', height: '38px', cursor: 'pointer' }}
256
+ value={selectedModel}
257
+ onChange={(e) => setSelectedModel(e.target.value)}
258
+ disabled={generating}
259
+ >
260
+ {modelOptions.map(({ value, label }) => (
261
+ <option key={value} value={value}>{label}</option>
262
+ ))}
263
+ <option value="custom">Custom Model ID...</option>
264
+ </select>
265
+ </div>
266
+ </div>
267
+
268
+ {/* Custom Model ID input */}
269
+ {selectedModel === 'custom' && (
270
+ <div className="img-studio-field" style={{ marginTop: '-8px' }}>
271
+ <input
272
+ type="text"
273
+ className="img-studio-textarea img-studio-textarea--sm"
274
+ placeholder={
275
+ selectedProvider === 'replicate'
276
+ ? 'e.g. owner/model-name'
277
+ : selectedProvider === 'dalle'
278
+ ? 'e.g. dall-e-3'
279
+ : 'e.g. sd3.5-large'
280
+ }
281
+ value={customModel}
282
+ onChange={(e) => setCustomModel(e.target.value)}
283
+ disabled={generating}
284
+ />
285
+ </div>
286
+ )}
287
+
288
+ {/* Prompt */}
289
+ <div className="img-studio-field">
290
+ <label className="img-studio-label" htmlFor="img-studio-prompt">
291
+ Prompt
292
+ <span className="img-studio-label-hint">Describe the image you want</span>
293
+ </label>
294
+ <textarea
295
+ id="img-studio-prompt"
296
+ ref={promptRef}
297
+ className="img-studio-textarea"
298
+ value={prompt}
299
+ onChange={(e) => setPrompt(e.target.value)}
300
+ onKeyDown={handleKeyDown}
301
+ placeholder="a dragon flying over Bangkok at sunset, golden hour, ultra-detailed..."
302
+ rows={4}
303
+ disabled={generating}
304
+ aria-required="true"
305
+ />
306
+ <div className="img-studio-prompt-hint">⌘+Enter to generate</div>
307
+ </div>
308
+
309
+ {/* Style suggestion chips */}
310
+ <div className="img-studio-field">
311
+ <span className="img-studio-label">Style suggestions</span>
312
+ <div className="img-studio-chips" role="group" aria-label="Style suggestions">
313
+ {STYLE_SUGGESTIONS.map((style) => (
314
+ <button
315
+ key={style}
316
+ type="button"
317
+ className="img-studio-chip"
318
+ onClick={() => setPrompt((p) => p.trim() ? `${p.trim()}, ${style}` : style)}
319
+ disabled={generating}
320
+ title={`Add "${style}" to prompt`}
321
+ >
322
+ {style}
323
+ </button>
324
+ ))}
325
+ </div>
326
+ </div>
327
+
328
+ {/* Negative prompt (collapsible) */}
329
+ <div className="img-studio-field">
330
+ <button
331
+ type="button"
332
+ className="img-studio-toggle"
333
+ onClick={() => setShowNegative((v) => !v)}
334
+ aria-expanded={showNegative}
335
+ aria-controls="img-studio-negative-wrap"
336
+ >
337
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
338
+ style={{ transform: showNegative ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s' }}>
339
+ <polyline points="9 18 15 12 9 6"/>
340
+ </svg>
341
+ Negative prompt
342
+ {negativePrompt.trim() && <span className="img-studio-badge-dot" aria-label="has content" />}
343
+ </button>
344
+ {showNegative && (
345
+ <div id="img-studio-negative-wrap">
346
+ <textarea
347
+ id="img-studio-negative"
348
+ className="img-studio-textarea img-studio-textarea--sm"
349
+ value={negativePrompt}
350
+ onChange={(e) => setNegativePrompt(e.target.value)}
351
+ placeholder="blurry, low quality, distorted, text, watermark..."
352
+ rows={2}
353
+ disabled={generating}
354
+ />
355
+ </div>
356
+ )}
357
+ </div>
358
+
359
+ {/* Aspect ratio */}
360
+ <div className="img-studio-field">
361
+ <span className="img-studio-label">Aspect ratio</span>
362
+ <div className="img-studio-aspect-group" role="radiogroup" aria-label="Aspect ratio">
363
+ {ASPECT_OPTIONS.map(({ value, label, icon }) => (
364
+ <button
365
+ key={value}
366
+ type="button"
367
+ role="radio"
368
+ aria-checked={aspectRatio === value}
369
+ className={`img-studio-aspect-btn ${aspectRatio === value ? 'is-active' : ''}`}
370
+ onClick={() => setAspectRatio(value)}
371
+ disabled={generating}
372
+ id={`img-studio-aspect-${value.replace(':', 'x')}`}
373
+ >
374
+ <span className="img-studio-aspect-icon" aria-hidden="true">{icon}</span>
375
+ <span>{label}</span>
376
+ </button>
377
+ ))}
378
+ </div>
379
+ </div>
380
+
381
+ {/* Count — DALL·E 3 only supports n=1 */}
382
+ <div className="img-studio-field">
383
+ <span className="img-studio-label">
384
+ Number of images
385
+ {(selectedProvider === 'dalle' && (effectiveModel === 'dall-e-3' || effectiveModel === 'gpt-image-1')) && (
386
+ <span className="img-studio-label-hint"> (DALL·E 3 / GPT-Image-1: max 1)</span>
387
+ )}
388
+ </span>
389
+ <div className="img-studio-count-group" role="radiogroup" aria-label="Image count">
390
+ {[1, 2, 3, 4].map((n) => {
391
+ const isDisabled = generating ||
392
+ (selectedProvider === 'dalle' && (effectiveModel === 'dall-e-3' || effectiveModel === 'gpt-image-1') && n > 1)
393
+ return (
394
+ <button
395
+ key={n}
396
+ type="button"
397
+ role="radio"
398
+ aria-checked={numImages === n}
399
+ className={`img-studio-count-btn ${numImages === n ? 'is-active' : ''}`}
400
+ onClick={() => setNumImages(n)}
401
+ disabled={isDisabled}
402
+ id={`img-studio-count-${n}`}
403
+ >
404
+ {n}
405
+ </button>
406
+ )
407
+ })}
408
+ </div>
409
+ </div>
410
+
411
+ {/* Generate button */}
412
+ <button
413
+ id="img-studio-generate-btn"
414
+ type="button"
415
+ className={`img-studio-generate-btn ${generating ? 'is-loading' : ''}`}
416
+ onClick={handleGenerate}
417
+ disabled={!prompt.trim() || generating}
418
+ aria-busy={generating}
419
+ >
420
+ {generating ? (
421
+ <>
422
+ <span className="img-studio-spinner" aria-hidden="true" />
423
+ Generating...
424
+ </>
425
+ ) : (
426
+ <>
427
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
428
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
429
+ </svg>
430
+ Generate Image
431
+ </>
432
+ )}
433
+ </button>
434
+
435
+ {/* Error */}
436
+ {error && (
437
+ <div className="img-studio-error" role="alert">
438
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
439
+ <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
440
+ </svg>
441
+ <span>{error}</span>
442
+ <button type="button" onClick={() => setError('')} aria-label="Dismiss error">×</button>
443
+ </div>
444
+ )}
445
+
446
+ {/* Prompt history */}
447
+ {promptHistory.length > 0 && (
448
+ <div className="img-studio-field">
449
+ <span className="img-studio-label">Recent prompts</span>
450
+ <div className="img-studio-history">
451
+ {promptHistory.map((p) => (
452
+ <button
453
+ key={p}
454
+ type="button"
455
+ className="img-studio-history-item"
456
+ onClick={() => { setPrompt(p); promptRef.current?.focus() }}
457
+ disabled={generating}
458
+ title={p}
459
+ >
460
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
461
+ <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
462
+ </svg>
463
+ <span>{p}</span>
464
+ </button>
465
+ ))}
466
+ </div>
467
+ </div>
468
+ )}
469
+ </section>
470
+
471
+ {/* Right: results */}
472
+ <section className="img-studio-results" aria-label="Generated images" aria-live="polite">
473
+ {/* Active Generation Session workspace */}
474
+ <div className="img-studio-active-workspace">
475
+ {generating && (
476
+ <div className="img-studio-skeleton-grid">
477
+ {Array.from({ length: numImages }).map((_, i) => (
478
+ <SkeletonCard key={i} />
479
+ ))}
480
+ <p className="img-studio-generating-label" aria-live="assertive">
481
+ Creating {numImages} image{numImages > 1 ? 's' : ''} with {providerLabel(selectedProvider)} — this may take a few seconds…
482
+ </p>
483
+ </div>
484
+ )}
485
+
486
+ {!generating && result && (
487
+ <>
488
+ {result.description && (
489
+ <p className="img-studio-description">{result.description}</p>
490
+ )}
491
+ <div
492
+ className="img-studio-grid"
493
+ style={{ '--img-count': result.images.length } as React.CSSProperties}
494
+ >
495
+ {result.images.map((entry, idx) => (
496
+ <article key={entry.id} className="img-studio-card" aria-label={`Generated image ${idx + 1}`}>
497
+ <div className="img-studio-card-img-wrap">
498
+ <img
499
+ src={convertFileSrc(entry.path || entry.url)}
500
+ alt={entry.message || prompt}
501
+ className="img-studio-card-img"
502
+ loading="eager"
503
+ decoding="async"
504
+ />
505
+ </div>
506
+ <div className="img-studio-card-actions">
507
+ <button
508
+ type="button"
509
+ className="img-studio-action-btn"
510
+ onClick={() => downloadImage(entry, idx)}
511
+ title="Download image"
512
+ id={`img-studio-download-${idx}`}
513
+ >
514
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
515
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
516
+ </svg>
517
+ Download
518
+ </button>
519
+ {onSendToChat && (
520
+ <button
521
+ type="button"
522
+ className="img-studio-action-btn img-studio-action-btn--primary"
523
+ onClick={() => onSendToChat(entry.url || entry.path, prompt)}
524
+ title="Send to Chat"
525
+ id={`img-studio-send-${idx}`}
526
+ >
527
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
528
+ <line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
529
+ </svg>
530
+ Send to Chat
531
+ </button>
532
+ )}
533
+ </div>
534
+ </article>
535
+ ))}
536
+ </div>
537
+ <p className="img-studio-meta">
538
+ Provider: <strong>{providerLabel(result.provider)}</strong> · Model: <strong>{result.model}</strong> · {result.images.length} image{result.images.length > 1 ? 's' : ''} · Saved to gallery
539
+ </p>
540
+ </>
541
+ )}
542
+
543
+ {!generating && !result && (
544
+ <div className="img-studio-active-empty">
545
+ <div className="img-studio-empty-icon" aria-hidden="true">
546
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
547
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
548
+ </svg>
549
+ </div>
550
+ <p>No active generation</p>
551
+ <span>Enter a prompt on the left pane and generate new images.</span>
552
+ </div>
553
+ )}
554
+ </div>
555
+
556
+ {/* 2. Middle divider / tab header */}
557
+ <div className="img-studio-history-header">
558
+ <div className="img-studio-history-title">
559
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
560
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
561
+ </svg>
562
+ <span>Gallery & History</span>
563
+ <span className="img-studio-history-badge">
564
+ {(() => {
565
+ const currentIds = new Set(result?.images.map(img => img.id) || [])
566
+ return historyPictures.filter(img => !currentIds.has(img.id)).length
567
+ })()}
568
+ </span>
569
+ </div>
570
+ </div>
571
+
572
+ {/* 3. Previously Generated Gallery list */}
573
+ <div className="img-studio-history-gallery">
574
+ {(() => {
575
+ const currentIds = new Set(result?.images.map(img => img.id) || [])
576
+ const filteredHistory = historyPictures.filter(img => !currentIds.has(img.id))
577
+
578
+ if (filteredHistory.length === 0) {
579
+ return (
580
+ <div className="img-studio-gallery-empty">
581
+ <p>Your saved gallery is empty</p>
582
+ <span>Generated images will be stored here automatically.</span>
583
+ </div>
584
+ )
585
+ }
586
+
587
+ return (
588
+ <div className="img-studio-grid">
589
+ {filteredHistory.map((picture, idx) => (
590
+ <article key={picture.id} className="img-studio-card" aria-label={`Saved image ${idx + 1}`}>
591
+ <div className="img-studio-card-img-wrap">
592
+ <img
593
+ src={convertFileSrc(picture.thumbnailPath || picture.thumbnailUrl || picture.path || picture.url)}
594
+ alt={picture.message || 'Generated image'}
595
+ className="img-studio-card-img"
596
+ loading="lazy"
597
+ decoding="async"
598
+ />
599
+ </div>
600
+ <div className="img-studio-card-actions">
601
+ <button
602
+ type="button"
603
+ className="img-studio-action-btn"
604
+ onClick={() => downloadImage(picture, idx)}
605
+ title="Download image"
606
+ id={`img-studio-gallery-download-${idx}`}
607
+ >
608
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
609
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
610
+ </svg>
611
+ Download
612
+ </button>
613
+ {onSendToChat && (
614
+ <button
615
+ type="button"
616
+ className="img-studio-action-btn img-studio-action-btn--primary"
617
+ onClick={() => onSendToChat(picture.url || picture.path, picture.message || '')}
618
+ title="Send to Chat"
619
+ id={`img-studio-gallery-send-${idx}`}
620
+ >
621
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
622
+ <line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
623
+ </svg>
624
+ Send to Chat
625
+ </button>
626
+ )}
627
+ </div>
628
+ </article>
629
+ ))}
630
+ </div>
631
+ )
632
+ })()}
633
+ </div>
634
+ </section>
635
+ </div>
636
+ </div>
637
+ )
638
+ }