Update notification service

This commit is contained in:
MacRimi
2026-03-18 09:36:05 +01:00
parent 46b222180a
commit c13c7ba626
4 changed files with 127 additions and 22 deletions

View File

@@ -61,6 +61,7 @@ interface NotificationConfig {
ai_model: string ai_model: string
ai_language: string ai_language: string
ai_ollama_url: string ai_ollama_url: string
ai_openai_base_url: string
channel_ai_detail: Record<string, string> channel_ai_detail: Record<string, string>
hostname: string hostname: string
webhook_secret: string webhook_secret: string
@@ -211,6 +212,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
ai_model: "", ai_model: "",
ai_language: "en", ai_language: "en",
ai_ollama_url: "http://localhost:11434", ai_ollama_url: "http://localhost:11434",
ai_openai_base_url: "",
channel_ai_detail: { channel_ai_detail: {
telegram: "brief", telegram: "brief",
gotify: "brief", gotify: "brief",
@@ -1390,6 +1392,26 @@ export function NotificationSettings() {
</div> </div>
)} )}
{/* Custom Base URL for OpenAI-compatible APIs */}
{config.ai_provider === "openai" && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-xs sm:text-sm text-foreground/80">Custom Base URL</Label>
<span className="text-xs text-muted-foreground">(optional)</span>
</div>
<Input
className="h-9 text-sm font-mono"
placeholder="Leave empty for OpenAI, or enter custom endpoint"
value={config.ai_openai_base_url}
onChange={e => updateConfig(p => ({ ...p, ai_openai_base_url: e.target.value }))}
disabled={!editMode}
/>
<p className="text-xs text-muted-foreground">
For OpenAI-compatible APIs: BytePlus, LocalAI, LM Studio, vLLM, etc.
</p>
</div>
)}
{/* API Key (not shown for Ollama) */} {/* API Key (not shown for Ollama) */}
{config.ai_provider !== "ollama" && ( {config.ai_provider !== "ollama" && (
<div className="space-y-2"> <div className="space-y-2">
@@ -1584,6 +1606,21 @@ export function NotificationSettings() {
<p className="text-xs sm:text-sm text-muted-foreground mt-2 ml-[52px] leading-relaxed"> <p className="text-xs sm:text-sm text-muted-foreground mt-2 ml-[52px] leading-relaxed">
{provider.description} {provider.description}
</p> </p>
{/* OpenAI compatibility note */}
{provider.value === "openai" && (
<div className="mt-3 ml-[52px] p-3 rounded-md bg-blue-500/10 border border-blue-500/20">
<p className="text-xs sm:text-sm text-blue-400 font-medium mb-1">OpenAI-Compatible APIs</p>
<p className="text-xs text-muted-foreground leading-relaxed">
You can use any OpenAI-compatible API by setting a custom Base URL. Compatible services include:
</p>
<ul className="text-xs text-muted-foreground mt-1.5 space-y-0.5 ml-3">
<li>BytePlus/ByteDance (Kimi K2.5)</li>
<li>LocalAI, LM Studio, vLLM</li>
<li>Together AI, Fireworks AI</li>
<li>Any service using OpenAI format</li>
</ul>
</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -8,16 +8,37 @@ from .base import AIProvider, AIProviderError
class OpenAIProvider(AIProvider): class OpenAIProvider(AIProvider):
"""OpenAI provider using their Chat Completions API.""" """OpenAI provider using their Chat Completions API.
Also compatible with OpenAI-compatible APIs like:
- BytePlus/ByteDance (Kimi K2.5)
- LocalAI
- LM Studio
- vLLM
- Together AI
- Any OpenAI-compatible endpoint
"""
NAME = "openai" NAME = "openai"
DEFAULT_MODEL = "gpt-4o-mini" DEFAULT_MODEL = "gpt-4o-mini"
REQUIRES_API_KEY = True REQUIRES_API_KEY = True
API_URL = "https://api.openai.com/v1/chat/completions" DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions"
def _get_api_url(self) -> str:
"""Get the API URL, using custom base_url if provided."""
if self.base_url:
# Ensure the URL ends with the correct path
base = self.base_url.rstrip('/')
if not base.endswith('/chat/completions'):
if not base.endswith('/v1'):
base = f"{base}/v1"
base = f"{base}/chat/completions"
return base
return self.DEFAULT_API_URL
def generate(self, system_prompt: str, user_message: str, def generate(self, system_prompt: str, user_message: str,
max_tokens: int = 200) -> Optional[str]: max_tokens: int = 200) -> Optional[str]:
"""Generate a response using OpenAI's API. """Generate a response using OpenAI's API or compatible endpoint.
Args: Args:
system_prompt: System instructions system_prompt: System instructions
@@ -48,7 +69,8 @@ class OpenAIProvider(AIProvider):
'Authorization': f'Bearer {self.api_key}', 'Authorization': f'Bearer {self.api_key}',
} }
result = self._make_request(self.API_URL, payload, headers) api_url = self._get_api_url()
result = self._make_request(api_url, payload, headers)
try: try:
return result['choices'][0]['message']['content'].strip() return result['choices'][0]['message']['content'].strip()

View File

@@ -1162,9 +1162,11 @@ class NotificationManager:
ai_status = f'AI: enabled ({ai_provider} / {ai_language})' if ai_enabled else 'AI: disabled' ai_status = f'AI: enabled ({ai_provider} / {ai_language})' if ai_enabled else 'AI: disabled'
# Base test message — shows current channel config # Base test message — shows current channel config
# NOTE: narrative lines are intentionally unlabeled so the AI
# does not prepend "Message:" or other spurious field labels.
base_title = 'ProxMenux Test' base_title = 'ProxMenux Test'
base_message = ( base_message = (
'Welcome to ProxMenux Monitor!\n' 'Welcome to ProxMenux Monitor!\n\n'
'This is a test message to verify your notification channel is working correctly.\n\n' 'This is a test message to verify your notification channel is working correctly.\n\n'
'Channel configuration:\n' 'Channel configuration:\n'
f'{icon_status}\n' f'{icon_status}\n'

View File

@@ -1064,9 +1064,9 @@ EVENT_EMOJI = {
'replication_fail': '\u274C', 'replication_fail': '\u274C',
'replication_complete': '\u2705', 'replication_complete': '\u2705',
# Backups # Backups
'backup_start': '\U0001F4E6', # package 'backup_start': '\U0001F4BE\U0001F680', # 💾🚀 floppy + rocket
'backup_complete': '\u2705', 'backup_complete': '\U0001F4BE\u2705', # 💾✅ floppy + check
'backup_fail': '\u274C', 'backup_fail': '\U0001F4BE\u274C', # 💾❌ floppy + cross
'snapshot_complete': '\U0001F4F8', # camera with flash 'snapshot_complete': '\U0001F4F8', # camera with flash
'snapshot_fail': '\u274C', 'snapshot_fail': '\u274C',
# Resources # Resources
@@ -1240,7 +1240,7 @@ AI_LANGUAGES = {
AI_DETAIL_TOKENS = { AI_DETAIL_TOKENS = {
'brief': 100, # 2-3 lines, essential only 'brief': 100, # 2-3 lines, essential only
'standard': 200, # Concise paragraph with context 'standard': 200, # Concise paragraph with context
'detailed': 400, # Complete technical details 'detailed': 700, # Complete technical details (raised: multi-VM backups can be long)
} }
# System prompt template - informative, no recommendations # System prompt template - informative, no recommendations
@@ -1255,7 +1255,9 @@ Your task is to translate and reformat incoming server alert messages into {lang
4. Tone: factual, concise, technical. No greetings, no closings, no apologies 4. Tone: factual, concise, technical. No greetings, no closings, no apologies
5. DO NOT add recommendations, action items, or suggestions ("you should…", "consider…") 5. DO NOT add recommendations, action items, or suggestions ("you should…", "consider…")
6. Present ONLY the facts already in the input — do not invent or assume information 6. Present ONLY the facts already in the input — do not invent or assume information
7. Detail level to apply: {detail_level} 7. PLAIN NARRATIVE LINES — if a line in the input is a complete sentence (not a "Label: value"
pair), translate it as-is. Never prepend "Message:", "Note:", or any other label to a sentence.
8. Detail level to apply: {detail_level}
- brief → 2-3 lines, essential data only (status + key metric) - brief → 2-3 lines, essential data only (status + key metric)
- standard → short paragraph covering who/what/where and the key value - standard → short paragraph covering who/what/where and the key value
- detailed → full technical breakdown of all available fields - detailed → full technical breakdown of all available fields
@@ -1271,13 +1273,22 @@ Your task is to translate and reformat incoming server alert messages into {lang
BACKUP (backup_complete / backup_fail / backup_start): BACKUP (backup_complete / backup_fail / backup_start):
Input contains: VM/CT names, IDs, size, duration, storage location, status per VM Input contains: VM/CT names, IDs, size, duration, storage location, status per VM
Output body must list each VM on its own line: name, ID, status (ok/error), size, duration Output body: first line is plain text (no emoji) describing the event briefly.
End with a summary line: total VMs, total size, total time Then list each VM/CT with its fields. End with a summary line.
PARTIAL FAILURE RULE: if some VMs succeeded and at least one failed, use a combined title
like "Backup partially failed" / "Copia de seguridad parcialmente fallida" — never say
"backup failed" when there are also successful VMs in the same job.
NEVER omit the storage/archive line or the summary line — always include them even for long jobs.
UPDATES (update_summary / pve_update): UPDATES (update_summary / pve_update):
Input contains: total count, security count, proxmox count, kernel count, package list Input contains: total count, security count, proxmox count, kernel count, package list
Output body must show each count on its own line with its label Output body must show each count on its own line with its label.
List important packages below, one per line For the package list: use "" (bullet + space) before each package name, NOT the 📋 emoji.
The 📋 emoji goes only on the "Important packages:" header line.
Example packages block:
📋 Important packages:
• pve-manager (9.1.4 -> 9.1.6)
• qemu-server (9.1.3 -> 9.1.4)
DISK / SMART ERRORS (disk_io_error / storage_unavailable): DISK / SMART ERRORS (disk_io_error / storage_unavailable):
Input contains: device name, error type, SMART values or I/O error codes Input contains: device name, error type, SMART values or I/O error codes
@@ -1403,37 +1414,61 @@ AI_EMOJI_INSTRUCTIONS = """
⚙️ Kernel updates: 0 ⚙️ Kernel updates: 0
📋 Important packages: 📋 Important packages:
📋 none none
EXAMPLE — updates message (with important packages): EXAMPLE — updates message (with important packages):
[TITLE] [TITLE]
📦 amd: Updates available 📦 amd: Updates available
[BODY] [BODY]
📦 Total updates: 55 📦 Total updates: 90
🔒 Security updates: 3 🔒 Security updates: 6
🔄 Proxmox updates: 2 🔄 Proxmox updates: 14
⚙️ Kernel updates: 1 ⚙️ Kernel updates: 1
📋 Important packages: 📋 Important packages:
📋 pve-manager pve-manager (9.1.4 -> 9.1.6)
📋 libssl3 • qemu-server (9.1.3 -> 9.1.4)
• pve-container (6.0.18 -> 6.1.2)
EXAMPLE — backup complete with multiple VMs: EXAMPLE — backup complete with multiple VMs:
[TITLE] [TITLE]
✅ pve01: Backup complete 💾✅ pve01: Backup complete
[BODY] [BODY]
Backup job finished on storage local-bak.
🏷️ VM web01 (ID: 100) 🏷️ VM web01 (ID: 100)
✅ Status: ok ✅ Status: ok
📏 Size: 12.3 GiB 📏 Size: 12.3 GiB
⏱️ Duration: 00:04:21 ⏱️ Duration: 00:04:21
🗄️ Storage: vm/100/2026-03-17T22:00:08Z
🏷️ CT db (ID: 101) 🏷️ CT db (ID: 101)
✅ Status: ok ✅ Status: ok
📏 Size: 4.1 GiB 📏 Size: 4.1 GiB
⏱️ Duration: 00:01:10 ⏱️ Duration: 00:01:10
🗄️ Storage: ct/101/2026-03-17T22:04:29Z
📊 Total: 2 backups | 16.4 GiB | ⏱️ 00:05:31 📊 Total: 2 backups | 16.4 GiB | ⏱️ 00:05:31
EXAMPLE — backup partially failed (some ok, some failed):
[TITLE]
💾❌ pve01: Backup partially failed
[BODY]
Backup job finished with errors on storage PBS2.
🏷️ VM web01 (ID: 100)
✅ Status: ok
📏 Size: 12.3 GiB
⏱️ Duration: 00:04:21
🗄️ Storage: vm/100/2026-03-17T22:00:08Z
🏷️ VM broken (ID: 102)
❌ Status: error
📏 Size: 0 B
⏱️ Duration: 00:00:37
📊 Total: 2 backups | ❌ 1 failed | 12.3 GiB | ⏱️ 00:04:58
EXAMPLE — disk I/O health warning: EXAMPLE — disk I/O health warning:
[TITLE] [TITLE]
💥 amd: Health warning — Disk I/O errors 💥 amd: Health warning — Disk I/O errors
@@ -1493,11 +1528,20 @@ class AIEnhancer:
from ai_providers import get_provider from ai_providers import get_provider
provider_name = self.config.get('ai_provider', 'groq') provider_name = self.config.get('ai_provider', 'groq')
# Determine base_url based on provider
if provider_name == 'ollama':
base_url = self.config.get('ai_ollama_url', '')
elif provider_name == 'openai':
base_url = self.config.get('ai_openai_base_url', '')
else:
base_url = ''
self._provider = get_provider( self._provider = get_provider(
provider_name, provider_name,
api_key=self.config.get('ai_api_key', ''), api_key=self.config.get('ai_api_key', ''),
model=self.config.get('ai_model', ''), model=self.config.get('ai_model', ''),
base_url=self.config.get('ai_ollama_url', ''), base_url=base_url,
) )
except Exception as e: except Exception as e:
print(f"[AIEnhancer] Failed to initialize provider: {e}") print(f"[AIEnhancer] Failed to initialize provider: {e}")