Ver código fonte

AI Caption from Image

Benjamin Harris 1 mês atrás
pai
commit
2ee311aac3

+ 47 - 6
services/gateway/server.js

@@ -265,17 +265,18 @@ const DEFAULT_OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2';
 app.get('/ai/config', async () => {
   const config = await getCredentials('ai_config');
   return {
-    provider: config?.provider || 'ollama',
-    endpoint: config?.endpoint || DEFAULT_OLLAMA_ENDPOINT,
-    model:    config?.model    || DEFAULT_OLLAMA_MODEL,
-    enabled:  config?.enabled  ?? true,
+    provider:     config?.provider     || 'ollama',
+    endpoint:     config?.endpoint     || DEFAULT_OLLAMA_ENDPOINT,
+    model:        config?.model        || DEFAULT_OLLAMA_MODEL,
+    visionModel:  config?.visionModel  || 'llava',
+    enabled:      config?.enabled      ?? true,
   };
 });
 
 app.put('/ai/config', async (request, reply) => {
-  const { provider = 'ollama', endpoint, model, enabled = true } = request.body || {};
+  const { provider = 'ollama', endpoint, model, visionModel = 'llava', enabled = true } = request.body || {};
   if (!endpoint) return reply.code(400).send({ error: 'endpoint is required' });
-  await setCredentials('ai_config', { provider, endpoint, model, enabled });
+  await setCredentials('ai_config', { provider, endpoint, model, visionModel, enabled });
   return { success: true };
 });
 
@@ -309,6 +310,46 @@ app.post('/ai/generate', async (request, reply) => {
   }
 });
 
+// Vision caption — fetches image, passes base64 to Ollama vision model
+app.post('/ai/caption', async (request, reply) => {
+  const { imageUrl, model: reqModel } = request.body || {};
+  if (!imageUrl) return reply.code(400).send({ error: 'imageUrl is required' });
+
+  const config = await getCredentials('ai_config');
+  const endpoint = config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
+  const model = reqModel || config?.visionModel || 'llava';
+
+  // Fetch image → base64
+  let imageBase64;
+  try {
+    let imageBuffer;
+    if (imageUrl.startsWith('/media/')) {
+      const filename = path.basename(imageUrl);
+      const filepath = path.join(UPLOAD_DIR, filename);
+      imageBuffer = fs.readFileSync(filepath);
+    } else {
+      const imgRes = await axios.get(imageUrl, { responseType: 'arraybuffer', timeout: 15000 });
+      imageBuffer = Buffer.from(imgRes.data);
+    }
+    imageBase64 = imageBuffer.toString('base64');
+  } catch (err) {
+    return reply.code(400).send({ error: 'Could not load image', detail: err.message });
+  }
+
+  try {
+    const res = await axios.post(`${endpoint}/api/generate`, {
+      model,
+      prompt: 'Generate an engaging, concise social media caption for this image. Write only the caption text with relevant hashtags. No explanations or preamble.',
+      images: [imageBase64],
+      stream: false,
+    }, { timeout: 90000 });
+    return { caption: res.data.response, model };
+  } catch (err) {
+    const status = err.response?.status || 503;
+    return reply.code(status).send({ error: 'Caption generation failed', detail: err.message });
+  }
+});
+
 // SSE streaming endpoint — sends token-by-token as text/event-stream
 app.post('/ai/stream', async (request, reply) => {
   const { prompt, system, model: reqModel } = request.body || {};

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

@@ -89,6 +89,10 @@ export default {
     aiNoContext: 'No profile — set one in Settings',
     aiNotConfigured: 'AI not configured — check Settings → AI Integration',
     aiError: 'Generation failed',
+
+    captionGenerate: '✨ Generate caption',
+    captionGenerating: 'Generating caption…',
+    captionError: 'Caption generation failed',
   },
 
   scheduler: {
@@ -208,6 +212,9 @@ export default {
     connected: 'Connected',
     connectionFailed: 'Connection failed',
     modelsAvailable: '{count} model available | {count} models available',
+    visionModelLabel: 'Vision Model',
+    visionModelPlaceholder: 'e.g. llava, llama3.2-vision',
+    visionModelHint: 'Used for image captioning. Pull with: ollama pull llava',
   },
 
   feed: {

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

@@ -89,6 +89,10 @@ export default {
     aiNoContext: 'Profil yok — Ayarlar\'dan ekle',
     aiNotConfigured: 'YZ yapılandırılmamış — Ayarlar → YZ Entegrasyonu',
     aiError: 'Oluşturma başarısız',
+
+    captionGenerate: '✨ Açıklama oluştur',
+    captionGenerating: 'Açıklama oluşturuluyor…',
+    captionError: 'Açıklama oluşturma başarısız',
   },
 
   scheduler: {
@@ -208,6 +212,9 @@ export default {
     connected: 'Bağlandı',
     connectionFailed: 'Bağlantı başarısız',
     modelsAvailable: '{count} model mevcut',
+    visionModelLabel: 'Görsel Model',
+    visionModelPlaceholder: 'örn. llava, llama3.2-vision',
+    visionModelHint: 'Görsel açıklama için kullanılır. Yükle: ollama pull llava',
   },
 
   feed: {

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

@@ -6,6 +6,7 @@ export interface AiConfig {
   provider: string
   endpoint: string
   model: string
+  visionModel: string
   enabled: boolean
 }
 
@@ -14,6 +15,7 @@ export const useAiStore = defineStore('ai', () => {
     provider: 'ollama',
     endpoint: 'http://ollama:11434',
     model: 'llama3.2',
+    visionModel: 'llava',
     enabled: true,
   })
   const models = ref<string[]>([])
@@ -78,6 +80,23 @@ export const useAiStore = defineStore('ai', () => {
     }
   }
 
+  async function generateCaption(imageUrl: string): Promise<string> {
+    loading.value = true
+    error.value = null
+    try {
+      const res = await axios.post('/api/ai/caption', {
+        imageUrl,
+        model: config.value.visionModel,
+      })
+      return res.data.caption as string
+    } catch (err: any) {
+      error.value = err.response?.data?.error || 'Caption generation failed'
+      throw err
+    } finally {
+      loading.value = false
+    }
+  }
+
   async function* streamGenerate(
     prompt: string,
     system?: string,
@@ -123,6 +142,6 @@ export const useAiStore = defineStore('ai', () => {
 
   return {
     config, models, loading, saving, modelsLoading, error,
-    fetchConfig, saveConfig, fetchModels, generate, streamGenerate,
+    fetchConfig, saveConfig, fetchModels, generate, generateCaption, streamGenerate,
   }
 })

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

@@ -93,6 +93,25 @@
               >✕</button>
             </div>
             <p v-if="mediaLoadError" class="text-xs text-red-400 mt-1">{{ $t('compose.mediaLoadError') }}</p>
+
+            <!-- Caption generation button — only for images when AI is configured -->
+            <div v-if="isImage(composeStore.mediaUrl) && aiConfigured && !mediaLoadError" class="mt-2">
+              <button
+                @click="generateCaption"
+                :disabled="captionGenerating"
+                class="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border transition-colors disabled:opacity-50"
+                :class="captionGenerating
+                  ? 'border-violet-700/40 text-violet-400 bg-violet-900/20'
+                  : 'border-violet-700/60 text-violet-300 hover:bg-violet-900/30 hover:border-violet-600'"
+              >
+                <svg v-if="captionGenerating" 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>
+                {{ captionGenerating ? $t('compose.captionGenerating') : $t('compose.captionGenerate') }}
+              </button>
+              <p v-if="captionError" class="text-xs text-red-400 mt-1">{{ $t('compose.captionError') }}</p>
+            </div>
           </div>
 
           <!-- Upload progress -->
@@ -607,6 +626,25 @@ function stopGeneration() {
   abortController.value?.abort()
 }
 
+// ─── Image Caption (Vision) ───────────────────────────────────────────────────
+
+const captionGenerating = ref(false)
+const captionError = ref(false)
+
+async function generateCaption() {
+  captionError.value = false
+  captionGenerating.value = true
+  try {
+    const caption = await aiStore.generateCaption(composeStore.mediaUrl)
+    const sep = composeStore.content.trim() ? '\n\n' : ''
+    composeStore.content = composeStore.content.trim() + sep + caption
+  } catch {
+    captionError.value = true
+  } finally {
+    captionGenerating.value = false
+  }
+}
+
 // ─── Hashtag Suggestions ──────────────────────────────────────────────────────
 
 const suggestedHashtags = ref<string[]>([])

+ 15 - 1
ui/src/views/Settings.vue

@@ -506,6 +506,18 @@
             </p>
           </div>
 
+          <!-- Vision model -->
+          <div>
+            <label class="block text-xs text-gray-500 mb-1">{{ $t('ai.visionModelLabel') }}</label>
+            <input
+              v-model="aiVisionModel"
+              type="text"
+              :placeholder="$t('ai.visionModelPlaceholder')"
+              class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-violet-500"
+            />
+            <p class="text-xs text-gray-600 mt-1">{{ $t('ai.visionModelHint') }}</p>
+          </div>
+
           <!-- Save -->
           <div class="flex items-center justify-end gap-3">
             <span v-if="aiSaved" class="text-xs text-green-400">{{ $t('ai.saved') }}</span>
@@ -731,6 +743,7 @@ async function saveProfile(key: string) {
 
 const aiEndpoint = ref('')
 const aiModel = ref('')
+const aiVisionModel = ref('')
 const aiModels = computed(() => aiStore.models)
 const aiConnected = ref<boolean | null>(null)
 const aiSaved = ref(false)
@@ -744,7 +757,7 @@ async function testAiConnection() {
 }
 
 async function saveAiConfig() {
-  const ok = await aiStore.saveConfig({ endpoint: aiEndpoint.value, model: aiModel.value })
+  const ok = await aiStore.saveConfig({ endpoint: aiEndpoint.value, model: aiModel.value, visionModel: aiVisionModel.value })
   if (ok) {
     aiSaved.value = true
     setTimeout(() => { aiSaved.value = false }, 2500)
@@ -775,5 +788,6 @@ onMounted(async () => {
   // Seed local form from fetched config
   aiEndpoint.value = aiStore.config.endpoint
   aiModel.value = aiStore.config.model
+  aiVisionModel.value = aiStore.config.visionModel
 })
 </script>