@pheem49/mint 1.6.3 → 1.7.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/Cargo.lock +3 -3
- package/Cargo.toml +1 -1
- package/README.md +82 -21
- package/assets/Mint_CLI.png +0 -0
- package/assets/Mint_Desktop.png +0 -0
- package/assets/Mint_Web.png +0 -0
- package/assets/Mint_WorkSpace.png +0 -0
- package/assets/Setting.png +0 -0
- package/assets/bridges.svg +4 -0
- package/assets/chat.svg +3 -0
- package/assets/code.svg +4 -0
- package/assets/features.svg +3 -0
- package/assets/imagegen.svg +5 -0
- package/assets/live2d.svg +6 -0
- package/assets/memory.svg +5 -0
- package/assets/screencapture.svg +4 -0
- package/assets/setup.svg +3 -0
- package/assets/tools.svg +4 -0
- package/bin/mint +0 -0
- package/crates/mint-cli/src/agent.rs +47 -15
- package/crates/mint-cli/src/main.rs +534 -7
- package/crates/mint-cli/src/onboard.rs +134 -1
- package/crates/mint-cli/src/setup.rs +173 -32
- package/crates/mint-core/src/api_server.rs +147 -3
- package/crates/mint-core/src/config.rs +22 -3
- package/crates/mint-core/src/image_gen.rs +895 -0
- package/crates/mint-core/src/lib.rs +4 -0
- package/crates/mint-core/src/memory.rs +27 -1
- package/crates/mint-core/src/orchestration.rs +1 -1
- package/crates/mint-core/src/plugins.rs +32 -0
- package/crates/mint-core/tests/memory_persistence.rs +28 -0
- package/package.json +1 -1
- package/src/bin/index.js +0 -0
- package/src/renderer/src/agentProgress.ts +39 -0
- package/src/renderer/src/components/ChatPanel.tsx +547 -47
- package/src/renderer/src/components/DashboardSidebar.tsx +12 -4
- package/src/renderer/src/components/ImageStudioPanel.tsx +638 -0
- package/src/renderer/src/components/MintDashboard.tsx +84 -31
- package/src/renderer/src/components/ModelPanel.tsx +2 -2
- package/src/renderer/src/components/PicturesLibrary.tsx +5 -1
- package/src/renderer/src/components/SettingsWindow.tsx +29 -20
- package/src/renderer/src/components/SpotlightWindow.tsx +8 -8
- package/src/renderer/src/components/WorkspacePanel.tsx +1 -1
- package/src/renderer/src/css/image-studio.css +722 -0
- package/src/renderer/src/css/settings.css +13 -3
- package/src/renderer/src/css/styles.css +245 -43
- package/src/renderer/src/index.css +152 -0
- package/src/renderer/src/tauri.ts +148 -0
- package/src/renderer/src-web/App.tsx +16 -1
- package/src/renderer/src-web/agentProgress.ts +39 -0
- package/src/renderer/src-web/components/ChatPanel.tsx +568 -46
- package/src/renderer/src-web/components/DashboardSidebar.tsx +10 -2
- package/src/renderer/src-web/components/ImageStudioPanel.tsx +638 -0
- package/src/renderer/src-web/components/MintDashboard.tsx +65 -19
- package/src/renderer/src-web/components/PicturesLibrary.tsx +5 -1
- package/src/renderer/src-web/components/SettingsWindow.tsx +29 -20
- package/src/renderer/src-web/css/image-studio.css +722 -0
- package/src/renderer/src-web/css/settings.css +14 -4
- package/src/renderer/src-web/css/styles.css +292 -44
- package/src/renderer/src-web/index.css +152 -0
- package/src/renderer/src-web/tauri.ts +149 -0
- package/src-tauri/src/lib.rs +65 -5
- package/src-tauri/tauri.conf.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
|
|
3
|
-
export type DashboardView = 'chat' | 'pictures' | 'model'
|
|
3
|
+
export type DashboardView = 'chat' | 'pictures' | 'model' | 'imagine'
|
|
4
4
|
|
|
5
5
|
interface ChatSessionItem {
|
|
6
6
|
id: string
|
|
@@ -56,7 +56,7 @@ export default function DashboardSidebar({
|
|
|
56
56
|
<div
|
|
57
57
|
className="sidebar-brand clickable"
|
|
58
58
|
onClick={onToggleSidebar}
|
|
59
|
-
title={sidebarCollapsed ? "
|
|
59
|
+
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
60
60
|
role="button"
|
|
61
61
|
tabIndex={0}
|
|
62
62
|
onKeyDown={(event) => {
|
|
@@ -95,6 +95,14 @@ export default function DashboardSidebar({
|
|
|
95
95
|
</span>
|
|
96
96
|
<span>Pictures</span>
|
|
97
97
|
</button>
|
|
98
|
+
<button className={`sidebar-top-action ${view === 'imagine' ? 'is-active' : ''}`} onClick={() => onSetView('imagine')}>
|
|
99
|
+
<span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
|
|
100
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
101
|
+
<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"></polygon>
|
|
102
|
+
</svg>
|
|
103
|
+
</span>
|
|
104
|
+
<span>Image Studio</span>
|
|
105
|
+
</button>
|
|
98
106
|
|
|
99
107
|
<div className="sidebar-section">
|
|
100
108
|
<div className="sidebar-section-title">Conversation CLI</div>
|
|
@@ -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.url || entry.path)}
|
|
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.thumbnailUrl || picture.thumbnailPath || picture.url || picture.path)}
|
|
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
|
+
}
|