Update notification service

This commit is contained in:
MacRimi
2026-03-17 14:07:47 +01:00
parent 9a51a9e635
commit 0cb8900374
14 changed files with 1390 additions and 112 deletions

View File

@@ -8,6 +8,7 @@ import { Label } from "./ui/label"
import { Badge } from "./ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
import { fetchApi } from "../lib/api-config"
import {
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
@@ -57,6 +58,9 @@ interface NotificationConfig {
ai_provider: string
ai_api_key: string
ai_model: string
ai_language: string
ai_ollama_url: string
channel_ai_detail: Record<string, string>
hostname: string
webhook_secret: string
webhook_allowed_ips: string
@@ -102,8 +106,63 @@ const EVENT_CATEGORIES = [
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email"] as const
const AI_PROVIDERS = [
{ value: "openai", label: "OpenAI" },
{ value: "groq", label: "Groq" },
{
value: "groq",
label: "Groq",
model: "llama-3.3-70b-versatile",
description: "Very fast, generous free tier (30 req/min). Ideal to start."
},
{
value: "openai",
label: "OpenAI",
model: "gpt-4o-mini",
description: "Industry standard. Very accurate and widely used."
},
{
value: "anthropic",
label: "Anthropic (Claude)",
model: "claude-3-haiku-20240307",
description: "Excellent for writing and translation. Fast and economical."
},
{
value: "gemini",
label: "Google Gemini",
model: "gemini-1.5-flash",
description: "Free tier available, great quality/price ratio."
},
{
value: "ollama",
label: "Ollama (Local)",
model: "llama3.2",
description: "100% local execution. No costs, total privacy, no internet required."
},
{
value: "openrouter",
label: "OpenRouter",
model: "meta-llama/llama-3.3-70b-instruct",
description: "Aggregator with access to 100+ models using a single API key. Maximum flexibility."
},
]
const AI_LANGUAGES = [
{ value: "en", label: "English" },
{ value: "es", label: "Espanol" },
{ value: "fr", label: "Francais" },
{ value: "de", label: "Deutsch" },
{ value: "pt", label: "Portugues" },
{ value: "it", label: "Italiano" },
{ value: "ru", label: "Russkiy" },
{ value: "sv", label: "Svenska" },
{ value: "no", label: "Norsk" },
{ value: "ja", label: "Nihongo" },
{ value: "zh", label: "Zhongwen" },
{ value: "nl", label: "Nederlands" },
]
const AI_DETAIL_LEVELS = [
{ value: "brief", label: "Brief", desc: "2-3 lines, essential only" },
{ value: "standard", label: "Standard", desc: "Concise with basic context" },
{ value: "detailed", label: "Detailed", desc: "Complete technical details" },
]
const DEFAULT_CONFIG: NotificationConfig = {
@@ -128,9 +187,17 @@ const DEFAULT_CONFIG: NotificationConfig = {
email: { categories: {}, events: {} },
},
ai_enabled: false,
ai_provider: "openai",
ai_provider: "groq",
ai_api_key: "",
ai_model: "",
ai_language: "en",
ai_ollama_url: "http://localhost:11434",
channel_ai_detail: {
telegram: "brief",
gotify: "brief",
discord: "brief",
email: "detailed",
},
hostname: "",
webhook_secret: "",
webhook_allowed_ips: "",
@@ -155,6 +222,9 @@ export function NotificationSettings() {
const [hasChanges, setHasChanges] = useState(false)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [originalConfig, setOriginalConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
const [showProviderInfo, setShowProviderInfo] = useState(false)
const [testingAI, setTestingAI] = useState(false)
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
const [webhookSetup, setWebhookSetup] = useState<{
status: "idle" | "running" | "success" | "failed"
fallback_commands: string[]
@@ -373,6 +443,8 @@ export function NotificationSettings() {
ai_provider: cfg.ai_provider,
ai_api_key: cfg.ai_api_key,
ai_model: cfg.ai_model,
ai_language: cfg.ai_language,
ai_ollama_url: cfg.ai_ollama_url,
hostname: cfg.hostname,
webhook_secret: cfg.webhook_secret,
webhook_allowed_ips: cfg.webhook_allowed_ips,
@@ -402,6 +474,12 @@ export function NotificationSettings() {
}
}
}
// Per-channel AI detail level
if (cfg.channel_ai_detail) {
for (const [chName, level] of Object.entries(cfg.channel_ai_detail)) {
flat[`${chName}.ai_detail_level`] = level
}
}
return flat
}
@@ -493,6 +571,28 @@ export function NotificationSettings() {
}
}
const handleTestAI = async () => {
setTestingAI(true)
setAiTestResult(null)
try {
const data = await fetchApi<{ success: boolean; message: string; model: string }>("/api/notifications/test-ai", {
method: "POST",
body: JSON.stringify({
provider: config.ai_provider,
api_key: config.ai_api_key,
model: config.ai_model,
ollama_url: config.ai_ollama_url,
}),
})
setAiTestResult(data)
} catch (err) {
setAiTestResult({ success: false, message: String(err) })
} finally {
setTestingAI(false)
setTimeout(() => setAiTestResult(null), 8000)
}
}
const handleClearHistory = async () => {
try {
await fetchApi("/api/notifications/history", { method: "DELETE" })
@@ -1228,8 +1328,17 @@ export function NotificationSettings() {
{config.ai_enabled && (
<>
{/* Provider + Info button */}
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Provider</Label>
<div className="flex items-center gap-2">
<Label className="text-[11px] text-muted-foreground">Provider</Label>
<button
onClick={() => setShowProviderInfo(true)}
className="text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
>
+info
</button>
</div>
<Select
value={config.ai_provider}
onValueChange={v => updateConfig(p => ({ ...p, ai_provider: v }))}
@@ -1245,39 +1354,143 @@ export function NotificationSettings() {
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">API Key</Label>
<div className="flex items-center gap-1.5">
{/* Ollama URL (conditional) */}
{config.ai_provider === "ollama" && (
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Ollama URL</Label>
<Input
type={showSecrets["ai_key"] ? "text" : "password"}
className="h-7 text-xs font-mono"
placeholder="sk-..."
value={config.ai_api_key}
onChange={e => updateConfig(p => ({ ...p, ai_api_key: e.target.value }))}
placeholder="http://localhost:11434"
value={config.ai_ollama_url}
onChange={e => updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))}
disabled={!editMode}
/>
<button
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
onClick={() => toggleSecret("ai_key")}
>
{showSecrets["ai_key"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
)}
{/* API Key (not shown for Ollama) */}
{config.ai_provider !== "ollama" && (
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">API Key</Label>
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["ai_key"] ? "text" : "password"}
className="h-7 text-xs font-mono"
placeholder="sk-..."
value={config.ai_api_key}
onChange={e => updateConfig(p => ({ ...p, ai_api_key: e.target.value }))}
disabled={!editMode}
/>
<button
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
onClick={() => toggleSecret("ai_key")}
>
{showSecrets["ai_key"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
)}
{/* Model (optional) */}
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Model (optional)</Label>
<Input
className="h-7 text-xs font-mono"
placeholder={config.ai_provider === "openai" ? "gpt-4o-mini" : "llama-3.3-70b-versatile"}
placeholder={AI_PROVIDERS.find(p => p.value === config.ai_provider)?.model || ""}
value={config.ai_model}
onChange={e => updateConfig(p => ({ ...p, ai_model: e.target.value }))}
disabled={!editMode}
/>
</div>
{/* Language selector */}
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Language</Label>
<Select
value={config.ai_language}
onValueChange={v => updateConfig(p => ({ ...p, ai_language: v }))}
disabled={!editMode}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_LANGUAGES.map(l => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Test Connection button */}
<button
onClick={handleTestAI}
disabled={!editMode || testingAI || (config.ai_provider !== "ollama" && !config.ai_api_key)}
className="w-full h-7 flex items-center justify-center gap-1.5 rounded-md text-xs font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testingAI ? (
<><Loader2 className="h-3 w-3 animate-spin" /> Testing...</>
) : (
<><Zap className="h-3 w-3" /> Test Connection</>
)}
</button>
{/* Test result */}
{aiTestResult && (
<div className={`flex items-start gap-2 p-2 rounded-md ${
aiTestResult.success
? "bg-green-500/10 border border-green-500/20"
: "bg-red-500/10 border border-red-500/20"
}`}>
{aiTestResult.success
? <CheckCircle2 className="h-3.5 w-3.5 text-green-400 shrink-0 mt-0.5" />
: <XCircle className="h-3.5 w-3.5 text-red-400 shrink-0 mt-0.5" />
}
<p className={`text-[10px] leading-relaxed ${
aiTestResult.success ? "text-green-400/90" : "text-red-400/90"
}`}>
{aiTestResult.message}
{aiTestResult.model && ` (${aiTestResult.model})`}
</p>
</div>
)}
{/* Per-channel detail level */}
<div className="space-y-2 pt-2 border-t border-border/50">
<Label className="text-[11px] text-muted-foreground">Detail Level per Channel</Label>
<div className="grid grid-cols-2 gap-2">
{CHANNEL_TYPES.map(ch => (
<div key={ch} className="flex items-center justify-between gap-2 px-2 py-1 rounded bg-muted/30">
<span className="text-[10px] text-muted-foreground capitalize">{ch}</span>
<Select
value={config.channel_ai_detail?.[ch] || "standard"}
onValueChange={v => updateConfig(p => ({
...p,
channel_ai_detail: { ...p.channel_ai_detail, [ch]: v }
}))}
disabled={!editMode}
>
<SelectTrigger className="h-5 w-[80px] text-[10px] px-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_DETAIL_LEVELS.map(l => (
<SelectItem key={l.value} value={l.value} className="text-[10px]">
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-purple-500/10 border border-purple-500/20">
<Info className="h-3.5 w-3.5 text-purple-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-purple-400/90 leading-relaxed">
AI enhancement is optional. When enabled, notifications include contextual analysis and recommended actions. If the AI service is unavailable, standard templates are used as fallback.
AI enhancement translates and formats notifications to your selected language. Each channel can have different detail levels. If the AI service is unavailable, standard templates are used as fallback.
</p>
</div>
</>
@@ -1301,5 +1514,35 @@ export function NotificationSettings() {
</div>
</CardContent>
</Card>
{/* AI Provider Information Modal */}
<Dialog open={showProviderInfo} onOpenChange={setShowProviderInfo}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-base">AI Providers Information</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
{AI_PROVIDERS.map(provider => (
<div
key={provider.value}
className="p-3 rounded-lg bg-muted/50 border border-border hover:border-muted-foreground/40 transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{provider.label}</span>
{provider.value === "ollama" && (
<Badge variant="outline" className="text-[9px] px-1.5 py-0">Local</Badge>
)}
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Default model: <code className="text-[10px] bg-muted px-1 py-0.5 rounded font-mono">{provider.model}</code>
</div>
<p className="text-[11px] text-muted-foreground mt-2 leading-relaxed">
{provider.description}
</p>
</div>
))}
</div>
</DialogContent>
</Dialog>
)
}