|
1 | 1 | /* |
2 | | - * NEXT-LEVEL ioBroker Gemini Integration |
3 | | - * * Features: |
4 | | - * - Dynamische Benachrichtigungen mit Google Gemini 2.5 Flash |
| 2 | + * NEXT-LEVEL ioBroker Ollama Integration |
| 3 | + * |
| 4 | + * Features: |
| 5 | + * - Dynamische Benachrichtigungen mit lokalem Ollama-Modell |
5 | 6 | * - Spamschutz (Debouncing) für flatternde Sensoren |
6 | 7 | * - Eigene "Personas" (Charaktere) pro Gerät |
7 | 8 | * - Tageszeit-Kontext für natürlichere Antworten |
8 | | - * - Natives API System-Prompting für strikte Regel-Einhaltung |
| 9 | + * - Optimiertes Prompting für lokale LLMs (Ollama-kompatibel) |
| 10 | + * - Automatisches Modell-Fallback |
9 | 11 | */ |
10 | 12 |
|
11 | 13 | (function() { |
12 | 14 | "use strict"; |
13 | | - |
14 | | - // Das 'axios' Modul einmalig laden. |
15 | | - // Muss als zusätzliches NPM-Modul in der Javascript-Adapter Konfiguration eingetragen werden! |
| 15 | + |
| 16 | + // @ts-ignore |
16 | 17 | const axios = require('axios'); |
17 | 18 |
|
18 | 19 | // +++++ KONFIGURATION +++++ |
19 | 20 |
|
20 | | - const GEMINI_API_KEY = getState("0_userdata.0.API-Keys.GEMINI_API_KEY").val; |
21 | | - const GEMINI_MODEL = 'gemini-2.5-flash'; |
| 21 | + // Ollama Endpunkt & Modelle |
| 22 | + const OLLAMA_BASE_URL = 'http://192.168.50.6:11434'; |
| 23 | + const OLLAMA_MODEL_PRIMARY = 'gemma4:31b-cloud'; |
| 24 | + const OLLAMA_MODEL_FALLBACK = ['qwen3-coder-next:cloud', 'gpt-oss:20b-cloud']; |
| 25 | + |
| 26 | + // Generierungs-Parameter (für lokale Modelle wichtiger als bei Cloud-APIs) |
| 27 | + const GENERATION_OPTIONS = { |
| 28 | + temperature: 0.85, // Kreativität: 0.0 = deterministisch, 1.0 = sehr kreativ |
| 29 | + top_p: 0.92, |
| 30 | + top_k: 40, |
| 31 | + num_predict: 80, // Max. Token-Ausgabe; kurz halten für kompakte Benachrichtigungen |
| 32 | + repeat_penalty: 1.15 // Verhindert Wiederholungen im Text |
| 33 | + }; |
22 | 34 |
|
23 | 35 | // Globale Standard-Persona, falls beim Gerät keine angegeben ist |
24 | 36 | const DEFAULT_PERSONA = 'eine freundliche und leicht humorvolle Smart-Home KI'; |
25 | 37 |
|
26 | 38 | const DEVICES = [ |
27 | 39 | { |
28 | | - triggerId: '0_userdata.0.briefkasten.post_war_da', |
29 | | - targetId: '0_userdata.0.Gemini.Notification.Briefkasten', |
| 40 | + triggerId: '0_userdata.0.briefkasten.post_war_da', |
| 41 | + targetId: '0_userdata.0.Ollama.Notification.Briefkasten', |
30 | 42 | deviceName: 'Briefkasten im Außenbereich', |
31 | 43 | stateMapping: { true: 'Post eingeworfen', false: 'Post geleert' }, |
32 | | - triggerOn: [true, false], |
33 | | - tense: 'Vergangenheit', |
34 | | - // NEXT LEVEL FEATURES: |
35 | | - persona: 'ein extrem höflicher, britischer Butler namens James', // KI spielt eine Rolle |
36 | | - debounceTime: 5000 // Wartet 5 Sekunden nach dem letzten Trigger, um "Flattern" zu ignorieren |
| 44 | + triggerOn: [true, false], |
| 45 | + tense: 'Vergangenheit', |
| 46 | + persona: 'ein extrem höflicher, britischer Butler namens James', |
| 47 | + debounceTime: 5000 |
37 | 48 | }, |
38 | 49 | // Beispiel für ein weiteres Gerät: |
39 | 50 | // { |
40 | | - // triggerId: 'alias.0.Wohnzimmer.Fenster.ACTUAL', |
41 | | - // targetId: '0_userdata.0.Gemini.Notification.Fenster_Wohnzimmer', |
| 51 | + // triggerId: 'alias.0.Wohnzimmer.Fenster.ACTUAL', |
| 52 | + // targetId: '0_userdata.0.Ollama.Notification.Fenster_Wohnzimmer', |
42 | 53 | // deviceName: 'Wohnzimmerfenster', |
43 | 54 | // stateMapping: { true: 'geöffnet', false: 'geschlossen' }, |
44 | | - // triggerOn: [true], |
45 | | - // tense: 'Gegenwart', |
46 | | - // persona: 'eine leicht besorgte Mutter, die Angst vor Zugluft hat', |
| 55 | + // triggerOn: [true], |
| 56 | + // tense: 'Gegenwart', |
| 57 | + // persona: 'eine leicht besorgte Mutter, die Angst vor Zugluft hat', |
47 | 58 | // debounceTime: 2000 |
48 | 59 | // } |
49 | 60 | ]; |
50 | 61 |
|
51 | 62 | // +++++ INTERNE LOGIK +++++ |
52 | 63 |
|
53 | | - const GEMINI_API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`; |
54 | | - const debounceTimers = {}; // Speichert die Timer für den Spamschutz |
| 64 | + const OLLAMA_GENERATE_URL = `${OLLAMA_BASE_URL}/api/generate`; |
| 65 | + const debounceTimers = {}; |
55 | 66 |
|
56 | | - // Hilfsfunktion: Tageszeit ermitteln für mehr Kontext |
57 | 67 | function getTimeOfDayContext() { |
58 | 68 | const hour = new Date().getHours(); |
59 | | - if (hour >= 5 && hour < 12) return "Es ist Vormittag."; |
| 69 | + if (hour >= 5 && hour < 12) return "Es ist Vormittag."; |
60 | 70 | if (hour >= 12 && hour < 18) return "Es ist Nachmittag."; |
61 | 71 | if (hour >= 18 && hour < 22) return "Es ist Abend."; |
62 | 72 | return "Es ist mitten in der Nacht."; |
63 | 73 | } |
64 | 74 |
|
65 | | - async function getGeminiResponse(deviceConfig, stateName) { |
66 | | - if (GEMINI_API_KEY === 'DEIN_API_SCHLUESSEL' || GEMINI_API_KEY.length < 10) { |
67 | | - log('Bitte tragen Sie einen gültigen Gemini API-Schlüssel ein.', 'error'); |
68 | | - return; |
69 | | - } |
70 | | - |
| 75 | + /** |
| 76 | + * Baut den optimierten Prompt für lokale Ollama-Modelle. |
| 77 | + * Lokale LLMs reagieren besser auf explizite, direkte Anweisungen |
| 78 | + * als auf abstrakte Rollenspiel-Konstrukte. |
| 79 | + */ |
| 80 | + function buildPrompt(deviceConfig, stateName) { |
71 | 81 | const tenseMap = { |
72 | | - 'vergangenheit': 'Verwende zwingend die Vergangenheitsform (Perfekt).', |
73 | | - 'zukunft': 'Verwende zwingend die Zukunftsform (Futur I).', |
74 | | - 'gegenwart': 'Verwende zwingend die Gegenwartsform (Präsens).' |
| 82 | + 'vergangenheit': 'Schreibe im Perfekt (Vergangenheit).', |
| 83 | + 'zukunft': 'Schreibe im Futur I (Zukunft).', |
| 84 | + 'gegenwart': 'Schreibe im Präsens (Gegenwart).' |
75 | 85 | }; |
76 | 86 | const tenseInstruction = tenseMap[(deviceConfig.tense || 'gegenwart').toLowerCase()] || tenseMap['gegenwart']; |
77 | | - const persona = deviceConfig.persona || DEFAULT_PERSONA; |
78 | | - const timeContext = getTimeOfDayContext(); |
79 | | - |
80 | | - // NEXT LEVEL: Wir nutzen 'systemInstruction', um der KI unumstößliche Kern-Regeln zu geben |
81 | | - const systemInstruction = `Du bist ${persona}. |
82 | | -Deine Aufgabe ist es, den Nutzer über ein Smart-Home-Ereignis zu informieren. |
83 | | -
|
84 | | -STRENGE REGELN: |
85 | | -1. Erwähne AUSSCHLIESSLICH das vorgegebene Gerät. Erfinde niemals andere Geräte! |
86 | | -2. ${tenseInstruction} |
87 | | -3. Formuliere exakt einen einzigen, kompakten Satz (maximal 12 Worte). |
88 | | -4. Sprich den Nutzer direkt an (z.B. "Sir", "Du", "Euer Majestät" - passend zu deiner Persona). |
89 | | -5. Liefere nur den reinen Benachrichtigungstext, ohne Anführungszeichen oder Einleitungen. |
90 | | -6. Berücksichtige die aktuelle Tageszeit in deiner Formulierung, falls es sich natürlich anfühlt.`; |
91 | | - |
92 | | - // Der eigentliche User-Prompt enthält nur noch die reinen, nackten Fakten |
93 | | - const userPrompt = `FAKTEN: |
94 | | -- Aktuelle Tageszeit: ${timeContext} |
95 | | -- Betroffenes Gerät: ${deviceConfig.deviceName} |
96 | | -- Neues Ereignis/Zustand: ${stateName}`; |
97 | | - |
98 | | - const payload = { |
99 | | - systemInstruction: { parts: [{ text: systemInstruction }] }, |
100 | | - contents: [{ parts: [{ text: userPrompt }] }] |
101 | | - }; |
| 87 | + const persona = deviceConfig.persona || DEFAULT_PERSONA; |
| 88 | + const timeCtx = getTimeOfDayContext(); |
| 89 | + |
| 90 | + // Für lokale Modelle: Klarer Aufbau mit System-Block, dann direkter Aufgabe. |
| 91 | + // Viele Ollama-Modelle unterstützen keinen separaten system-Parameter im /api/generate |
| 92 | + // Endpunkt, daher wird alles in einen einzigen strukturierten Prompt gebettet. |
| 93 | + return `### SYSTEM |
| 94 | +Du bist ${persona}. |
| 95 | +Deine einzige Aufgabe: Formuliere eine einzige, knappe Smart-Home-Benachrichtigung. |
| 96 | +
|
| 97 | +STRIKTE REGELN — halte sie ohne Ausnahme ein: |
| 98 | +- Erwähne NUR das angegebene Gerät. Erfinde nichts dazu. |
| 99 | +- ${tenseInstruction} |
| 100 | +- Maximal 1 Satz, maximal 12 Wörter. |
| 101 | +- Sprich den Nutzer passend zur Persona an (z. B. "Sir", "Du" o. Ä.). |
| 102 | +- Gib NUR den reinen Benachrichtigungstext aus — keine Anführungszeichen, keine Einleitungen, kein Kommentar. |
| 103 | +- Antworte ausschließlich auf Deutsch. |
| 104 | +
|
| 105 | +### AUFGABE |
| 106 | +${timeCtx} |
| 107 | +Gerät: ${deviceConfig.deviceName} |
| 108 | +Ereignis: ${stateName} |
| 109 | +
|
| 110 | +### BENACHRICHTIGUNG`; |
| 111 | + } |
102 | 112 |
|
103 | | - const MAX_RETRIES = 3; |
104 | | - let delay = 2000; |
| 113 | + /** |
| 114 | + * Versucht die Generierung mit dem primären Modell, |
| 115 | + * fällt bei Fehler automatisch auf die Fallback-Modelle zurück. |
| 116 | + */ |
| 117 | + async function generateWithFallback(prompt) { |
| 118 | + const modelQueue = [OLLAMA_MODEL_PRIMARY, ...OLLAMA_MODEL_FALLBACK]; |
105 | 119 |
|
106 | | - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { |
| 120 | + for (const model of modelQueue) { |
107 | 121 | try { |
108 | | - log(`Sende Anfrage für "${deviceConfig.targetId}" an Gemini API...`); |
109 | | - const response = await axios.post(GEMINI_API_URL, payload, { |
110 | | - headers: { 'Content-Type': 'application/json' } |
| 122 | + log(`Versuche Modell: ${model}`); |
| 123 | + const payload = { |
| 124 | + model: model, |
| 125 | + prompt: prompt, |
| 126 | + stream: false, // Wichtig: false für eine einzelne vollständige Antwort |
| 127 | + options: GENERATION_OPTIONS |
| 128 | + }; |
| 129 | + |
| 130 | + const response = await axios.post(OLLAMA_GENERATE_URL, payload, { |
| 131 | + headers: { 'Content-Type': 'application/json' }, |
| 132 | + timeout: 60000 // 60 Sekunden Timeout (lokale Modelle können langsam laden) |
111 | 133 | }); |
112 | 134 |
|
113 | | - if (response.status === 200) { |
114 | | - const generatedText = response.data.candidates?.[0]?.content?.parts?.[0]?.text; |
115 | | - if (generatedText) { |
116 | | - const cleanText = generatedText.trim().replace(/^["']|["']$/g, ''); // Entfernt evtl. generierte Anführungszeichen |
117 | | - log(`Antwort für ${deviceConfig.targetId}: ${cleanText}`); |
118 | | - setState(deviceConfig.targetId, cleanText, true); |
119 | | - return; |
120 | | - } |
| 135 | + if (response.status === 200 && response.data?.response) { |
| 136 | + log(`Erfolg mit Modell: ${model}`); |
| 137 | + return { text: response.data.response, model }; |
121 | 138 | } |
| 139 | + |
122 | 140 | } catch (e) { |
123 | | - if ((e.response?.status === 503 || e.response?.status === 429) && attempt < MAX_RETRIES) { |
124 | | - await new Promise(resolve => setTimeout(resolve, delay)); |
125 | | - delay *= 2; |
| 141 | + const status = e.response?.status; |
| 142 | + const errMsg = e.message; |
| 143 | + |
| 144 | + if (status === 404) { |
| 145 | + log(`Modell "${model}" nicht gefunden auf Ollama-Server. Versuche nächstes...`, 'warn'); |
| 146 | + } else if (e.code === 'ECONNREFUSED' || e.code === 'ECONNABORTED') { |
| 147 | + log(`Ollama-Server nicht erreichbar (${OLLAMA_BASE_URL}): ${errMsg}`, 'error'); |
| 148 | + return null; // Kein Fallback nötig, Server ist weg |
126 | 149 | } else { |
127 | | - log(`API Fehler bei ${deviceConfig.targetId}: ${e.message}`, 'error'); |
128 | | - setState(deviceConfig.targetId, `API Fehler aufgetreten.`, true); |
129 | | - return; |
| 150 | + log(`Fehler mit Modell "${model}" (HTTP ${status || 'N/A'}): ${errMsg}`, 'warn'); |
130 | 151 | } |
131 | 152 | } |
132 | 153 | } |
| 154 | + |
| 155 | + log('Alle Modelle fehlgeschlagen. Keine Benachrichtigung generiert.', 'error'); |
| 156 | + return null; |
| 157 | + } |
| 158 | + |
| 159 | + async function getOllamaResponse(deviceConfig, stateName) { |
| 160 | + const prompt = buildPrompt(deviceConfig, stateName); |
| 161 | + log(`Sende Anfrage für "${deviceConfig.deviceName}" an Ollama...`); |
| 162 | + |
| 163 | + const result = await generateWithFallback(prompt); |
| 164 | + |
| 165 | + if (!result) { |
| 166 | + setState(deviceConfig.targetId, 'Fehler: Ollama nicht erreichbar.', true); |
| 167 | + return; |
| 168 | + } |
| 169 | + |
| 170 | + // Bereinigung: Anführungszeichen, Zeilenumbrüche und Modell-Artefakte entfernen |
| 171 | + let cleanText = result.text |
| 172 | + .trim() |
| 173 | + .replace(/^["'„»«]|["'""»«]$/g, '') // Anführungszeichen am Rand |
| 174 | + .replace(/\n.*/s, '') // Alles nach dem ersten Zeilenumbruch |
| 175 | + .replace(/^(Benachrichtigung:|Antwort:|Output:)\s*/i, '') // Modell-Präfixe |
| 176 | + .trim(); |
| 177 | + |
| 178 | + log(`[${result.model}] Antwort für "${deviceConfig.deviceName}": ${cleanText}`); |
| 179 | + setState(deviceConfig.targetId, cleanText, true); |
133 | 180 | } |
134 | 181 |
|
135 | 182 | async function main() { |
136 | | - // NEXT LEVEL: Saubere ioBroker Datenpunkt-Erstellung mit createStateAsync (kompatibel mit allen JS-Adapter Versionen) |
| 183 | + // Zieldatenpunkte anlegen |
137 | 184 | for (const device of DEVICES) { |
138 | 185 | await createStateAsync(device.targetId, { |
139 | | - name: `Gemini Info: ${device.deviceName}`, |
140 | | - desc: `KI generierter Text für ${device.deviceName}`, |
141 | | - type: 'string', |
142 | | - role: 'text', |
143 | | - read: true, |
| 186 | + name: `Ollama Info: ${device.deviceName}`, |
| 187 | + desc: `KI generierter Text für ${device.deviceName}`, |
| 188 | + type: 'string', |
| 189 | + role: 'text', |
| 190 | + read: true, |
144 | 191 | write: false, |
145 | | - def: '' |
| 192 | + def: '' |
146 | 193 | }); |
147 | 194 | } |
148 | 195 |
|
| 196 | + // Verbindung zum Ollama-Server beim Start prüfen |
| 197 | + try { |
| 198 | + await axios.get(`${OLLAMA_BASE_URL}/api/tags`, { timeout: 5000 }); |
| 199 | + log(`Ollama-Server erreichbar unter ${OLLAMA_BASE_URL}. Skript bereit.`); |
| 200 | + } catch (e) { |
| 201 | + log(`WARNUNG: Ollama-Server unter ${OLLAMA_BASE_URL} nicht erreichbar. Bitte Server prüfen!`, 'warn'); |
| 202 | + } |
| 203 | + |
149 | 204 | const allTriggerIds = DEVICES.map(device => device.triggerId); |
150 | | - log('Gemini Skript gestartet. Alle Zieldatenpunkte sind bereit.'); |
| 205 | + log('Ollama-Skript gestartet. Alle Zieldatenpunkte sind bereit.'); |
151 | 206 |
|
152 | 207 | on({ id: allTriggerIds, change: 'ne' }, async (obj) => { |
153 | 208 | const device = DEVICES.find(d => d.triggerId === obj.id); |
154 | 209 | if (!device) return; |
155 | 210 |
|
156 | 211 | const stateVal = obj.state.val; |
157 | 212 | const triggerOn = device.triggerOn || [true]; |
158 | | - |
| 213 | + |
159 | 214 | if (!triggerOn.includes(stateVal)) return; |
160 | 215 |
|
161 | 216 | const stateName = device.stateMapping[stateVal.toString()] || (stateVal ? 'aktiviert' : 'deaktiviert'); |
162 | | - log(`Trigger erkannt: ${device.deviceName} -> "${stateName}". Starte Spamschutz-Timer...`); |
| 217 | + log(`Trigger erkannt: ${device.deviceName} → "${stateName}". Starte Debounce-Timer...`); |
163 | 218 |
|
164 | | - // NEXT LEVEL: Debouncing (Spamschutz) |
165 | | - // Wenn der Trigger innerhalb der Wartezeit erneut auslöst, wird der alte Timer abgebrochen |
| 219 | + // Debouncing: Bei erneutem Trigger innerhalb der Wartezeit alten Timer verwerfen |
166 | 220 | if (debounceTimers[device.triggerId]) { |
167 | 221 | clearTimeout(debounceTimers[device.triggerId]); |
168 | 222 | } |
169 | 223 |
|
170 | | - const debounceMs = device.debounceTime || 1000; // Standard: 1 Sekunde |
171 | | - |
| 224 | + const debounceMs = device.debounceTime || 1000; |
| 225 | + |
172 | 226 | debounceTimers[device.triggerId] = setTimeout(() => { |
173 | | - log(`Spamschutz abgelaufen. Generiere Text für ${device.deviceName}...`); |
174 | | - getGeminiResponse(device, stateName); |
| 227 | + log(`Debounce abgelaufen. Generiere Text für ${device.deviceName}...`); |
| 228 | + getOllamaResponse(device, stateName); |
175 | 229 | delete debounceTimers[device.triggerId]; |
176 | 230 | }, debounceMs); |
177 | 231 | }); |
|
0 commit comments