mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-17 17:42:19 +00:00
Update notification service
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user