From aabe328b516d1c4181daa708b64e82c236045479 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 00:35:35 +0000 Subject: [PATCH] feat(matrix): add Matrix Ollama Bot service GDPR-compliant replacement for telegram-ollama-bot using Matrix protocol: New service: services/matrix-ollama-bot/ - NestJS application with matrix-bot-sdk - Same functionality as telegram-ollama-bot - Commands: !help, !models, !model, !mode, !clear, !status - System prompts: default, classify, summarize, translate, code - Chat history per user (last 10 messages) Changes: - docker-compose.macmini.yml: Added matrix-ollama-bot service - health-check.sh: Added Matrix Ollama Bot health check Environment variables required: - MATRIX_OLLAMA_BOT_TOKEN: Bot access token - MATRIX_OLLAMA_BOT_ROOMS: Optional room restrictions https://claude.ai/code/session_01E3r5aFW3YLAhEJfsL2ryhv --- docker-compose.macmini.yml | 34 ++ scripts/mac-mini/health-check.sh | 1 + services/matrix-ollama-bot/.env.example | 15 + services/matrix-ollama-bot/CLAUDE.md | 137 +++++++ services/matrix-ollama-bot/Dockerfile | 53 +++ services/matrix-ollama-bot/nest-cli.json | 8 + services/matrix-ollama-bot/package.json | 34 ++ services/matrix-ollama-bot/src/app.module.ts | 17 + .../matrix-ollama-bot/src/bot/bot.module.ts | 10 + .../src/bot/matrix.service.ts | 340 ++++++++++++++++++ .../src/config/configuration.ts | 22 ++ .../src/health.controller.ts | 13 + services/matrix-ollama-bot/src/main.ts | 15 + .../src/ollama/ollama.module.ts | 8 + .../src/ollama/ollama.service.ts | 94 +++++ services/matrix-ollama-bot/tsconfig.json | 22 ++ 16 files changed, 823 insertions(+) create mode 100644 services/matrix-ollama-bot/.env.example create mode 100644 services/matrix-ollama-bot/CLAUDE.md create mode 100644 services/matrix-ollama-bot/Dockerfile create mode 100644 services/matrix-ollama-bot/nest-cli.json create mode 100644 services/matrix-ollama-bot/package.json create mode 100644 services/matrix-ollama-bot/src/app.module.ts create mode 100644 services/matrix-ollama-bot/src/bot/bot.module.ts create mode 100644 services/matrix-ollama-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-ollama-bot/src/config/configuration.ts create mode 100644 services/matrix-ollama-bot/src/health.controller.ts create mode 100644 services/matrix-ollama-bot/src/main.ts create mode 100644 services/matrix-ollama-bot/src/ollama/ollama.module.ts create mode 100644 services/matrix-ollama-bot/src/ollama/ollama.service.ts create mode 100644 services/matrix-ollama-bot/tsconfig.json diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index fd5e8af95..3178c2ed9 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -803,6 +803,38 @@ services: timeout: 10s retries: 3 + # ============================================ + # Matrix Ollama Bot (GDPR-compliant AI Chat) + # ============================================ + + matrix-ollama-bot: + image: ghcr.io/memo-2023/matrix-ollama-bot:latest + container_name: manacore-matrix-ollama-bot + restart: always + depends_on: + synapse: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3311 + TZ: Europe/Berlin + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_OLLAMA_BOT_TOKEN} + MATRIX_ALLOWED_ROOMS: ${MATRIX_OLLAMA_BOT_ROOMS:-} + OLLAMA_URL: http://host.docker.internal:11434 + OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:4b} + OLLAMA_TIMEOUT: 120000 + volumes: + - matrix_ollama_bot_data:/app/data + ports: + - "3311:3311" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3311/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # Auto-Update (Watchtower) # ============================================ @@ -843,3 +875,5 @@ volumes: name: manacore-n8n synapse_data: name: manacore-synapse + matrix_ollama_bot_data: + name: manacore-matrix-ollama-bot diff --git a/scripts/mac-mini/health-check.sh b/scripts/mac-mini/health-check.sh index 887af6d51..f121f150e 100755 --- a/scripts/mac-mini/health-check.sh +++ b/scripts/mac-mini/health-check.sh @@ -233,6 +233,7 @@ echo "" echo "Matrix (DSGVO-konform):" check_service "Synapse" "http://localhost:8008/health" check_service "Element Web" "http://localhost:8087/" +check_service "Matrix Ollama Bot" "http://localhost:3311/health" echo "" echo "Cloudflare Tunnel:" diff --git a/services/matrix-ollama-bot/.env.example b/services/matrix-ollama-bot/.env.example new file mode 100644 index 000000000..d064c4f02 --- /dev/null +++ b/services/matrix-ollama-bot/.env.example @@ -0,0 +1,15 @@ +# Server +PORT=3311 + +# Matrix Configuration +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_your_access_token_here +# Optional: Restrict to specific rooms (comma-separated) +MATRIX_ALLOWED_ROOMS= +# Path for bot sync storage +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Ollama Configuration +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=gemma3:4b +OLLAMA_TIMEOUT=120000 diff --git a/services/matrix-ollama-bot/CLAUDE.md b/services/matrix-ollama-bot/CLAUDE.md new file mode 100644 index 000000000..a6c35830c --- /dev/null +++ b/services/matrix-ollama-bot/CLAUDE.md @@ -0,0 +1,137 @@ +# Matrix Ollama Bot - Claude Code Guidelines + +## Overview + +Matrix Ollama Bot provides a GDPR-compliant chat interface to local LLM inference via Ollama. It uses the Matrix protocol for messaging, which allows self-hosting all data on the Mac Mini server. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **LLM**: Ollama (local inference) + +## Commands + +```bash +# Development +pnpm install +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types +``` + +## Project Structure + +``` +services/matrix-ollama-bot/ +├── src/ +│ ├── main.ts # Application entry point +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health check endpoint +│ ├── config/ +│ │ └── configuration.ts # Configuration & system prompts +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── matrix.service.ts # Matrix client & command handlers +│ └── ollama/ +│ ├── ollama.module.ts +│ └── ollama.service.ts # Ollama API client +├── Dockerfile +└── package.json +``` + +## Matrix Commands + +| Command | Description | +|---------|-------------| +| `!help` | Show help message | +| `!models` | List available Ollama models | +| `!model [name]` | Switch to a different model | +| `!mode [mode]` | Change system prompt mode | +| `!clear` | Clear chat history | +| `!status` | Show Ollama connection status | + +## System Prompt Modes + +| Mode | Description | +|------|-------------| +| `default` | General assistant | +| `classify` | Text classification | +| `summarize` | Text summarization | +| `translate` | Translation | +| `code` | Programming help | + +## Environment Variables + +```env +# Server +PORT=3311 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#ollama-bot:mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Ollama +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=gemma3:4b +OLLAMA_TIMEOUT=120000 +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-ollama-bot/Dockerfile -t matrix-ollama-bot services/matrix-ollama-bot + +# Run +docker run -p 3311:3311 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e OLLAMA_URL=http://host.docker.internal:11434 \ + -v matrix-ollama-bot-data:/app/data \ + matrix-ollama-bot +``` + +## Health Check + +```bash +curl http://localhost:3311/health +``` + +## Getting a Matrix Access Token + +```bash +# Login to get access token +curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "m.login.password", + "user": "ollama-bot", + "password": "your-password" + }' + +# Response contains: {"access_token": "syt_xxx", ...} +``` + +## Key Differences from Telegram Bot + +| Feature | Telegram | Matrix | +|---------|----------|--------| +| Commands | `/command` | `!command` | +| Message limit | 4096 chars | ~65535 chars | +| Data storage | Telegram servers | Self-hosted | +| E2E encryption | Bot chats unencrypted | Optional (not enabled) | +| Typing indicator | `sendChatAction` | `sendTyping` | + +## GDPR Compliance + +- All message data stored locally on Mac Mini +- No third-party data processing +- Full control over data retention +- Can delete all user data on request diff --git a/services/matrix-ollama-bot/Dockerfile b/services/matrix-ollama-bot/Dockerfile new file mode 100644 index 000000000..c9d2c5a0d --- /dev/null +++ b/services/matrix-ollama-bot/Dockerfile @@ -0,0 +1,53 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy source +COPY . . + +# Build +RUN pnpm build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Create data directory for bot storage +RUN mkdir -p /app/data + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install production dependencies only +RUN pnpm install --prod --frozen-lockfile || pnpm install --prod + +# Copy built files +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs +RUN chown -R nestjs:nodejs /app +USER nestjs + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3311/health || exit 1 + +EXPOSE 3311 + +CMD ["node", "dist/main.js"] diff --git a/services/matrix-ollama-bot/nest-cli.json b/services/matrix-ollama-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-ollama-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/matrix-ollama-bot/package.json b/services/matrix-ollama-bot/package.json new file mode 100644 index 000000000..a1761a1dd --- /dev/null +++ b/services/matrix-ollama-bot/package.json @@ -0,0 +1,34 @@ +{ + "name": "@manacore/matrix-ollama-bot", + "version": "1.0.0", + "description": "Matrix bot for local LLM inference via Ollama - GDPR compliant", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-ollama-bot/src/app.module.ts b/services/matrix-ollama-bot/src/app.module.ts new file mode 100644 index 000000000..52d5de4a4 --- /dev/null +++ b/services/matrix-ollama-bot/src/app.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BotModule } from './bot/bot.module'; +import { HealthController } from './health.controller'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-ollama-bot/src/bot/bot.module.ts b/services/matrix-ollama-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..dbdd32451 --- /dev/null +++ b/services/matrix-ollama-bot/src/bot/bot.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { OllamaModule } from '../ollama/ollama.module'; + +@Module({ + imports: [OllamaModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-ollama-bot/src/bot/matrix.service.ts b/services/matrix-ollama-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..d1f743f89 --- /dev/null +++ b/services/matrix-ollama-bot/src/bot/matrix.service.ts @@ -0,0 +1,340 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, + RichConsoleLogger, + LogService, + MessageEvent, + RoomEvent, +} from 'matrix-bot-sdk'; +import { OllamaService } from '../ollama/ollama.service'; +import { SYSTEM_PROMPTS } from '../config/configuration'; + +interface UserSession { + systemPrompt: string; + model: string; + history: { role: 'user' | 'assistant'; content: string }[]; +} + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private sessions: Map = new Map(); + private readonly allowedRooms: string[]; + private botUserId: string = ''; + + constructor( + private configService: ConfigService, + private ollamaService: OllamaService + ) { + this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + } + + async onModuleInit() { + const homeserverUrl = this.configService.get('matrix.homeserverUrl'); + const accessToken = this.configService.get('matrix.accessToken'); + const storagePath = this.configService.get('matrix.storagePath'); + + if (!accessToken) { + this.logger.error('MATRIX_ACCESS_TOKEN is required'); + return; + } + + // Setup logging + LogService.setLogger(new RichConsoleLogger()); + LogService.setLevel(LogService.LogLevel.INFO); + + // Storage for sync token persistence + const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); + + // Create Matrix client + this.client = new MatrixClient(homeserverUrl!, accessToken, storage); + + // Auto-join rooms when invited + AutojoinRoomsMixin.setupOnClient(this.client); + + // Get bot's user ID + this.botUserId = await this.client.getUserId(); + this.logger.log(`Bot user ID: ${this.botUserId}`); + + // Setup message handler + this.client.on('room.message', this.handleRoomMessage.bind(this)); + + // Start the client + await this.client.start(); + this.logger.log('Matrix bot started successfully'); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + this.logger.log('Matrix bot stopped'); + } + } + + private isRoomAllowed(roomId: string): boolean { + if (this.allowedRooms.length === 0) return true; + return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed)); + } + + private getSession(senderId: string): UserSession { + if (!this.sessions.has(senderId)) { + this.sessions.set(senderId, { + systemPrompt: SYSTEM_PROMPTS.default, + model: this.ollamaService.getDefaultModel(), + history: [], + }); + } + return this.sessions.get(senderId)!; + } + + private async handleRoomMessage(roomId: string, event: RoomEvent) { + // Ignore messages from self + if (event.sender === this.botUserId) return; + + // Check if room is allowed + if (!this.isRoomAllowed(roomId)) { + this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); + return; + } + + // Only handle text messages + const content = event.content; + if (content.msgtype !== 'm.text') return; + + const body = content.body; + if (!body) return; + + this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`); + + // Handle commands + if (body.startsWith('!')) { + await this.handleCommand(roomId, event.sender, body); + return; + } + + // Regular chat message + await this.handleChat(roomId, event.sender, body); + } + + private async handleCommand(roomId: string, sender: string, body: string) { + const [command, ...args] = body.slice(1).split(' '); + const argString = args.join(' '); + + switch (command.toLowerCase()) { + case 'help': + case 'start': + await this.sendHelp(roomId); + break; + + case 'models': + await this.sendModels(roomId, sender); + break; + + case 'model': + await this.setModel(roomId, sender, argString); + break; + + case 'mode': + await this.setMode(roomId, sender, argString); + break; + + case 'clear': + await this.clearHistory(roomId, sender); + break; + + case 'status': + await this.sendStatus(roomId, sender); + break; + + default: + await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help für eine Liste der Befehle.`); + } + } + + private async sendHelp(roomId: string) { + const helpText = `**Ollama Bot - Lokale KI (DSGVO-konform)** + +**Befehle:** +- \`!help\` - Diese Hilfe anzeigen +- \`!models\` - Verfügbare Modelle anzeigen +- \`!model [name]\` - Modell wechseln +- \`!mode [modus]\` - System-Prompt ändern +- \`!clear\` - Chat-Verlauf löschen +- \`!status\` - Ollama Status prüfen + +**Modi:** +- \`default\` - Allgemeiner Assistent +- \`classify\` - Text-Klassifizierung +- \`summarize\` - Zusammenfassungen +- \`translate\` - Übersetzungen +- \`code\` - Programmier-Hilfe + +**Verwendung:** +Schreibe einfach eine Nachricht und ich antworte! + +**Aktuelles Modell:** \`${this.ollamaService.getDefaultModel()}\``; + + await this.sendMessage(roomId, helpText); + } + + private async sendModels(roomId: string, sender: string) { + const models = await this.ollamaService.listModels(); + if (models.length === 0) { + await this.sendMessage(roomId, 'Keine Modelle gefunden. Ist Ollama gestartet?'); + return; + } + + const session = this.getSession(sender); + const modelList = models + .map((m) => { + const sizeMB = (m.size / 1024 / 1024).toFixed(0); + const active = m.name === session.model ? ' ✓' : ''; + return `- \`${m.name}\` (${sizeMB} MB)${active}`; + }) + .join('\n'); + + await this.sendMessage(roomId, `**Verfügbare Modelle:**\n\n${modelList}\n\nWechseln mit: \`!model [name]\``); + } + + private async setModel(roomId: string, sender: string, modelName: string) { + if (!modelName) { + const session = this.getSession(sender); + await this.sendMessage(roomId, `Aktuelles Modell: \`${session.model}\`\n\nVerwendung: \`!model gemma3:4b\``); + return; + } + + const models = await this.ollamaService.listModels(); + const exists = models.some((m) => m.name === modelName); + + if (!exists) { + const available = models.map((m) => m.name).join(', '); + await this.sendMessage(roomId, `Modell "${modelName}" nicht gefunden.\n\nVerfügbar: ${available}`); + return; + } + + const session = this.getSession(sender); + session.model = modelName; + session.history = []; + + this.logger.log(`User ${sender} switched to model ${modelName}`); + await this.sendMessage(roomId, `Modell gewechselt zu: \`${modelName}\``); + } + + private async setMode(roomId: string, sender: string, mode: string) { + const availableModes = Object.keys(SYSTEM_PROMPTS); + + if (!mode) { + const session = this.getSession(sender); + const currentMode = + Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt)?.[0] || 'custom'; + await this.sendMessage(roomId, `Aktueller Modus: \`${currentMode}\`\n\nVerfügbar: ${availableModes.join(', ')}`); + return; + } + + const normalizedMode = mode.toLowerCase(); + if (!SYSTEM_PROMPTS[normalizedMode]) { + await this.sendMessage(roomId, `Unbekannter Modus: ${mode}\n\nVerfügbar: ${availableModes.join(', ')}`); + return; + } + + const session = this.getSession(sender); + session.systemPrompt = SYSTEM_PROMPTS[normalizedMode]; + session.history = []; + + this.logger.log(`User ${sender} switched to mode ${normalizedMode}`); + await this.sendMessage(roomId, `Modus gewechselt zu: \`${normalizedMode}\``); + } + + private async clearHistory(roomId: string, sender: string) { + const session = this.getSession(sender); + session.history = []; + + this.logger.log(`User ${sender} cleared history`); + await this.sendMessage(roomId, 'Chat-Verlauf gelöscht.'); + } + + private async sendStatus(roomId: string, sender: string) { + const connected = await this.ollamaService.checkConnection(); + const models = await this.ollamaService.listModels(); + const session = this.getSession(sender); + + const statusText = `**Ollama Status** + +**Verbindung:** ${connected ? '✅ Online' : '❌ Offline'} +**Modelle:** ${models.length} +**Dein Modell:** \`${session.model}\` +**Chat-Verlauf:** ${session.history.length} Nachrichten +**DSGVO:** ✅ Alle Daten lokal`; + + await this.sendMessage(roomId, statusText); + } + + private async handleChat(roomId: string, sender: string, message: string) { + const session = this.getSession(sender); + + // Send typing indicator + await this.client.sendTyping(roomId, true, 30000); + + try { + // Add user message to history + session.history.push({ role: 'user', content: message }); + + // Keep only last 10 messages + if (session.history.length > 10) { + session.history = session.history.slice(-10); + } + + // Build messages with system prompt + const messages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [ + { role: 'system', content: session.systemPrompt }, + ...session.history, + ]; + + const response = await this.ollamaService.chat(messages, session.model); + + // Add assistant response to history + session.history.push({ role: 'assistant', content: response }); + + // Stop typing indicator + await this.client.sendTyping(roomId, false); + + // Send response (Matrix has higher message limits than Telegram) + await this.sendMessage(roomId, response); + } catch (error) { + await this.client.sendTyping(roomId, false); + this.logger.error(`Error processing message:`, error); + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `❌ Fehler: ${errorMessage}`); + } + } + + private async sendMessage(roomId: string, message: string) { + // Convert markdown to basic HTML for Matrix + const htmlBody = this.markdownToHtml(message); + + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: message, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + } + + private markdownToHtml(markdown: string): string { + return markdown + // Code blocks + .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Bold + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Italic + .replace(/\*([^*]+)\*/g, '$1') + // Line breaks + .replace(/\n/g, '
'); + } +} diff --git a/services/matrix-ollama-bot/src/config/configuration.ts b/services/matrix-ollama-bot/src/config/configuration.ts new file mode 100644 index 000000000..72d1af582 --- /dev/null +++ b/services/matrix-ollama-bot/src/config/configuration.ts @@ -0,0 +1,22 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3311', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',').filter(Boolean) || [], + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + ollama: { + url: process.env.OLLAMA_URL || 'http://localhost:11434', + model: process.env.OLLAMA_MODEL || 'gemma3:4b', + timeout: parseInt(process.env.OLLAMA_TIMEOUT || '120000', 10), + }, +}); + +export const SYSTEM_PROMPTS: Record = { + default: `Du bist ein hilfreicher KI-Assistent. Antworte auf Deutsch, wenn der Nutzer Deutsch schreibt. Halte deine Antworten prägnant und hilfreich.`, + classify: `Du bist ein Textklassifizierer. Analysiere den gegebenen Text und ordne ihn einer passenden Kategorie zu. Gib nur die Kategorie und eine kurze Begründung an.`, + summarize: `Du bist ein Zusammenfassungs-Experte. Fasse den gegebenen Text kurz und präzise zusammen. Behalte die wichtigsten Informationen bei.`, + translate: `Du bist ein Übersetzer. Übersetze den Text in die gewünschte Sprache. Wenn keine Zielsprache angegeben ist, übersetze zwischen Deutsch und Englisch.`, + code: `Du bist ein Programmier-Assistent. Hilf bei Code-Fragen, erkläre Konzepte und schreibe sauberen, gut dokumentierten Code. Verwende Markdown Code-Blöcke für Code.`, +}; diff --git a/services/matrix-ollama-bot/src/health.controller.ts b/services/matrix-ollama-bot/src/health.controller.ts new file mode 100644 index 000000000..85ecda0d3 --- /dev/null +++ b/services/matrix-ollama-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-ollama-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-ollama-bot/src/main.ts b/services/matrix-ollama-bot/src/main.ts new file mode 100644 index 000000000..0ecb02c07 --- /dev/null +++ b/services/matrix-ollama-bot/src/main.ts @@ -0,0 +1,15 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const port = process.env.PORT || 3311; + await app.listen(port); + + logger.log(`Matrix Ollama Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} +bootstrap(); diff --git a/services/matrix-ollama-bot/src/ollama/ollama.module.ts b/services/matrix-ollama-bot/src/ollama/ollama.module.ts new file mode 100644 index 000000000..a0ae211c4 --- /dev/null +++ b/services/matrix-ollama-bot/src/ollama/ollama.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { OllamaService } from './ollama.service'; + +@Module({ + providers: [OllamaService], + exports: [OllamaService], +}) +export class OllamaModule {} diff --git a/services/matrix-ollama-bot/src/ollama/ollama.service.ts b/services/matrix-ollama-bot/src/ollama/ollama.service.ts new file mode 100644 index 000000000..7d0dbb29c --- /dev/null +++ b/services/matrix-ollama-bot/src/ollama/ollama.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface OllamaModel { + name: string; + size: number; + modified_at: string; +} + +@Injectable() +export class OllamaService implements OnModuleInit { + private readonly logger = new Logger(OllamaService.name); + private readonly baseUrl: string; + private readonly defaultModel: string; + private readonly timeout: number; + + constructor(private configService: ConfigService) { + this.baseUrl = this.configService.get('ollama.url') || 'http://localhost:11434'; + this.defaultModel = this.configService.get('ollama.model') || 'gemma3:4b'; + this.timeout = this.configService.get('ollama.timeout') || 120000; + } + + async onModuleInit() { + await this.checkConnection(); + } + + async checkConnection(): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/version`, { + signal: AbortSignal.timeout(5000), + }); + const data = await response.json(); + this.logger.log(`Ollama connected: v${data.version}`); + return true; + } catch (error) { + this.logger.error(`Failed to connect to Ollama at ${this.baseUrl}:`, error); + return false; + } + } + + async listModels(): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/tags`); + const data = await response.json(); + return data.models || []; + } catch (error) { + this.logger.error('Failed to list models:', error); + return []; + } + } + + async chat( + messages: { role: 'user' | 'assistant' | 'system'; content: string }[], + model?: string + ): Promise { + const selectedModel = model || this.defaultModel; + + try { + const response = await fetch(`${this.baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: selectedModel, + messages, + stream: false, + }), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Ollama API error: ${response.status}`); + } + + const data = await response.json(); + + // Log performance metrics + if (data.eval_count && data.eval_duration) { + const tokensPerSec = (data.eval_count / data.eval_duration) * 1e9; + this.logger.debug(`Generated ${data.eval_count} tokens at ${tokensPerSec.toFixed(1)} t/s`); + } + + return data.message?.content || ''; + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + throw new Error('Ollama Timeout - Antwort dauerte zu lange'); + } + throw error; + } + } + + getDefaultModel(): string { + return this.defaultModel; + } +} diff --git a/services/matrix-ollama-bot/tsconfig.json b/services/matrix-ollama-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-ollama-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +}