mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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
This commit is contained in:
parent
3aa9e8608d
commit
aabe328b51
16 changed files with 823 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
15
services/matrix-ollama-bot/.env.example
Normal file
15
services/matrix-ollama-bot/.env.example
Normal file
|
|
@ -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
|
||||
137
services/matrix-ollama-bot/CLAUDE.md
Normal file
137
services/matrix-ollama-bot/CLAUDE.md
Normal file
|
|
@ -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
|
||||
53
services/matrix-ollama-bot/Dockerfile
Normal file
53
services/matrix-ollama-bot/Dockerfile
Normal file
|
|
@ -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"]
|
||||
8
services/matrix-ollama-bot/nest-cli.json
Normal file
8
services/matrix-ollama-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
34
services/matrix-ollama-bot/package.json
Normal file
34
services/matrix-ollama-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
17
services/matrix-ollama-bot/src/app.module.ts
Normal file
17
services/matrix-ollama-bot/src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||
10
services/matrix-ollama-bot/src/bot/bot.module.ts
Normal file
10
services/matrix-ollama-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
340
services/matrix-ollama-bot/src/bot/matrix.service.ts
Normal file
340
services/matrix-ollama-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -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<string, UserSession> = new Map();
|
||||
private readonly allowedRooms: string[];
|
||||
private botUserId: string = '';
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private ollamaService: OllamaService
|
||||
) {
|
||||
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
|
||||
const accessToken = this.configService.get<string>('matrix.accessToken');
|
||||
const storagePath = this.configService.get<string>('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<MessageEvent>) {
|
||||
// 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, '<pre><code>$2</code></pre>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Bold
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
// Italic
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
// Line breaks
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
}
|
||||
22
services/matrix-ollama-bot/src/config/configuration.ts
Normal file
22
services/matrix-ollama-bot/src/config/configuration.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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.`,
|
||||
};
|
||||
13
services/matrix-ollama-bot/src/health.controller.ts
Normal file
13
services/matrix-ollama-bot/src/health.controller.ts
Normal file
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
services/matrix-ollama-bot/src/main.ts
Normal file
15
services/matrix-ollama-bot/src/main.ts
Normal file
|
|
@ -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();
|
||||
8
services/matrix-ollama-bot/src/ollama/ollama.module.ts
Normal file
8
services/matrix-ollama-bot/src/ollama/ollama.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { OllamaService } from './ollama.service';
|
||||
|
||||
@Module({
|
||||
providers: [OllamaService],
|
||||
exports: [OllamaService],
|
||||
})
|
||||
export class OllamaModule {}
|
||||
94
services/matrix-ollama-bot/src/ollama/ollama.service.ts
Normal file
94
services/matrix-ollama-bot/src/ollama/ollama.service.ts
Normal file
|
|
@ -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<string>('ollama.url') || 'http://localhost:11434';
|
||||
this.defaultModel = this.configService.get<string>('ollama.model') || 'gemma3:4b';
|
||||
this.timeout = this.configService.get<number>('ollama.timeout') || 120000;
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.checkConnection();
|
||||
}
|
||||
|
||||
async checkConnection(): Promise<boolean> {
|
||||
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<OllamaModel[]> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
22
services/matrix-ollama-bot/tsconfig.json
Normal file
22
services/matrix-ollama-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue