Răsfoiți Sursa

Initial Ollama Integration

Benjamin Harris 1 lună în urmă
părinte
comite
bb6c755d4b
6 a modificat fișierele cu 388 adăugiri și 0 ștergeri
  1. 14 0
      docker-compose.yml
  2. 91 0
      services/gateway/server.js
  3. 24 0
      ui/src/locales/en.ts
  4. 24 0
      ui/src/locales/tr.ts
  5. 126 0
      ui/src/stores/ai.ts
  6. 109 0
      ui/src/views/Settings.vue

+ 14 - 0
docker-compose.yml

@@ -216,6 +216,19 @@ services:
       - mongodb
       - redis
 
+  # ── Optional: Ollama local AI ─────────────────────────────────────────────────
+  # Uncomment to run Ollama inside Docker (CPU only).
+  # For GPU acceleration, add a `deploy.resources.reservations.devices` block.
+  # After starting, pull a model:  docker exec -it ollama ollama pull llama3.2
+  # ollama:
+  #   image: ollama/ollama:latest
+  #   container_name: ollama
+  #   restart: unless-stopped
+  #   volumes:
+  #     - ollama-data:/root/.ollama
+  #   networks:
+  #     - socialMediaManagerNetwork
+
   ui:
     build: ./ui
     volumes:
@@ -234,6 +247,7 @@ volumes:
   mongodb-data:
   redis-data:
   media_uploads:
+  ollama-data:
   gateway_modules:
   socket_modules:
   formatter_modules:

+ 91 - 0
services/gateway/server.js

@@ -257,6 +257,97 @@ app.put('/profiles/:accountKey', async (request, reply) => {
   return { success: true };
 });
 
+// ─── AI / Ollama ──────────────────────────────────────────────────────────────
+
+const DEFAULT_OLLAMA_ENDPOINT = process.env.OLLAMA_ENDPOINT || 'http://ollama:11434';
+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,
+  };
+});
+
+app.put('/ai/config', async (request, reply) => {
+  const { provider = 'ollama', endpoint, model, enabled = true } = request.body || {};
+  if (!endpoint) return reply.code(400).send({ error: 'endpoint is required' });
+  await setCredentials('ai_config', { provider, endpoint, model, enabled });
+  return { success: true };
+});
+
+app.get('/ai/models', async (request, reply) => {
+  const config = await getCredentials('ai_config');
+  // Allow caller to override endpoint for test-without-save UX
+  const endpoint = request.query.endpoint || config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
+  try {
+    const res = await axios.get(`${endpoint}/api/tags`, { timeout: 5000 });
+    const models = (res.data.models || []).map((m) => m.name);
+    return { models, endpoint };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Could not reach Ollama — check the endpoint', detail: err.message });
+  }
+});
+
+app.post('/ai/generate', async (request, reply) => {
+  const { prompt, system, model: reqModel } = request.body || {};
+  if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
+
+  const config = await getCredentials('ai_config');
+  const endpoint = config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
+  const model = reqModel || config?.model || DEFAULT_OLLAMA_MODEL;
+
+  try {
+    const res = await axios.post(`${endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 90000 });
+    return { text: res.data.response, model, done: res.data.done };
+  } catch (err) {
+    const status = err.response?.status || 503;
+    return reply.code(status).send({ error: 'AI 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 || {};
+  if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
+
+  const config = await getCredentials('ai_config');
+  const endpoint = config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
+  const model = reqModel || config?.model || DEFAULT_OLLAMA_MODEL;
+
+  reply.raw.setHeader('Content-Type', 'text/event-stream');
+  reply.raw.setHeader('Cache-Control', 'no-cache');
+  reply.raw.setHeader('X-Accel-Buffering', 'no');
+  reply.raw.setHeader('Connection', 'keep-alive');
+  reply.raw.flushHeaders();
+
+  try {
+    const ollamaRes = await axios.post(`${endpoint}/api/generate`, { model, prompt, system, stream: true }, { responseType: 'stream', timeout: 120000 });
+
+    ollamaRes.data.on('data', (chunk) => {
+      try {
+        const lines = chunk.toString().split('\n').filter(Boolean);
+        for (const line of lines) {
+          const data = JSON.parse(line);
+          reply.raw.write(`data: ${JSON.stringify({ token: data.response || '', done: !!data.done })}\n\n`);
+        }
+      } catch (_) {}
+    });
+
+    ollamaRes.data.on('end', () => { reply.raw.end(); });
+    ollamaRes.data.on('error', (err) => {
+      reply.raw.write(`data: ${JSON.stringify({ error: err.message, done: true })}\n\n`);
+      reply.raw.end();
+    });
+  } catch (err) {
+    reply.raw.write(`data: ${JSON.stringify({ error: err.message, done: true })}\n\n`);
+    reply.raw.end();
+  }
+});
+
 // ─── Platform service URLs ────────────────────────────────────────────────────
 
 const PLATFORM_SERVICES = {

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

@@ -157,9 +157,33 @@ export default {
       errorTitle: 'OAuth Error',
       getAppHelp: 'Get your App ID and Secret from',
       devPortal: 'developers.facebook.com',
+
+      expiryWarningTitle: 'Instagram token expiring soon',
+      expiryWarningBody: '{username} expires in {days} day | {username} expires in {days} days',
+      expiryReconnect: 'Reconnect now',
+      expiryDismiss: 'Dismiss',
     },
   },
 
+  ai: {
+    sectionTitle: 'AI Integration',
+    sectionSubtitle: 'Connect a local Ollama instance to generate post content with AI.',
+    endpointLabel: 'Ollama Endpoint',
+    endpointPlaceholder: 'http://localhost:11434',
+    endpointHint: 'Inside Docker the default is http://ollama:11434',
+    modelLabel: 'Model',
+    modelPlaceholder: 'Select a model…',
+    testConnection: 'Test Connection',
+    testing: 'Testing…',
+    saveConfig: 'Save',
+    saving: 'Saving…',
+    saved: 'Saved!',
+    noModels: 'No models found — is Ollama running?',
+    connected: 'Connected',
+    connectionFailed: 'Connection failed',
+    modelsAvailable: '{count} model available | {count} models available',
+  },
+
   feed: {
     openOriginal: '↗ Open',
   },

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

@@ -157,9 +157,33 @@ export default {
       errorTitle: 'OAuth Hatası',
       getAppHelp: 'Uygulama Kimliği ve Gizli Anahtarını şuradan al:',
       devPortal: 'developers.facebook.com',
+
+      expiryWarningTitle: 'Instagram token\'ı yakında sona eriyor',
+      expiryWarningBody: '{username} {days} gün içinde sona eriyor | {username} {days} gün içinde sona eriyor',
+      expiryReconnect: 'Şimdi yeniden bağlan',
+      expiryDismiss: 'Kapat',
     },
   },
 
+  ai: {
+    sectionTitle: 'Yapay Zeka Entegrasyonu',
+    sectionSubtitle: 'Gönderi içeriği oluşturmak için yerel bir Ollama örneği bağla.',
+    endpointLabel: 'Ollama Adresi',
+    endpointPlaceholder: 'http://localhost:11434',
+    endpointHint: 'Docker içinde varsayılan: http://ollama:11434',
+    modelLabel: 'Model',
+    modelPlaceholder: 'Model seç…',
+    testConnection: 'Bağlantıyı Test Et',
+    testing: 'Test ediliyor…',
+    saveConfig: 'Kaydet',
+    saving: 'Kaydediliyor…',
+    saved: 'Kaydedildi!',
+    noModels: 'Model bulunamadı — Ollama çalışıyor mu?',
+    connected: 'Bağlandı',
+    connectionFailed: 'Bağlantı başarısız',
+    modelsAvailable: '{count} model mevcut',
+  },
+
   feed: {
     openOriginal: '↗ Aç',
   },

+ 126 - 0
ui/src/stores/ai.ts

@@ -0,0 +1,126 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import axios from 'axios'
+
+export interface AiConfig {
+  provider: string
+  endpoint: string
+  model: string
+  enabled: boolean
+}
+
+export const useAiStore = defineStore('ai', () => {
+  const config = ref<AiConfig>({
+    provider: 'ollama',
+    endpoint: 'http://ollama:11434',
+    model: 'llama3.2',
+    enabled: true,
+  })
+  const models = ref<string[]>([])
+  const loading = ref(false)
+  const saving = ref(false)
+  const modelsLoading = ref(false)
+  const error = ref<string | null>(null)
+
+  async function fetchConfig() {
+    try {
+      const res = await axios.get('/api/ai/config')
+      config.value = res.data
+    } catch (err) {
+      console.error('AI config fetch error:', err)
+    }
+  }
+
+  async function saveConfig(updates: Partial<AiConfig>): Promise<boolean> {
+    saving.value = true
+    error.value = null
+    try {
+      const merged = { ...config.value, ...updates }
+      await axios.put('/api/ai/config', merged)
+      config.value = merged
+      return true
+    } catch (err: any) {
+      error.value = err.response?.data?.error || 'Failed to save config'
+      return false
+    } finally {
+      saving.value = false
+    }
+  }
+
+  async function fetchModels(overrideEndpoint?: string): Promise<boolean> {
+    modelsLoading.value = true
+    error.value = null
+    try {
+      const params = overrideEndpoint ? { endpoint: overrideEndpoint } : {}
+      const res = await axios.get('/api/ai/models', { params })
+      models.value = res.data.models || []
+      return true
+    } catch (err: any) {
+      error.value = err.response?.data?.error || 'Could not connect to Ollama'
+      models.value = []
+      return false
+    } finally {
+      modelsLoading.value = false
+    }
+  }
+
+  async function generate(prompt: string, system?: string, model?: string): Promise<string> {
+    loading.value = true
+    error.value = null
+    try {
+      const res = await axios.post('/api/ai/generate', { prompt, system, model })
+      return res.data.text as string
+    } catch (err: any) {
+      error.value = err.response?.data?.error || 'Generation failed'
+      throw err
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function* streamGenerate(
+    prompt: string,
+    system?: string,
+    model?: string,
+  ): AsyncGenerator<string> {
+    const response = await fetch('/api/ai/stream', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ prompt, system, model }),
+    })
+
+    if (!response.ok || !response.body) {
+      throw new Error(`Stream request failed: ${response.status}`)
+    }
+
+    const reader = response.body.getReader()
+    const decoder = new TextDecoder()
+    let buffer = ''
+
+    while (true) {
+      const { done, value } = await reader.read()
+      if (done) break
+      buffer += decoder.decode(value, { stream: true })
+      const lines = buffer.split('\n')
+      buffer = lines.pop() ?? ''
+      for (const line of lines) {
+        if (!line.startsWith('data: ')) continue
+        const payload = line.slice(6)
+        try {
+          const parsed = JSON.parse(payload) as { token?: string; done?: boolean; error?: string }
+          if (parsed.error) throw new Error(parsed.error)
+          if (parsed.token) yield parsed.token
+          if (parsed.done) return
+        } catch (e) {
+          if (e instanceof SyntaxError) continue
+          throw e
+        }
+      }
+    }
+  }
+
+  return {
+    config, models, loading, saving, modelsLoading, error,
+    fetchConfig, saveConfig, fetchModels, generate, streamGenerate,
+  }
+})

+ 109 - 0
ui/src/views/Settings.vue

@@ -443,6 +443,84 @@
 
       </div>
 
+      <!-- ═══════════════════════════════════════════════════════════════════
+           AI INTEGRATION — Ollama configuration card
+      ════════════════════════════════════════════════════════════════════ -->
+      <div class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
+
+        <!-- Header -->
+        <div class="p-5 border-b border-gray-800 flex items-center gap-3">
+          <div class="w-9 h-9 rounded-full bg-violet-700 flex items-center justify-center text-white text-sm font-bold shrink-0">AI</div>
+          <div>
+            <p class="font-semibold">{{ $t('ai.sectionTitle') }}</p>
+            <p class="text-xs text-gray-500 mt-0.5">{{ $t('ai.sectionSubtitle') }}</p>
+          </div>
+          <!-- Connection status pill -->
+          <div v-if="aiConnected !== null" class="ml-auto shrink-0">
+            <span
+              class="text-xs px-2 py-0.5 rounded-full font-medium"
+              :class="aiConnected ? 'bg-green-900/50 text-green-400 border border-green-700' : 'bg-red-900/40 text-red-400 border border-red-800'"
+            >
+              {{ aiConnected ? $t('ai.connected') : $t('ai.connectionFailed') }}
+            </span>
+          </div>
+        </div>
+
+        <div class="p-5 space-y-4">
+
+          <!-- Endpoint -->
+          <div>
+            <label class="block text-xs text-gray-500 mb-1">{{ $t('ai.endpointLabel') }}</label>
+            <div class="flex gap-2">
+              <input
+                v-model="aiEndpoint"
+                type="text"
+                :placeholder="$t('ai.endpointPlaceholder')"
+                class="flex-1 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"
+              />
+              <button
+                @click="testAiConnection"
+                :disabled="aiStore.modelsLoading || !aiEndpoint"
+                class="px-3 py-2 bg-gray-700 hover:bg-gray-600 disabled:opacity-40 border border-gray-600 rounded-lg text-xs font-medium transition-colors whitespace-nowrap"
+              >
+                {{ aiStore.modelsLoading ? $t('ai.testing') : $t('ai.testConnection') }}
+              </button>
+            </div>
+            <p class="text-xs text-gray-600 mt-1">{{ $t('ai.endpointHint') }}</p>
+          </div>
+
+          <!-- Model selector -->
+          <div>
+            <label class="block text-xs text-gray-500 mb-1">{{ $t('ai.modelLabel') }}</label>
+            <select
+              v-model="aiModel"
+              :disabled="!aiModels.length"
+              class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-violet-500 disabled:opacity-40"
+            >
+              <option value="">{{ $t('ai.modelPlaceholder') }}</option>
+              <option v-for="m in aiModels" :key="m" :value="m">{{ m }}</option>
+            </select>
+            <p v-if="aiConnected === false" class="text-xs text-red-400 mt-1">{{ $t('ai.noModels') }}</p>
+            <p v-else-if="aiModels.length" class="text-xs text-gray-600 mt-1">
+              {{ $t('ai.modelsAvailable', aiModels.length) }}
+            </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>
+            <button
+              @click="saveAiConfig"
+              :disabled="aiStore.saving || !aiEndpoint"
+              class="px-4 py-2 bg-violet-600 hover:bg-violet-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
+            >
+              {{ aiStore.saving ? $t('ai.saving') : $t('ai.saveConfig') }}
+            </button>
+          </div>
+
+        </div>
+      </div>
+
       <!-- Refresh button -->
       <button
         @click="platformsStore.fetchStatuses()"
@@ -461,11 +539,13 @@ import { useRoute } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import axios from 'axios'
 import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
+import { useAiStore } from '../stores/ai'
 
 const { t } = useI18n()
 
 const route = useRoute()
 const platformsStore = usePlatformsStore()
+const aiStore = useAiStore()
 
 // ─── App credential form state ──────────────────────────────────────────────
 
@@ -647,6 +727,30 @@ async function saveProfile(key: string) {
   }
 }
 
+// ─── AI Configuration ─────────────────────────────────────────────────────────
+
+const aiEndpoint = ref('')
+const aiModel = ref('')
+const aiModels = computed(() => aiStore.models)
+const aiConnected = ref<boolean | null>(null)
+const aiSaved = ref(false)
+
+async function testAiConnection() {
+  const ok = await aiStore.fetchModels(aiEndpoint.value)
+  aiConnected.value = ok
+  if (ok && !aiModel.value && aiStore.models.length) {
+    aiModel.value = aiStore.models[0]
+  }
+}
+
+async function saveAiConfig() {
+  const ok = await aiStore.saveConfig({ endpoint: aiEndpoint.value, model: aiModel.value })
+  if (ok) {
+    aiSaved.value = true
+    setTimeout(() => { aiSaved.value = false }, 2500)
+  }
+}
+
 // ─── On mount ────────────────────────────────────────────────────────────────
 
 onMounted(async () => {
@@ -665,6 +769,11 @@ onMounted(async () => {
     platformsStore.fetchStatuses(),
     platformsStore.fetchMetaCredentials(),
     loadMetaConnections(),
+    aiStore.fetchConfig(),
   ])
+
+  // Seed local form from fetched config
+  aiEndpoint.value = aiStore.config.endpoint
+  aiModel.value = aiStore.config.model
 })
 </script>