diff --git a/ollama/docker-compose.yml b/ollama/docker-compose.yml new file mode 100644 index 0000000..7519745 --- /dev/null +++ b/ollama/docker-compose.yml @@ -0,0 +1,27 @@ +services: + ollama: + image: ollama/ollama + container_name: ollama + ports: + - "11436:11434" + volumes: + - /mnt/ssd/ai/ollama:/root/.ollama + restart: always + environment: + # Allow qwen3:8b + qwen2.5:1.5b to coexist in VRAM (~6.7-7.7 GB on 8 GB GPU) + - OLLAMA_MAX_LOADED_MODELS=2 + # One GPU inference at a time — prevents compute contention between models + - OLLAMA_NUM_PARALLEL=1 + # Force all layers to GPU — fail instead of falling back to CPU + - OLLAMA_NUM_GPU=999 + runtime: nvidia + mem_limit: 4g + + ollama-cpu: + image: ollama/ollama + container_name: ollama-cpu + ports: + - "11435:11434" + volumes: + - /mnt/ssd/ai/ollama-cpu:/root/.ollama + restart: always diff --git a/openai/docker-compose.yml b/openai/docker-compose.yml index c520fb3..609ed81 100644 --- a/openai/docker-compose.yml +++ b/openai/docker-compose.yml @@ -1,65 +1,4 @@ services: - ollama: - image: ollama/ollama - container_name: ollama - ports: - - "11436:11434" - volumes: - - /mnt/ssd/ai/ollama:/root/.ollama - - /mnt/ssd/ai/open-webui:/app/backend/data - restart: always - environment: - # Allow qwen3:8b + qwen2.5:1.5b to coexist in VRAM (~6.7-7.7 GB on 8 GB GPU) - - OLLAMA_MAX_LOADED_MODELS=2 - # One GPU inference at a time — prevents compute contention between models - - OLLAMA_NUM_PARALLEL=1 - # Force all layers to GPU — fail instead of falling back to CPU - - OLLAMA_NUM_GPU=999 - runtime: nvidia - mem_limit: 4g - - ollama-cpu: - image: ollama/ollama - container_name: ollama-cpu - ports: - - "11435:11434" - volumes: - - /mnt/ssd/ai/ollama-cpu:/root/.ollama - restart: always - - open-webui: - image: ghcr.io/open-webui/open-webui:main - container_name: open-webui - ports: - - "3125:8080" - volumes: - - /mnt/ssd/ai/open-webui:/app/backend/data - extra_hosts: - - "host.docker.internal:host-gateway" - restart: always - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] - environment: - - ANTHROPIC_API_KEY=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 - - OPENAI_API_BASE_URL=http://host.docker.internal:4000/v1 - - OPENAI_API_KEY=dummy - # STT — Faster-Whisper large-v3-turbo - - AUDIO_STT_ENGINE=openai - - AUDIO_STT_OPENAI_API_BASE_URL=http://host.docker.internal:8880/v1 - - AUDIO_STT_OPENAI_API_KEY=dummy - - AUDIO_STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2 - # TTS — Silero v4 - - AUDIO_TTS_ENGINE=openai - - AUDIO_TTS_OPENAI_API_BASE_URL=http://host.docker.internal:8881/v1 - - AUDIO_TTS_OPENAI_API_KEY=dummy - - AUDIO_TTS_MODEL=silero - - AUDIO_TTS_VOICE=onyx - litellm-db: image: postgres:16-alpine container_name: litellm-db @@ -119,16 +58,6 @@ services: - langfuse-db restart: always - searxng: - image: docker.io/searxng/searxng:latest - container_name: searxng - volumes: - - /mnt/ssd/ai/searxng/config/:/etc/searxng/ - - /mnt/ssd/ai/searxng/data/:/var/cache/searxng/ - restart: always - ports: - - "11437:8080" - qdrant: image: qdrant/qdrant container_name: qdrant diff --git a/openwebui/docker-compose.yml b/openwebui/docker-compose.yml new file mode 100644 index 0000000..0a28c66 --- /dev/null +++ b/openwebui/docker-compose.yml @@ -0,0 +1,33 @@ +services: + open-webui: + image: ghcr.io/open-webui/open-webui:main + container_name: open-webui + ports: + - "3125:8080" + volumes: + - /mnt/ssd/ai/open-webui:/app/backend/data + extra_hosts: + - "host.docker.internal:host-gateway" + restart: always + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENAI_API_BASE_URL=http://host.docker.internal:4000/v1 + - OPENAI_API_KEY=dummy + # STT — Faster-Whisper large-v3-turbo + - AUDIO_STT_ENGINE=openai + - AUDIO_STT_OPENAI_API_BASE_URL=http://host.docker.internal:8880/v1 + - AUDIO_STT_OPENAI_API_KEY=dummy + - AUDIO_STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2 + # TTS — Silero v4 + - AUDIO_TTS_ENGINE=openai + - AUDIO_TTS_OPENAI_API_BASE_URL=http://host.docker.internal:8881/v1 + - AUDIO_TTS_OPENAI_API_KEY=dummy + - AUDIO_TTS_MODEL=silero + - AUDIO_TTS_VOICE=onyx diff --git a/searxng/docker-compose.yml b/searxng/docker-compose.yml new file mode 100644 index 0000000..e48d7c7 --- /dev/null +++ b/searxng/docker-compose.yml @@ -0,0 +1,19 @@ +services: + searxng: + image: docker.io/searxng/searxng:latest + container_name: searxng + volumes: + - /mnt/ssd/ai/searxng/config/:/etc/searxng/ + - /mnt/ssd/ai/searxng/data/:/var/cache/searxng/ + restart: always + ports: + - "11437:8080" + + searxng-mcp: + build: ./mcp + container_name: searxng-mcp + network_mode: host + restart: unless-stopped + environment: + - PORT=3102 + - SEARXNG_URL=http://localhost:11437 diff --git a/searxng/mcp/Dockerfile b/searxng/mcp/Dockerfile new file mode 100644 index 0000000..7873641 --- /dev/null +++ b/searxng/mcp/Dockerfile @@ -0,0 +1,6 @@ +FROM node:22-slim +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY server.js ./ +CMD ["node", "server.js"] diff --git a/searxng/mcp/package.json b/searxng/mcp/package.json new file mode 100644 index 0000000..f9818d3 --- /dev/null +++ b/searxng/mcp/package.json @@ -0,0 +1,11 @@ +{ + "name": "searxng-mcp", + "version": "1.0.0", + "type": "module", + "main": "server.js", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "express": "^4.19.0", + "zod": "^3.23.0" + } +} diff --git a/searxng/mcp/server.js b/searxng/mcp/server.js new file mode 100644 index 0000000..4fb3e8c --- /dev/null +++ b/searxng/mcp/server.js @@ -0,0 +1,98 @@ +import express from 'express'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { z } from 'zod'; + +const PORT = parseInt(process.env.PORT || '3102'); +const SEARXNG_URL = (process.env.SEARXNG_URL || 'http://localhost:11437').replace(/\/$/, ''); + +function createServer() { + const server = new McpServer({ name: 'searxng-mcp', version: '1.0.0' }); + + server.tool( + 'searxng_search', + 'Search the web using the self-hosted SearXNG meta-search engine. Returns titles, URLs, and content snippets from multiple search engines.', + { + query: z.string().describe('Search query'), + categories: z.enum(['general', 'news', 'images', 'videos', 'science', 'files', 'social_media', 'it']) + .optional() + .describe('Search category, default: general'), + language: z.string().optional().describe('Language code (e.g. "ru", "en", "auto"), default: auto'), + time_range: z.enum(['day', 'week', 'month', 'year']).optional().describe('Limit results to time range'), + limit: z.number().optional().describe('Max results to return, default 10'), + }, + async ({ query, categories, language, time_range, limit = 10 }) => { + try { + const params = new URLSearchParams({ + q: query, + format: 'json', + categories: categories || 'general', + language: language || 'auto', + }); + if (time_range) params.set('time_range', time_range); + + const res = await fetch(`${SEARXNG_URL}/search?${params}`); + if (!res.ok) { + return { content: [{ type: 'text', text: `SearXNG returned HTTP ${res.status}` }], isError: true }; + } + + const data = await res.json(); + const results = (data.results || []).slice(0, limit).map(r => ({ + title: r.title || null, + url: r.url || null, + content: (r.content || '').slice(0, 500) || null, + engine: r.engine || null, + score: r.score != null ? Math.round(r.score * 100) / 100 : null, + publishedDate: r.publishedDate || null, + })); + + const out = { + query: data.query, + totalResults: data.number_of_results, + count: results.length, + results, + }; + + return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }; + } + } + ); + + return server; +} + +const app = express(); +app.use(express.json()); + +const sseTransports = new Map(); + +app.all('/mcp', async (req, res) => { + try { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on('close', () => transport.close()); + await createServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (e) { + if (!res.headersSent) res.status(500).json({ error: e.message }); + } +}); + +app.get('/sse', async (req, res) => { + const transport = new SSEServerTransport('/messages', res); + sseTransports.set(transport.sessionId, transport); + res.on('close', () => sseTransports.delete(transport.sessionId)); + await createServer().connect(transport); +}); + +app.post('/messages', async (req, res) => { + const transport = sseTransports.get(req.query.sessionId); + if (!transport) return res.status(400).send('Unknown session'); + await transport.handlePostMessage(req, res); +}); + +app.get('/health', (_, res) => res.json({ status: 'ok', searxng: SEARXNG_URL })); + +app.listen(PORT, () => console.log(`searxng-mcp listening on :${PORT}`));