Bladeren bron

Additional AI Providers

Benjamin Harris 1 maand geleden
bovenliggende
commit
8b43957685
6 gewijzigde bestanden met toevoegingen van 656 en 63 verwijderingen
  1. 20 4
      README.md
  2. 320 49
      services/gateway/server.js
  3. 26 0
      ui/src/locales/en.ts
  4. 26 0
      ui/src/locales/tr.ts
  5. 74 2
      ui/src/stores/ai.ts
  6. 190 8
      ui/src/views/Settings.vue

+ 20 - 4
README.md

@@ -14,7 +14,7 @@ A self-hosted, local-first social media management platform. Aggregate feeds fro
 - **Draft Saving** — Save posts and return to them later from the Drafts tab
 - **Content Calendar** — Month/week calendar view of scheduled posts in the Scheduler
 - **Account Profiles** — Store business context (name, industry, audience, tone, hashtags) per account for AI context injection
-- **AI Assistance** — Powered by local [Ollama](https://ollama.ai): draft generation, hashtag suggestions, image captions (via vision models); streams directly into the editor
+- **AI Assistance** — Multi-provider: local [Ollama](https://ollama.ai) (llama3.2, llava, etc.), OpenAI (GPT-4o), Groq (Llama, Mixtral), or Google Gemini; draft generation, hashtag suggestions, image captions; streams directly into the editor
 - **Analytics & Insights** — Publishing stats, 30-day activity chart, platform breakdown, per-account filtering, engagement heatmap, best posting times, and top posts (crawled from platform APIs)
 - **Scheduling Suggestions** — Optimal posting times suggested in Compose, based on your engagement history or industry defaults
 - **Token Expiry Warnings** — Dashboard banner when Meta tokens are within 7 days of expiry
@@ -35,7 +35,7 @@ A self-hosted, local-first social media management platform. Aggregate feeds fro
 | Platform Services | Node.js / Fastify (one per platform) |
 | Database | MongoDB 6 |
 | Job Queue | Redis + BullMQ |
-| AI | [Ollama](https://ollama.ai) (local LLM — llama3.2, llava, etc.) |
+| AI | [Ollama](https://ollama.ai) (local) · OpenAI · Groq · Google Gemini |
 | Logging | Pino (structured JSON) |
 | Reverse Proxy | Nginx |
 | Containerization | Docker Compose |
@@ -105,7 +105,11 @@ docker compose up -d
 
 Open **<http://localhost:8081>** in your browser.
 
-### 4. (Optional) Connect AI via Ollama
+### 4. (Optional) Connect AI
+
+SocialManager supports four AI providers. Switch between them at any time in **Settings → AI Integration**.
+
+#### Ollama (local, free)
 
 Run Ollama on your host machine and pull a model:
 
@@ -114,7 +118,19 @@ ollama pull llama3.2      # text generation
 ollama pull llava         # image captioning (vision)
 ```
 
-Then go to **Settings → AI Integration**, set the endpoint to `http://host.docker.internal:11434`, test the connection, and save.
+In Settings, set the Ollama endpoint to `http://host.docker.internal:11434`, test the connection, and click **Save**.
+
+#### Cloud providers (OpenAI · Groq · Gemini)
+
+Go to **Settings → AI Integration** and open the card for the provider you want:
+
+| Provider | Where to get a key |
+| --- | --- |
+| OpenAI (GPT-4o, GPT-4o-mini) | [platform.openai.com](https://platform.openai.com) |
+| Groq (Llama 3, Mixtral) | [console.groq.com](https://console.groq.com) |
+| Google Gemini (2.0 Flash, 1.5 Pro) | [aistudio.google.com](https://aistudio.google.com) |
+
+Paste your API key and click **Connect & Set Active**. Keys are stored AES-256-GCM encrypted in MongoDB — no `.env` editing required. Only one provider is active at a time; switch with the **Set as Active** button on any configured card.
 
 ---
 

+ 320 - 49
services/gateway/server.js

@@ -355,32 +355,208 @@ app.put('/profiles/:accountKey', async (request, reply) => {
   return { success: true };
 });
 
-// ─── AI / Ollama ──────────────────────────────────────────────────────────────
+// ─── AI / Multi-provider ─────────────────────────────────────────────────────
 
 const DEFAULT_OLLAMA_ENDPOINT = process.env.OLLAMA_ENDPOINT || 'http://ollama:11434';
 const DEFAULT_OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2';
 
+const PROVIDER_MODELS = {
+  openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
+  groq:   ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768', 'gemma2-9b-it'],
+  gemini: ['gemini-2.0-flash', 'gemini-1.5-flash', 'gemini-1.5-pro'],
+};
+
+const PROVIDER_BASE_URLS = {
+  openai: 'https://api.openai.com/v1',
+  groq:   'https://api.groq.com/openai/v1',
+};
+
+// Returns decrypted runtime config for the currently active provider
+async function getActiveProviderConfig() {
+  const aiConfig = await getCredentials('ai_config');
+  const provider = aiConfig?.provider || 'ollama';
+  if (provider === 'openai' || provider === 'groq') {
+    const doc = await getCredentials(`${provider}_config`);
+    return {
+      provider,
+      apiKey: doc?.apiKey ? decryptToken(doc.apiKey) : null,
+      model: doc?.model || PROVIDER_MODELS[provider][0],
+      baseUrl: PROVIDER_BASE_URLS[provider],
+    };
+  }
+  if (provider === 'gemini') {
+    const doc = await getCredentials('gemini_config');
+    return {
+      provider,
+      apiKey: doc?.apiKey ? decryptToken(doc.apiKey) : null,
+      model: doc?.model || PROVIDER_MODELS.gemini[0],
+    };
+  }
+  return {
+    provider: 'ollama',
+    endpoint: aiConfig?.endpoint || DEFAULT_OLLAMA_ENDPOINT,
+    model: aiConfig?.model || DEFAULT_OLLAMA_MODEL,
+    visionModel: aiConfig?.visionModel || 'llava',
+  };
+}
+
+function buildOpenAIMessages(prompt, system) {
+  const messages = [];
+  if (system) messages.push({ role: 'system', content: system });
+  messages.push({ role: 'user', content: prompt });
+  return messages;
+}
+
+// Gemini encodes system as a leading user/model conversation pair
+function buildGeminiContents(prompt, system) {
+  const contents = [];
+  if (system) {
+    contents.push({ role: 'user',  parts: [{ text: system }] });
+    contents.push({ role: 'model', parts: [{ text: 'Understood.' }] });
+  }
+  contents.push({ role: 'user', parts: [{ text: prompt }] });
+  return contents;
+}
+
 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,
-    visionModel:  config?.visionModel  || 'llava',
-    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, visionModel = 'llava', enabled = true } = request.body || {};
-  if (!endpoint) return reply.code(400).send({ error: 'endpoint is required' });
+  if (provider === 'ollama' && !endpoint) return reply.code(400).send({ error: 'endpoint is required for Ollama' });
   await setCredentials('ai_config', { provider, endpoint, model, visionModel, enabled });
   return { success: true };
 });
 
+// ─── Provider management routes ───────────────────────────────────────────────
+
+app.get('/ai/providers', async () => {
+  const aiConfig = await getCredentials('ai_config');
+  const active = aiConfig?.provider || 'ollama';
+  const [openaiDoc, groqDoc, geminiDoc] = await Promise.all([
+    getCredentials('openai_config'),
+    getCredentials('groq_config'),
+    getCredentials('gemini_config'),
+  ]);
+  return {
+    active,
+    providers: [
+      {
+        name: 'ollama',
+        configured: true,
+        active: active === 'ollama',
+        endpoint: aiConfig?.endpoint || DEFAULT_OLLAMA_ENDPOINT,
+        model: aiConfig?.model || DEFAULT_OLLAMA_MODEL,
+        visionModel: aiConfig?.visionModel || 'llava',
+      },
+      {
+        name: 'openai',
+        configured: !!openaiDoc?.apiKey,
+        active: active === 'openai',
+        model: openaiDoc?.model || PROVIDER_MODELS.openai[0],
+        apiKeyHint: openaiDoc?.apiKey ? `sk-...${decryptToken(openaiDoc.apiKey).slice(-4)}` : null,
+      },
+      {
+        name: 'groq',
+        configured: !!groqDoc?.apiKey,
+        active: active === 'groq',
+        model: groqDoc?.model || PROVIDER_MODELS.groq[0],
+        apiKeyHint: groqDoc?.apiKey ? `gsk_...${decryptToken(groqDoc.apiKey).slice(-4)}` : null,
+      },
+      {
+        name: 'gemini',
+        configured: !!geminiDoc?.apiKey,
+        active: active === 'gemini',
+        model: geminiDoc?.model || PROVIDER_MODELS.gemini[0],
+        apiKeyHint: geminiDoc?.apiKey ? `AIza...${decryptToken(geminiDoc.apiKey).slice(-4)}` : null,
+      },
+    ],
+  };
+});
+
+// PUT /ai/provider/:name — save credentials and optionally set as active
+// ollama body: { endpoint, model, visionModel, setActive? }
+// others body: { apiKey, model, setActive? }
+app.put('/ai/provider/:name', async (request, reply) => {
+  const { name } = request.params;
+  const { apiKey, model, endpoint, visionModel, setActive = false } = request.body || {};
+
+  if (name === 'ollama') {
+    if (!endpoint) return reply.code(400).send({ error: 'endpoint is required for Ollama' });
+    const existing = await getCredentials('ai_config') || {};
+    await setCredentials('ai_config', {
+      ...existing,
+      provider: setActive ? 'ollama' : (existing.provider || 'ollama'),
+      endpoint,
+      model: model || DEFAULT_OLLAMA_MODEL,
+      visionModel: visionModel || 'llava',
+    });
+  } else if (['openai', 'groq', 'gemini'].includes(name)) {
+    if (!apiKey) return reply.code(400).send({ error: 'apiKey is required' });
+    await setCredentials(`${name}_config`, {
+      apiKey: encryptToken(apiKey),
+      model: model || PROVIDER_MODELS[name][0],
+    });
+    if (setActive) {
+      const existing = await getCredentials('ai_config') || {};
+      await setCredentials('ai_config', { ...existing, provider: name });
+    }
+  } else {
+    return reply.code(404).send({ error: `Unknown provider: ${name}` });
+  }
+
+  return { success: true };
+});
+
+// DELETE /ai/provider/:name — remove provider credentials; falls back to ollama if it was active
+app.delete('/ai/provider/:name', async (request, reply) => {
+  const { name } = request.params;
+  if (name === 'ollama') return reply.code(400).send({ error: 'Cannot remove Ollama provider' });
+  if (!['openai', 'groq', 'gemini'].includes(name)) return reply.code(404).send({ error: `Unknown provider: ${name}` });
+  const db = await getDb();
+  await db.collection('platform_credentials').deleteOne({ _id: `${name}_config` });
+  const aiConfig = await getCredentials('ai_config') || {};
+  if (aiConfig.provider === name) {
+    await setCredentials('ai_config', { ...aiConfig, provider: 'ollama' });
+  }
+  return { success: true };
+});
+
+// POST /ai/provider/:name/models — list models for a provider (test without saving key)
+app.post('/ai/provider/:name/models', async (request, reply) => {
+  const { name } = request.params;
+  const { apiKey: bodyApiKey, endpoint: bodyEndpoint } = request.body || {};
+
+  if (name === 'ollama') {
+    const aiConfig = await getCredentials('ai_config');
+    const ep = bodyEndpoint || aiConfig?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
+    try {
+      const res = await axios.get(`${ep}/api/tags`, { timeout: 5000 });
+      return { models: (res.data.models || []).map((m) => m.name) };
+    } catch (err) {
+      return reply.code(503).send({ error: 'Could not reach Ollama', detail: err.message });
+    }
+  }
+  if (['openai', 'groq', 'gemini'].includes(name)) {
+    return { models: PROVIDER_MODELS[name] };
+  }
+  return reply.code(404).send({ error: `Unknown provider: ${name}` });
+});
+
 app.get('/ai/models', async (request, reply) => {
   const config = await getCredentials('ai_config');
-  // Allow caller to override endpoint for test-without-save UX
+  const provider = config?.provider || 'ollama';
+  if (provider !== 'ollama') {
+    return { models: PROVIDER_MODELS[provider] || [], provider };
+  }
   const endpoint = request.query.endpoint || config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
   try {
     const res = await axios.get(`${endpoint}/api/tags`, { timeout: 5000 });
@@ -395,67 +571,117 @@ 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;
+  const pconf = await getActiveProviderConfig();
+  const model = reqModel || pconf.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 };
+    if (pconf.provider === 'ollama') {
+      const res = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 90000 });
+      return { text: res.data.response, model, done: res.data.done };
+    }
+
+    if (pconf.provider === 'openai' || pconf.provider === 'groq') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: `${pconf.provider} API key not configured` });
+      const res = await axios.post(`${pconf.baseUrl}/chat/completions`, {
+        model, messages: buildOpenAIMessages(prompt, system), stream: false,
+      }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, timeout: 90000 });
+      return { text: res.data.choices[0]?.message?.content || '', model, done: true };
+    }
+
+    if (pconf.provider === 'gemini') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: 'Gemini API key not configured' });
+      const res = await axios.post(
+        `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${pconf.apiKey}`,
+        { contents: buildGeminiContents(prompt, system) },
+        { timeout: 90000 },
+      );
+      return { text: res.data.candidates?.[0]?.content?.parts?.[0]?.text || '', model, done: true };
+    }
+
+    return reply.code(400).send({ error: `Unknown provider: ${pconf.provider}` });
   } catch (err) {
     const status = err.response?.status || 503;
     return reply.code(status).send({ error: 'AI generation failed', detail: err.message });
   }
 });
 
-// Vision caption — fetches image, passes base64 to Ollama vision model
+const CAPTION_PROMPT = 'Generate an engaging, concise social media caption for this image. Write only the caption text with relevant hashtags. No explanations or preamble.';
+
+// Vision caption — supports ollama, openai, gemini (groq has no vision)
 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';
+  const pconf = await getActiveProviderConfig();
 
-  // Fetch image → base64
-  let imageBase64;
+  let imageBase64, imageMime;
   try {
     let imageBuffer;
     if (imageUrl.startsWith('/media/')) {
       const filename = path.basename(imageUrl);
-      const filepath = path.join(UPLOAD_DIR, filename);
-      imageBuffer = fs.readFileSync(filepath);
+      imageBuffer = fs.readFileSync(path.join(UPLOAD_DIR, filename));
     } else {
       const imgRes = await axios.get(imageUrl, { responseType: 'arraybuffer', timeout: 15000 });
       imageBuffer = Buffer.from(imgRes.data);
+      imageMime = imgRes.headers['content-type'] || 'image/jpeg';
     }
     imageBase64 = imageBuffer.toString('base64');
+    if (!imageMime) imageMime = 'image/jpeg';
   } 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 };
+    const model = reqModel || pconf.visionModel || pconf.model;
+
+    if (pconf.provider === 'ollama') {
+      const res = await axios.post(`${pconf.endpoint}/api/generate`, {
+        model, prompt: CAPTION_PROMPT, images: [imageBase64], stream: false,
+      }, { timeout: 90000 });
+      return { caption: res.data.response, model };
+    }
+
+    if (pconf.provider === 'openai') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: 'OpenAI API key not configured' });
+      const res = await axios.post(`${pconf.baseUrl}/chat/completions`, {
+        model: model || 'gpt-4o',
+        messages: [{ role: 'user', content: [
+          { type: 'text', text: CAPTION_PROMPT },
+          { type: 'image_url', image_url: { url: `data:${imageMime};base64,${imageBase64}` } },
+        ]}],
+        stream: false,
+      }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, timeout: 90000 });
+      return { caption: res.data.choices[0]?.message?.content || '', model };
+    }
+
+    if (pconf.provider === 'gemini') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: 'Gemini API key not configured' });
+      const geminiModel = model || 'gemini-1.5-flash';
+      const res = await axios.post(
+        `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${pconf.apiKey}`,
+        { contents: [{ role: 'user', parts: [
+          { text: CAPTION_PROMPT },
+          { inlineData: { mimeType: imageMime, data: imageBase64 } },
+        ]}]},
+        { timeout: 90000 },
+      );
+      return { caption: res.data.candidates?.[0]?.content?.parts?.[0]?.text || '', model: geminiModel };
+    }
+
+    return reply.code(400).send({ error: `Provider ${pconf.provider} does not support vision captions` });
   } 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
+// SSE streaming endpoint — normalized data: { token, done } format for all providers
 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;
+  const pconf = await getActiveProviderConfig();
+  const model = reqModel || pconf.model;
 
   reply.raw.setHeader('Content-Type', 'text/event-stream');
   reply.raw.setHeader('Cache-Control', 'no-cache');
@@ -463,27 +689,72 @@ app.post('/ai/stream', async (request, reply) => {
   reply.raw.setHeader('Connection', 'keep-alive');
   reply.raw.flushHeaders();
 
+  const writeToken = (token, done = false) => reply.raw.write(`data: ${JSON.stringify({ token, done })}\n\n`);
+  const writeError = (msg) => { reply.raw.write(`data: ${JSON.stringify({ error: msg, done: true })}\n\n`); reply.raw.end(); };
+
   try {
-    const ollamaRes = await axios.post(`${endpoint}/api/generate`, { model, prompt, system, stream: true }, { responseType: 'stream', timeout: 120000 });
+    if (pconf.provider === 'ollama') {
+      const ollamaRes = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: true }, { responseType: 'stream', timeout: 120000 });
+      ollamaRes.data.on('data', (chunk) => {
+        try {
+          for (const line of chunk.toString().split('\n').filter(Boolean)) {
+            const data = JSON.parse(line);
+            writeToken(data.response || '', !!data.done);
+          }
+        } catch (_) {}
+      });
+      ollamaRes.data.on('end', () => reply.raw.end());
+      ollamaRes.data.on('error', (err) => writeError(err.message));
+      return;
+    }
 
-    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 (_) {}
-    });
+    if (pconf.provider === 'openai' || pconf.provider === 'groq') {
+      if (!pconf.apiKey) return writeError(`${pconf.provider} API key not configured`);
+      const upstreamRes = await axios.post(`${pconf.baseUrl}/chat/completions`, {
+        model, messages: buildOpenAIMessages(prompt, system), stream: true,
+      }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, responseType: 'stream', timeout: 120000 });
+      upstreamRes.data.on('data', (chunk) => {
+        try {
+          for (const line of chunk.toString().split('\n').filter(Boolean)) {
+            if (!line.startsWith('data: ')) continue;
+            const payload = line.slice(6).trim();
+            if (payload === '[DONE]') { writeToken('', true); return; }
+            const data = JSON.parse(payload);
+            const token = data.choices?.[0]?.delta?.content || '';
+            if (token) writeToken(token);
+          }
+        } catch (_) {}
+      });
+      upstreamRes.data.on('end', () => reply.raw.end());
+      upstreamRes.data.on('error', (err) => writeError(err.message));
+      return;
+    }
 
-    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();
-    });
+    if (pconf.provider === 'gemini') {
+      if (!pconf.apiKey) return writeError('Gemini API key not configured');
+      const geminiRes = await axios.post(
+        `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${pconf.apiKey}`,
+        { contents: buildGeminiContents(prompt, system) },
+        { responseType: 'stream', timeout: 120000 },
+      );
+      geminiRes.data.on('data', (chunk) => {
+        try {
+          for (const line of chunk.toString().split('\n').filter(Boolean)) {
+            if (!line.startsWith('data: ')) continue;
+            const data = JSON.parse(line.slice(6));
+            const token = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+            if (token) writeToken(token);
+          }
+        } catch (_) {}
+      });
+      geminiRes.data.on('end', () => { writeToken('', true); reply.raw.end(); });
+      geminiRes.data.on('error', (err) => writeError(err.message));
+      return;
+    }
+
+    writeError(`Unknown provider: ${pconf.provider}`);
   } catch (err) {
-    reply.raw.write(`data: ${JSON.stringify({ error: err.message, done: true })}\n\n`);
-    reply.raw.end();
+    writeError(err.message);
   }
 });
 

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

@@ -327,6 +327,32 @@ export default {
     visionModelLabel: 'Vision Model',
     visionModelPlaceholder: 'e.g. llava, llama3.2-vision',
     visionModelHint: 'Used for image captioning. Pull with: ollama pull llava',
+    activeProvider: 'Active Provider',
+    setActive: 'Set as Active',
+    active: 'Active',
+    disconnect: 'Disconnect',
+    disconnectConfirm: 'Remove this provider and its API key?',
+    apiKeyLabel: 'API Key',
+    apiKeyPlaceholder: 'Paste your API key…',
+    apiKeyConfigured: 'API key saved',
+    connectAndActivate: 'Connect & Set Active',
+    saveProvider: 'Save',
+    providerSaved: 'Saved!',
+    openai: {
+      sectionTitle: 'OpenAI',
+      sectionSubtitle: 'GPT-4o and GPT-4o-mini via the OpenAI API.',
+      getKeyHint: 'Get an API key at platform.openai.com',
+    },
+    groq: {
+      sectionTitle: 'Groq',
+      sectionSubtitle: 'Ultra-fast inference with Llama and Mixtral models.',
+      getKeyHint: 'Get an API key at console.groq.com',
+    },
+    gemini: {
+      sectionTitle: 'Google Gemini',
+      sectionSubtitle: 'Gemini 2.0 Flash and 1.5 Pro with native vision support.',
+      getKeyHint: 'Get an API key at aistudio.google.com',
+    },
   },
 
   feed: {

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

@@ -327,6 +327,32 @@ export default {
     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',
+    activeProvider: 'Aktif Sağlayıcı',
+    setActive: 'Aktif Yap',
+    active: 'Aktif',
+    disconnect: 'Bağlantıyı Kes',
+    disconnectConfirm: 'Bu sağlayıcı ve API anahtarı kaldırılsın mı?',
+    apiKeyLabel: 'API Anahtarı',
+    apiKeyPlaceholder: 'API anahtarınızı yapıştırın…',
+    apiKeyConfigured: 'API anahtarı kaydedildi',
+    connectAndActivate: 'Bağlan & Aktif Yap',
+    saveProvider: 'Kaydet',
+    providerSaved: 'Kaydedildi!',
+    openai: {
+      sectionTitle: 'OpenAI',
+      sectionSubtitle: 'OpenAI API üzerinden GPT-4o ve GPT-4o-mini.',
+      getKeyHint: 'API anahtarı için: platform.openai.com',
+    },
+    groq: {
+      sectionTitle: 'Groq',
+      sectionSubtitle: 'Llama ve Mixtral modelleriyle ultra hızlı çıkarım.',
+      getKeyHint: 'API anahtarı için: console.groq.com',
+    },
+    gemini: {
+      sectionTitle: 'Google Gemini',
+      sectionSubtitle: 'Gemini 2.0 Flash ve 1.5 Pro, yerel görsel desteğiyle.',
+      getKeyHint: 'API anahtarı için: aistudio.google.com',
+    },
   },
 
   feed: {

+ 74 - 2
ui/src/stores/ai.ts

@@ -10,6 +10,24 @@ export interface AiConfig {
   enabled: boolean
 }
 
+export interface ProviderInfo {
+  name: string
+  configured: boolean
+  active: boolean
+  model: string
+  // ollama-specific
+  endpoint?: string
+  visionModel?: string
+  // cloud-specific
+  apiKeyHint?: string | null
+}
+
+export const PROVIDER_MODELS: Record<string, string[]> = {
+  openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
+  groq:   ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768', 'gemma2-9b-it'],
+  gemini: ['gemini-2.0-flash', 'gemini-1.5-flash', 'gemini-1.5-pro'],
+}
+
 export const useAiStore = defineStore('ai', () => {
   const config = ref<AiConfig>({
     provider: 'ollama',
@@ -18,6 +36,7 @@ export const useAiStore = defineStore('ai', () => {
     visionModel: 'llava',
     enabled: true,
   })
+  const providers = ref<ProviderInfo[]>([])
   const models = ref<string[]>([])
   const loading = ref(false)
   const saving = ref(false)
@@ -33,6 +52,58 @@ export const useAiStore = defineStore('ai', () => {
     }
   }
 
+  async function fetchProviders() {
+    try {
+      const res = await axios.get('/api/ai/providers')
+      providers.value = res.data.providers || []
+      // Keep config.provider in sync with the active provider
+      const active = res.data.active
+      if (active) config.value.provider = active
+    } catch (err) {
+      console.error('AI providers fetch error:', err)
+    }
+  }
+
+  async function saveProvider(
+    name: string,
+    payload: { apiKey?: string; model?: string; endpoint?: string; visionModel?: string; setActive?: boolean },
+  ): Promise<boolean> {
+    saving.value = true
+    error.value = null
+    try {
+      await axios.put(`/api/ai/provider/${name}`, payload)
+      await fetchProviders()
+      if (payload.setActive) config.value.provider = name
+      return true
+    } catch (err: any) {
+      error.value = err.response?.data?.error || `Failed to save ${name} config`
+      return false
+    } finally {
+      saving.value = false
+    }
+  }
+
+  async function deleteProvider(name: string): Promise<boolean> {
+    try {
+      await axios.delete(`/api/ai/provider/${name}`)
+      await fetchProviders()
+      if (config.value.provider === name) config.value.provider = 'ollama'
+      return true
+    } catch (err: any) {
+      error.value = err.response?.data?.error || `Failed to disconnect ${name}`
+      return false
+    }
+  }
+
+  async function fetchProviderModels(name: string, payload?: { apiKey?: string; endpoint?: string }): Promise<string[]> {
+    try {
+      const res = await axios.post(`/api/ai/provider/${name}/models`, payload || {})
+      return res.data.models || []
+    } catch {
+      return []
+    }
+  }
+
   async function saveConfig(updates: Partial<AiConfig>): Promise<boolean> {
     saving.value = true
     error.value = null
@@ -141,7 +212,8 @@ export const useAiStore = defineStore('ai', () => {
   }
 
   return {
-    config, models, loading, saving, modelsLoading, error,
-    fetchConfig, saveConfig, fetchModels, generate, generateCaption, streamGenerate,
+    config, providers, models, loading, saving, modelsLoading, error,
+    fetchConfig, fetchProviders, saveProvider, deleteProvider, fetchProviderModels,
+    saveConfig, fetchModels, generate, generateCaption, streamGenerate,
   }
 })

+ 190 - 8
ui/src/views/Settings.vue

@@ -639,12 +639,11 @@
             <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'"
-            >
+          <div class="ml-auto flex items-center gap-2 shrink-0">
+            <span v-if="aiStore.config.provider === 'ollama'" class="text-xs px-2 py-0.5 rounded-full font-medium bg-violet-900/50 text-violet-300 border border-violet-700">
+              {{ $t('ai.active') }}
+            </span>
+            <span v-if="aiConnected !== null" 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>
@@ -717,6 +716,130 @@
         </div>
       </div>
 
+      <!-- ═══════════════════════════════════════════════════════════════════
+           AI PROVIDERS — OpenAI, Groq, Gemini cards
+      ════════════════════════════════════════════════════════════════════ -->
+      <template v-for="providerName in ['openai', 'groq', 'gemini']" :key="providerName">
+        <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 flex items-center justify-center text-white text-xs font-bold shrink-0"
+              :class="providerName === 'openai' ? 'bg-emerald-700' : providerName === 'groq' ? 'bg-orange-700' : 'bg-blue-700'"
+            >
+              {{ providerName === 'openai' ? 'OAI' : providerName === 'groq' ? 'GRQ' : 'GEM' }}
+            </div>
+            <div>
+              <p class="font-semibold">{{ $t(`ai.${providerName}.sectionTitle`) }}</p>
+              <p class="text-xs text-gray-500 mt-0.5">{{ $t(`ai.${providerName}.sectionSubtitle`) }}</p>
+            </div>
+            <div class="ml-auto flex items-center gap-2 shrink-0">
+              <span v-if="aiStore.config.provider === providerName" class="text-xs px-2 py-0.5 rounded-full font-medium bg-violet-900/50 text-violet-300 border border-violet-700">
+                {{ $t('ai.active') }}
+              </span>
+              <span v-else-if="getProvider(providerName)?.configured" class="text-xs px-2 py-0.5 rounded-full font-medium bg-green-900/50 text-green-400 border border-green-700">
+                ✓ {{ $t('ai.apiKeyConfigured') }}
+              </span>
+            </div>
+          </div>
+
+          <div class="p-5 space-y-4">
+
+            <!-- Configured state -->
+            <div v-if="getProvider(providerName)?.configured && !providerForms[providerName].editing">
+              <div class="flex items-center justify-between text-sm">
+                <div class="space-y-1">
+                  <p class="text-xs text-gray-400">{{ $t('ai.apiKeyLabel') }}: <span class="font-mono text-gray-300">{{ getProvider(providerName)?.apiKeyHint }}</span></p>
+                  <p v-if="providerForms[providerName].saved" class="text-xs text-green-400">{{ $t('ai.providerSaved') }}</p>
+                </div>
+                <div class="flex gap-2">
+                  <button @click="providerForms[providerName].editing = true" class="text-xs px-2.5 py-1 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-md text-gray-400 hover:text-gray-200 transition-colors">
+                    Edit
+                  </button>
+                  <button
+                    v-if="aiStore.config.provider !== providerName"
+                    @click="setActiveProvider(providerName)"
+                    :disabled="aiStore.saving"
+                    class="text-xs px-2.5 py-1 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded-md text-white transition-colors"
+                  >
+                    {{ $t('ai.setActive') }}
+                  </button>
+                  <button @click="disconnectCloudProvider(providerName)" class="text-xs px-2.5 py-1 bg-red-900/40 hover:bg-red-900/60 border border-red-800 rounded-md text-red-400 hover:text-red-300 transition-colors">
+                    {{ $t('ai.disconnect') }}
+                  </button>
+                </div>
+              </div>
+
+              <!-- Model selector for configured provider -->
+              <div class="mt-3">
+                <label class="block text-xs text-gray-500 mb-1">{{ $t('ai.modelLabel') }}</label>
+                <select
+                  v-model="providerForms[providerName].model"
+                  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"
+                >
+                  <option v-for="m in (PROVIDER_MODELS[providerName] || [])" :key="m" :value="m">{{ m }}</option>
+                </select>
+              </div>
+              <div class="flex justify-end mt-3">
+                <button
+                  @click="saveCloudProvider(providerName, aiStore.config.provider === providerName)"
+                  :disabled="providerForms[providerName].saving"
+                  class="px-4 py-2 bg-violet-600 hover:bg-violet-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
+                >
+                  {{ providerForms[providerName].saving ? $t('ai.saving') : $t('ai.saveProvider') }}
+                </button>
+              </div>
+            </div>
+
+            <!-- Unconfigured / editing state -->
+            <div v-else class="space-y-3">
+              <div>
+                <label class="block text-xs text-gray-500 mb-1">{{ $t('ai.apiKeyLabel') }}</label>
+                <input
+                  v-model="providerForms[providerName].apiKey"
+                  type="password"
+                  :placeholder="$t('ai.apiKeyPlaceholder')"
+                  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.${providerName}.getKeyHint`) }}</p>
+              </div>
+
+              <div>
+                <label class="block text-xs text-gray-500 mb-1">{{ $t('ai.modelLabel') }}</label>
+                <select
+                  v-model="providerForms[providerName].model"
+                  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"
+                >
+                  <option v-for="m in (PROVIDER_MODELS[providerName] || [])" :key="m" :value="m">{{ m }}</option>
+                </select>
+              </div>
+
+              <div class="flex items-center justify-end gap-2">
+                <button v-if="providerForms[providerName].editing" @click="providerForms[providerName].editing = false" class="text-xs px-3 py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 transition-colors">
+                  Cancel
+                </button>
+                <button
+                  @click="saveCloudProvider(providerName, false)"
+                  :disabled="providerForms[providerName].saving || !providerForms[providerName].apiKey"
+                  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"
+                >
+                  {{ providerForms[providerName].saving ? $t('ai.saving') : $t('ai.saveProvider') }}
+                </button>
+                <button
+                  @click="saveCloudProvider(providerName, true)"
+                  :disabled="providerForms[providerName].saving || !providerForms[providerName].apiKey"
+                  class="px-4 py-2 bg-violet-600 hover:bg-violet-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
+                >
+                  {{ providerForms[providerName].saving ? $t('ai.saving') : $t('ai.connectAndActivate') }}
+                </button>
+              </div>
+            </div>
+
+          </div>
+        </div>
+      </template>
+
       <!-- Refresh button -->
       <button
         @click="platformsStore.fetchStatuses()"
@@ -735,7 +858,7 @@ 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'
+import { useAiStore, PROVIDER_MODELS } from '../stores/ai'
 import { COMMON_TIMEZONES } from '../utils/timezone'
 
 const { t } = useI18n()
@@ -998,13 +1121,70 @@ async function testAiConnection() {
 }
 
 async function saveAiConfig() {
-  const ok = await aiStore.saveConfig({ endpoint: aiEndpoint.value, model: aiModel.value, visionModel: aiVisionModel.value })
+  const ok = await aiStore.saveProvider('ollama', { endpoint: aiEndpoint.value, model: aiModel.value, visionModel: aiVisionModel.value, setActive: true })
   if (ok) {
     aiSaved.value = true
     setTimeout(() => { aiSaved.value = false }, 2500)
   }
 }
 
+// ─── Cloud AI providers (OpenAI, Groq, Gemini) ───────────────────────────────
+
+interface ProviderFormState {
+  apiKey: string
+  model: string
+  editing: boolean
+  saving: boolean
+  saved: boolean
+  testResult: boolean | null
+}
+
+function makeProviderState(): ProviderFormState {
+  return { apiKey: '', model: '', editing: false, saving: false, saved: false, testResult: null }
+}
+
+const providerForms = ref<Record<string, ProviderFormState>>({
+  openai: makeProviderState(),
+  groq:   makeProviderState(),
+  gemini: makeProviderState(),
+})
+
+function getProvider(name: string) {
+  return aiStore.providers.find((p) => p.name === name)
+}
+
+async function saveCloudProvider(name: string, setActive = false) {
+  const form = providerForms.value[name]
+  form.saving = true
+  const ok = await aiStore.saveProvider(name, { apiKey: form.apiKey || undefined, model: form.model || undefined, setActive })
+  form.saving = false
+  if (ok) {
+    form.saved = true
+    form.editing = false
+    form.apiKey = ''
+    setTimeout(() => { form.saved = false }, 2500)
+  }
+}
+
+async function setActiveProvider(name: string) {
+  const provider = getProvider(name)
+  if (!provider?.configured) return
+  await aiStore.saveProvider(name, { setActive: true })
+}
+
+async function disconnectCloudProvider(name: string) {
+  if (!confirm(t('ai.disconnectConfirm'))) return
+  await aiStore.deleteProvider(name)
+}
+
+function seedProviderForms() {
+  for (const p of aiStore.providers) {
+    if (p.name === 'ollama') continue
+    const form = providerForms.value[p.name]
+    if (form) form.model = p.model || ''
+  }
+}
+
 // ─── On mount ────────────────────────────────────────────────────────────────
 
 onMounted(async () => {
@@ -1032,6 +1212,7 @@ onMounted(async () => {
     loadMetaConnections(),
     platformsStore.fetchTokenExpiry(),
     aiStore.fetchConfig(),
+    aiStore.fetchProviders(),
   ])
 
   // Seed board checkboxes from current selection
@@ -1041,5 +1222,6 @@ onMounted(async () => {
   aiEndpoint.value = aiStore.config.endpoint
   aiModel.value = aiStore.config.model
   aiVisionModel.value = aiStore.config.visionModel
+  seedProviderForms()
 })
 </script>