pdo = $pdo; } else { $this->pdo = $this->createConnection(); } } /** * Get singleton instance for static method compatibility. */ public static function getInstance(): self { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } /** * Create database connection to ki_dev. */ private function createConnection(): PDO { $password = $_ENV['MARIADB_ROOT_PASSWORD'] ?? ''; if (empty($password) && file_exists('/var/www/dev.campus.systemische-tools.de/.env')) { $envContent = file_get_contents('/var/www/dev.campus.systemische-tools.de/.env'); if (preg_match('/MARIADB_ROOT_PASSWORD=(.+)/', $envContent, $matches)) { $password = trim($matches[1]); } } return new PDO( 'mysql:host=localhost;dbname=ki_dev;charset=utf8mb4', 'root', $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); } /** * Clear cache (e.g., after Ollama sync). */ public static function clearCache(): void { self::$cache = null; } /** * Get all available chat models. * * @return array [full_key => display_name] */ public function getChatModels(): array { return $this->getModels(chat: true); } /** * Get all vision-capable models. * * @return array [full_key => display_name] */ public function getVisionModels(): array { return $this->getModels(vision: true); } /** * Get all embedding models. * * @return array [full_key => display_name] */ public function getEmbeddingModels(): array { return $this->getModels(embedding: true); } /** * Get models with optional filters. * * @return array [full_key => display_name] */ public function getModels( ?bool $chat = null, ?bool $vision = null, ?bool $embedding = null, ?string $provider = null ): array { $allModels = $this->loadAllModels(); $result = []; foreach ($allModels as $model) { if (!$model['is_available']) { continue; } if ($chat !== null && (bool) $model['is_chat'] !== $chat) { continue; } if ($vision !== null && (bool) $model['is_vision'] !== $vision) { continue; } if ($embedding !== null && (bool) $model['is_embedding'] !== $embedding) { continue; } if ($provider !== null && $model['provider'] !== $provider) { continue; } $result[$model['full_key']] = $model['display_name']; } return $result; } /** * Get a single model by full_key. */ public function getModel(string $fullKey): ?array { $allModels = $this->loadAllModels(); foreach ($allModels as $model) { if ($model['full_key'] === $fullKey) { return $model; } } return null; } /** * Get display label for a model. */ public function getLabel(string $fullKey): string { $model = $this->getModel($fullKey); return $model['display_name'] ?? $fullKey; } /** * Check if model exists and is available. */ public function isValid(string $fullKey): bool { $model = $this->getModel($fullKey); return $model !== null && $model['is_available']; } /** * Check if model is local (Ollama). */ public function isLocal(string $fullKey): bool { return str_starts_with($fullKey, 'ollama:'); } /** * Get default chat model (first available by priority). */ public function getDefaultChatModel(): string { $chatModels = $this->getChatModels(); return array_key_first($chatModels) ?? 'ollama:mistral:latest'; } /** * Get default vision model. */ public function getDefaultVisionModel(): string { $visionModels = $this->getVisionModels(); // Prefer local vision model foreach (array_keys($visionModels) as $key) { if (str_starts_with($key, 'ollama:')) { return $key; } } return array_key_first($visionModels) ?? 'ollama:minicpm-v:latest'; } /** * Load all models from database (with caching). */ private function loadAllModels(): array { if (self::$cache !== null) { return self::$cache; } $stmt = $this->pdo->query( 'SELECT id, provider, model_id, display_name, full_key, is_available, is_chat, is_embedding, is_vision, context_length, parameters, priority FROM ai_models WHERE is_available = 1 ORDER BY priority ASC' ); self::$cache = $stmt->fetchAll(PDO::FETCH_ASSOC); return self::$cache; } /** * Sync models from Ollama (call after `ollama pull`). * Updates is_available and last_seen_at for Ollama models. */ public function syncFromOllama(): array { $output = []; exec('ollama list 2>/dev/null', $output, $returnCode); if ($returnCode !== 0) { return ['error' => 'Could not run ollama list']; } $ollamaModels = []; foreach ($output as $line) { if (preg_match('/^(\S+)\s+/', $line, $matches)) { $modelName = $matches[1]; if ($modelName !== 'NAME') { $ollamaModels[] = $modelName; } } } // Mark all Ollama models as unavailable first $this->pdo->exec( "UPDATE ai_models SET is_available = 0 WHERE provider = 'ollama'" ); $added = []; $updated = []; foreach ($ollamaModels as $modelId) { // Check if model exists $stmt = $this->pdo->prepare( 'SELECT id FROM ai_models WHERE provider = ? AND model_id = ?' ); $stmt->execute(['ollama', $modelId]); $existing = $stmt->fetch(); if ($existing) { // Update existing $stmt = $this->pdo->prepare( 'UPDATE ai_models SET is_available = 1, last_seen_at = NOW() WHERE provider = ? AND model_id = ?' ); $stmt->execute(['ollama', $modelId]); $updated[] = $modelId; } else { // Insert new $displayName = $this->generateDisplayName($modelId); $isEmbedding = str_contains($modelId, 'embed'); $isVision = str_contains($modelId, 'vision') || str_contains($modelId, 'minicpm-v'); $stmt = $this->pdo->prepare( 'INSERT INTO ai_models (provider, model_id, display_name, is_available, is_chat, is_embedding, is_vision, last_seen_at, priority) VALUES (?, ?, ?, 1, ?, ?, ?, NOW(), 90)' ); $stmt->execute([ 'ollama', $modelId, $displayName, $isEmbedding ? 0 : 1, $isEmbedding ? 1 : 0, $isVision ? 1 : 0, ]); $added[] = $modelId; } } self::clearCache(); return [ 'ollama_models' => $ollamaModels, 'added' => $added, 'updated' => $updated, ]; } /** * Generate display name from model ID. */ private function generateDisplayName(string $modelId): string { // Remove version tags $name = preg_replace('/:latest$/', '', $modelId); // Capitalize and format $parts = explode(':', $name); $baseName = ucfirst($parts[0]); if (isset($parts[1])) { $baseName .= ' ' . strtoupper($parts[1]); } return $baseName . ' (lokal)'; } }