Browse Source

AI image generation — DALL-E 3 text-to-image + DALL-E 2 variations in Compose

- Gateway: IMAGE_GENERATION_PROVIDERS registry for future extensibility; GET
  /ai/image-providers (dynamic availability check); POST /ai/image handles
  both DALL-E 3 (prompt) and DALL-E 2 (variation from reference image),
  downloads the result immediately to the media library so the OpenAI URL
  expiry is never a problem; returns local mediaUrl
- ai.ts store: generateImage() action
- Compose.vue: image generation panel in AI toolbar (only shown when a
  provider is configured); aspect-ratio picker 1:1 / 16:9 / 9:16, HD
  quality toggle, vivid/natural style, optional variation from current
  attached media; generated image auto-attached to post
- Locales: compose.image* keys in en.ts and tr.ts
- README.md updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 weeks ago
parent
commit
2e1e38b5ba
6 changed files with 341 additions and 1 deletions
  1. 1 0
      README.md
  2. 153 0
      services/gateway/server.js
  3. 13 0
      ui/src/locales/en.ts
  4. 13 0
      ui/src/locales/tr.ts
  5. 12 1
      ui/src/stores/ai.ts
  6. 149 0
      ui/src/views/Compose.vue

+ 1 - 0
README.md

@@ -17,6 +17,7 @@ A self-hosted, local-first social media management platform. Aggregate feeds fro
 - **AI Content Plan** — Generate a full month of platform-native posts with a narrative brief (theme, pillars, tone) at `/calendar-plan`; save all posts as drafts or export to CSV
 - **Account Profiles** — Store business context (name, industry, audience, tone, hashtags) per account for AI context injection; built-in **Strategy Consistency Audit** checks your profile against recent posts
 - **AI Assistance** — Multi-provider: local [Ollama](https://ollama.ai) (llama3.2, llava, etc.), OpenAI (GPT-4o), Groq (Llama, Mixtral), or Google Gemini; draft generation with platform-native writing rules, hashtag suggestions, image captions; streams directly into the editor
+- **AI Image Generation** — Generate images directly in the Compose view using OpenAI DALL-E 3 (text-to-image) or DALL-E 2 (variations from an existing image); choose aspect ratio (1:1, 16:9, 9:16), HD quality, and vivid/natural style; generated images are saved to the media library and auto-attached to the post
 - **Platform-native Writing Rules** — When platforms are selected in Compose, the AI automatically applies platform-specific length, tone, and format rules (Twitter brevity, LinkedIn professional depth, Instagram visual storytelling, etc.)
 - **Competitor Intelligence** — Track up to 5 competitors; AI-powered structured analysis (themes, tone, positioning, gaps, moves); Porter-style **market signal detection** (7 signal types, 3 severity levels); **response prediction** (next moves, vulnerabilities, retaliation triggers); **competitor profile fact-sheet** (pricing, key features, channels, target customer); keyword extraction with intent classification; content gap analysis against your own hashtag history; 5-post content roadmap; side-by-side card layout with double-danger gap highlighting
 - **Competitor Discovery** — "Find Competitors Automatically" uses AI + your account profile to suggest competitors; "Find Nearby" uses Google Places API to discover local competitors by address or area (requires free Google Cloud API key)

+ 153 - 0
services/gateway/server.js

@@ -790,6 +790,17 @@ const PROVIDER_BASE_URLS = {
   groq:   'https://api.groq.com/openai/v1',
 };
 
+// Extensible registry of image-generation providers.
+// Add new providers here when integrating Stable Diffusion, Midjourney, Flux, etc.
+const IMAGE_GENERATION_PROVIDERS = {
+  openai: {
+    name: 'OpenAI DALL-E',
+    textToImage: { model: 'dall-e-3', sizes: ['1024x1024', '1792x1024', '1024x1792'] },
+    imageVariations: { model: 'dall-e-2', sizes: ['256x256', '512x512', '1024x1024'] },
+  },
+  // future: { stable_diffusion: { ... }, midjourney: { ... } }
+};
+
 // Returns decrypted runtime config for the currently active provider
 async function getActiveProviderConfig(ws = 'default') {
   const aiConfig = await getCredentials(ws, 'ai_config');
@@ -1332,6 +1343,148 @@ app.post('/ai/stream', async (request, reply) => {
   }
 });
 
+// ─── AI Image Generation ──────────────────────────────────────────────────────
+
+// GET /ai/image-providers — return configured providers that support image generation
+app.get('/ai/image-providers', async (request, reply) => {
+  const ws = request.workspaceId;
+  const configured = [];
+
+  // OpenAI — check if API key is saved
+  const openaiDoc = await getCredentials(ws, 'openai_config');
+  if (openaiDoc?.apiKey) {
+    configured.push({
+      name: 'openai',
+      label: IMAGE_GENERATION_PROVIDERS.openai.name,
+      textToImage: IMAGE_GENERATION_PROVIDERS.openai.textToImage,
+      imageVariations: IMAGE_GENERATION_PROVIDERS.openai.imageVariations,
+    });
+  }
+
+  // Future providers (stable_diffusion, midjourney, etc.) would be appended here
+
+  return { providers: configured };
+});
+
+// POST /ai/image — generate or vary an image, save to media library, return local URL
+// Body: { prompt, size?, quality?, style?, referenceImageUrl? }
+//   prompt            — text description of the desired image
+//   size              — one of the provider's supported sizes (default 1024x1024)
+//   quality           — 'standard' | 'hd' (DALL-E 3 only, default 'standard')
+//   style             — 'vivid' | 'natural' (DALL-E 3 only, default 'vivid')
+//   referenceImageUrl — local /media/ path or external URL; triggers image variations (DALL-E 2)
+app.post('/ai/image', async (request, reply) => {
+  const { prompt, size = '1024x1024', quality = 'standard', style = 'vivid', referenceImageUrl } = request.body || {};
+  if (!prompt?.trim() && !referenceImageUrl) {
+    return reply.code(400).send({ error: 'prompt is required' });
+  }
+
+  const ws = request.workspaceId;
+  const openaiDoc = await getCredentials(ws, 'openai_config');
+  if (!openaiDoc?.apiKey) {
+    return reply.code(503).send({ error: 'Image generation requires an OpenAI API key. Add it in Global Settings → OpenAI.' });
+  }
+
+  const apiKey = decryptToken(openaiDoc.apiKey);
+  const OPENAI_API = PROVIDER_BASE_URLS.openai;
+
+  try {
+    let remoteUrl;
+    let model;
+
+    if (referenceImageUrl) {
+      // ── Image variations via DALL-E 2 ─────────────────────────────────────
+      model = IMAGE_GENERATION_PROVIDERS.openai.imageVariations.model;
+
+      // Load the reference image (support local /media/ paths and external URLs)
+      let imgBuffer;
+      if (referenceImageUrl.startsWith('/media/')) {
+        imgBuffer = fs.readFileSync(path.join(UPLOAD_DIR, path.basename(referenceImageUrl)));
+      } else if (referenceImageUrl.match(/https?:\/\/(localhost|127\.0\.0\.1)/)) {
+        const internalUrl = referenceImageUrl.replace(/https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, 'http://nginx:8081');
+        const res = await axios.get(internalUrl, { responseType: 'arraybuffer', timeout: 15000 });
+        imgBuffer = Buffer.from(res.data);
+      } else {
+        const res = await axios.get(referenceImageUrl, { responseType: 'arraybuffer', timeout: 15000 });
+        imgBuffer = Buffer.from(res.data);
+      }
+
+      const allowedVariationSizes = IMAGE_GENERATION_PROVIDERS.openai.imageVariations.sizes;
+      const variationSize = allowedVariationSizes.includes(size) ? size : '1024x1024';
+
+      const form = new FormData();
+      form.append('image', new Blob([imgBuffer], { type: 'image/png' }), 'reference.png');
+      form.append('n', '1');
+      form.append('size', variationSize);
+      form.append('response_format', 'url');
+
+      const res = await fetch(`${OPENAI_API}/images/variations`, {
+        method: 'POST',
+        headers: { Authorization: `Bearer ${apiKey}` },
+        body: form,
+      });
+      if (!res.ok) {
+        const errBody = await res.json().catch(() => ({}));
+        throw new Error(errBody.error?.message || `OpenAI variations API error ${res.status}`);
+      }
+      remoteUrl = (await res.json()).data?.[0]?.url;
+
+    } else {
+      // ── Text-to-image via DALL-E 3 ────────────────────────────────────────
+      model = IMAGE_GENERATION_PROVIDERS.openai.textToImage.model;
+
+      const allowedSizes = IMAGE_GENERATION_PROVIDERS.openai.textToImage.sizes;
+      const imageSize = allowedSizes.includes(size) ? size : '1024x1024';
+
+      const res = await axios.post(`${OPENAI_API}/images/generations`, {
+        model,
+        prompt: prompt.trim().slice(0, 4000),
+        n: 1,
+        size: imageSize,
+        quality: quality === 'hd' ? 'hd' : 'standard',
+        style: style === 'natural' ? 'natural' : 'vivid',
+        response_format: 'url',
+      }, {
+        headers: { Authorization: `Bearer ${apiKey}` },
+        timeout: 120000,
+      });
+      remoteUrl = res.data.data?.[0]?.url;
+    }
+
+    if (!remoteUrl) throw new Error('No image URL returned from OpenAI');
+
+    // Download and persist — OpenAI URLs expire after ~1 hour
+    const imgRes = await axios.get(remoteUrl, { responseType: 'arraybuffer', timeout: 30000 });
+    const imgBuffer = Buffer.from(imgRes.data);
+    const filename = `${crypto.randomUUID()}.png`;
+    fs.writeFileSync(path.join(UPLOAD_DIR, filename), imgBuffer);
+
+    const appBase = (process.env.APP_BASE_URL || '').replace(/\/$/, '');
+    const mediaUrl = appBase ? `${appBase}/media/${filename}` : `/media/${filename}`;
+
+    const db = await getDb();
+    await db.collection('media_files').insertOne({
+      filename,
+      originalName: `ai-image-${model}-${Date.now()}.png`,
+      url: mediaUrl,
+      mimetype: 'image/png',
+      size: imgBuffer.length,
+      folder: null,
+      tags: ['ai-generated'],
+      workspaceId: ws,
+      uploadedAt: new Date(),
+    });
+
+    log.info({ action: 'image_generate', provider: 'openai', model, size, outcome: 'success' });
+    return { mediaUrl, provider: 'openai', model };
+
+  } catch (err) {
+    const detail = err.response?.data?.error?.message || err.message;
+    log.error({ action: 'image_generate', provider: 'openai', outcome: 'failure', err: detail });
+    return reply.code(502).send({ error: 'Image generation failed', detail });
+  }
+});
+
 // ─── Monthly Content Calendar ─────────────────────────────────────────────────
 
 // POST /ai/content-brief — generate a narrative brief only (step 1 of 2-step calendar flow)

+ 13 - 0
ui/src/locales/en.ts

@@ -242,6 +242,19 @@ export default {
     captionGenerating: 'Generating caption…',
     captionError: 'Caption generation failed',
 
+    imageButton: 'Generate image',
+    imageGenerateTitle: 'AI Image Generation',
+    imageGeneratePowered: 'Powered by DALL-E 3',
+    imagePrompt: 'Image prompt',
+    imagePromptPlaceholder: 'Describe the image you want to generate…',
+    imageStyleVivid: 'Vivid',
+    imageStyleNatural: 'Natural',
+    imageVariation: 'Use current media as reference (variation)',
+    imageGenerate: 'Generate Image',
+    imageGenerating: 'Generating…',
+    imageGeneratingHint: 'This may take up to 30 seconds.',
+    imageGenerateFailed: 'Image generation failed',
+
     firstCommentToggle: 'First Comment',
     firstCommentPlaceholder: 'Add a first comment (hashtags, links, extra context)…',
     firstCommentHint: 'Supported on Instagram, Facebook, Mastodon, and Bluesky.',

+ 13 - 0
ui/src/locales/tr.ts

@@ -242,6 +242,19 @@ export default {
     captionGenerating: 'Açıklama oluşturuluyor…',
     captionError: 'Açıklama oluşturma başarısız',
 
+    imageButton: 'Görsel oluştur',
+    imageGenerateTitle: 'Yapay Zeka Görsel Üretimi',
+    imageGeneratePowered: 'DALL-E 3 tarafından desteklenmektedir',
+    imagePrompt: 'Görsel açıklaması',
+    imagePromptPlaceholder: 'Oluşturmak istediğiniz görseli açıklayın…',
+    imageStyleVivid: 'Canlı',
+    imageStyleNatural: 'Doğal',
+    imageVariation: 'Mevcut medyayı referans olarak kullan (varyasyon)',
+    imageGenerate: 'Görsel Oluştur',
+    imageGenerating: 'Oluşturuluyor…',
+    imageGeneratingHint: 'Bu 30 saniyeye kadar sürebilir.',
+    imageGenerateFailed: 'Görsel oluşturma başarısız',
+
     firstCommentToggle: 'İlk Yorum',
     firstCommentPlaceholder: 'İlk yorum ekle (hashtagler, bağlantılar, ek bilgi)…',
     firstCommentHint: 'Instagram, Facebook, Mastodon ve Bluesky\'de desteklenir.',

+ 12 - 1
ui/src/stores/ai.ts

@@ -151,6 +151,17 @@ export const useAiStore = defineStore('ai', () => {
     }
   }
 
+  async function generateImage(options: {
+    prompt: string
+    size?: string
+    quality?: 'standard' | 'hd'
+    style?: 'vivid' | 'natural'
+    referenceImageUrl?: string
+  }): Promise<{ mediaUrl: string; provider: string; model: string }> {
+    const res = await axios.post('/api/ai/image', options)
+    return res.data
+  }
+
   async function generateCaption(imageUrl: string): Promise<string> {
     loading.value = true
     error.value = null
@@ -218,6 +229,6 @@ export const useAiStore = defineStore('ai', () => {
   return {
     config, providers, models, loading, saving, modelsLoading, error,
     fetchConfig, fetchProviders, saveProvider, deleteProvider, fetchProviderModels,
-    saveConfig, fetchModels, generate, generateCaption, streamGenerate,
+    saveConfig, fetchModels, generate, generateImage, generateCaption, streamGenerate,
   }
 })

+ 149 - 0
ui/src/views/Compose.vue

@@ -184,6 +184,17 @@
               <span>{{ $t('compose.aiButton') }}</span>
             </button>
 
+            <!-- Image generation toggle — only shown when a provider is configured -->
+            <button
+              v-if="imageGenAvailable"
+              @click="toggleImagePanel"
+              class="flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors"
+              :class="imagePanelOpen ? 'text-emerald-400 bg-emerald-900/30' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'"
+            >
+              <i class="fa-solid fa-image text-[10px]"></i>
+              <span>{{ $t('compose.imageButton') }}</span>
+            </button>
+
             <span class="ml-auto text-xs font-mono" :class="overLimit ? 'text-red-400' : charNearLimit ? 'text-amber-400' : 'text-gray-600'">
               {{ composeStore.content.length }}<template v-if="composeStore.activeCharLimit">/{{ composeStore.activeCharLimit }}</template>
             </span>
@@ -324,6 +335,90 @@
           </div>
         </div>
 
+        <!-- Image Generation Panel -->
+        <div v-if="imagePanelOpen && imageGenAvailable" class="bg-gray-900 border border-emerald-800/40 rounded-xl overflow-hidden">
+          <div class="px-4 py-3 border-b border-gray-800 flex items-center gap-2">
+            <i class="fa-solid fa-image text-emerald-400 text-xs"></i>
+            <span class="text-sm font-medium text-white">{{ $t('compose.imageGenerateTitle') }}</span>
+            <span class="text-xs text-gray-500 ml-auto">{{ $t('compose.imageGeneratePowered') }}</span>
+          </div>
+          <div class="px-4 py-3 space-y-3">
+            <!-- Prompt -->
+            <div>
+              <label class="block text-xs text-gray-400 mb-1.5">{{ $t('compose.imagePrompt') }}</label>
+              <textarea
+                v-model="imagePrompt"
+                rows="2"
+                class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 resize-none focus:outline-none focus:border-emerald-600 placeholder-gray-600"
+                :placeholder="$t('compose.imagePromptPlaceholder')"
+              />
+            </div>
+
+            <!-- Controls row -->
+            <div class="flex flex-wrap gap-2 items-center">
+              <!-- Aspect ratio -->
+              <div class="flex rounded-lg overflow-hidden border border-gray-700 shrink-0">
+                <button
+                  v-for="s in IMAGE_SIZES" :key="s.value"
+                  @click="imageSize = s.value"
+                  class="px-2.5 py-1 text-xs transition-colors"
+                  :class="imageSize === s.value ? 'bg-emerald-700 text-white' : 'text-gray-400 hover:text-gray-300 bg-gray-800'"
+                >{{ s.label }}</button>
+              </div>
+
+              <!-- HD quality toggle -->
+              <button
+                @click="imageQuality = imageQuality === 'hd' ? 'standard' : 'hd'"
+                class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-lg border transition-colors shrink-0"
+                :class="imageQuality === 'hd' ? 'bg-amber-700/40 border-amber-600 text-amber-300' : 'border-gray-700 text-gray-400 hover:border-gray-600 hover:text-gray-300'"
+              >
+                <i class="fa-solid fa-star text-[9px]"></i> HD
+              </button>
+
+              <!-- Style toggle -->
+              <div class="flex rounded-lg overflow-hidden border border-gray-700 shrink-0">
+                <button
+                  @click="imageStyle = 'vivid'"
+                  class="px-2.5 py-1 text-xs transition-colors"
+                  :class="imageStyle === 'vivid' ? 'bg-violet-700 text-white' : 'text-gray-400 hover:text-gray-300 bg-gray-800'"
+                >{{ $t('compose.imageStyleVivid') }}</button>
+                <button
+                  @click="imageStyle = 'natural'"
+                  class="px-2.5 py-1 text-xs transition-colors"
+                  :class="imageStyle === 'natural' ? 'bg-violet-700 text-white' : 'text-gray-400 hover:text-gray-300 bg-gray-800'"
+                >{{ $t('compose.imageStyleNatural') }}</button>
+              </div>
+            </div>
+
+            <!-- Variation option (when an image is already attached) -->
+            <label
+              v-if="composeStore.mediaUrl && isImage(composeStore.mediaUrl)"
+              class="flex items-center gap-2 text-xs text-gray-400 cursor-pointer"
+            >
+              <input type="checkbox" v-model="useImageVariation" class="w-3.5 h-3.5 accent-emerald-500" />
+              {{ $t('compose.imageVariation') }}
+            </label>
+
+            <!-- Generate button + error -->
+            <div class="flex items-center gap-3">
+              <button
+                @click="generateImage"
+                :disabled="imageGenerating || (!imagePrompt.trim() && !useImageVariation)"
+                class="flex items-center gap-1.5 text-xs px-4 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 text-white rounded-lg transition-colors font-medium"
+              >
+                <svg v-if="imageGenerating" class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
+                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
+                </svg>
+                <i v-else class="fa-solid fa-wand-magic-sparkles text-[10px]"></i>
+                {{ imageGenerating ? $t('compose.imageGenerating') : $t('compose.imageGenerate') }}
+              </button>
+              <span v-if="imageError" class="text-xs text-red-400">{{ imageError }}</span>
+              <span v-if="imageGenerating" class="text-xs text-gray-500">{{ $t('compose.imageGeneratingHint') }}</span>
+            </div>
+          </div>
+        </div>
+
         <!-- Hashtag suggestions -->
         <div
           v-if="suggestedHashtags.length || hashtagsLoading"
@@ -546,6 +641,14 @@ onMounted(async () => {
   ])
   composeStore.initDestinations()
 
+  // Check whether an image generation provider is configured
+  try {
+    const imgRes = await axios.get('/api/ai/image-providers')
+    imageGenAvailable.value = (imgRes.data.providers?.length ?? 0) > 0
+  } catch {
+    imageGenAvailable.value = false
+  }
+
   // Pre-fill content when arriving from Competitor Roadmap ("Draft this post")
   if (route.query.prefill) {
     composeStore.content = String(route.query.prefill)
@@ -900,6 +1003,52 @@ async function generateCaption() {
   }
 }
 
+// ─── Image Generation ────────────────────────────────────────────────────────
+
+const IMAGE_SIZES = [
+  { value: '1024x1024', label: '1:1' },
+  { value: '1792x1024', label: '16:9' },
+  { value: '1024x1792', label: '9:16' },
+]
+
+const imageGenAvailable = ref(false)
+const imagePanelOpen = ref(false)
+const imagePrompt = ref('')
+const imageSize = ref('1024x1024')
+const imageQuality = ref<'standard' | 'hd'>('standard')
+const imageStyle = ref<'vivid' | 'natural'>('vivid')
+const useImageVariation = ref(false)
+const imageGenerating = ref(false)
+const imageError = ref('')
+
+function toggleImagePanel() {
+  imagePanelOpen.value = !imagePanelOpen.value
+  if (imagePanelOpen.value && !imagePrompt.value) {
+    imagePrompt.value = composeStore.content.slice(0, 500)
+  }
+}
+
+async function generateImage() {
+  imageGenerating.value = true
+  imageError.value = ''
+  try {
+    const result = await aiStore.generateImage({
+      prompt: imagePrompt.value,
+      size: imageSize.value,
+      quality: imageQuality.value,
+      style: imageStyle.value,
+      referenceImageUrl: useImageVariation.value && composeStore.mediaUrl ? composeStore.mediaUrl : undefined,
+    })
+    composeStore.mediaUrl = result.mediaUrl
+    mediaLoadError.value = false
+    imagePanelOpen.value = false
+  } catch (err: any) {
+    imageError.value = err.response?.data?.detail || err.response?.data?.error || t('compose.imageGenerateFailed')
+  } finally {
+    imageGenerating.value = false
+  }
+}
+
 // ─── Hashtag Suggestions ──────────────────────────────────────────────────────
 
 const suggestedHashtags = ref<string[]>([])