feat: implement AI integration with backend configuration and rule-based enrichment actions
This commit is contained in:
+206
-10
@@ -9,8 +9,10 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||
@@ -23,20 +25,25 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *db.DB
|
||||
auth *auth.AuthService
|
||||
crawler *crawler.Crawler
|
||||
parser *parser.Parser
|
||||
pool *worker.Pool
|
||||
db *db.DB
|
||||
auth *auth.AuthService
|
||||
crawler *crawler.Crawler
|
||||
parser *parser.Parser
|
||||
pool *worker.Pool
|
||||
aiClient *ai.Client
|
||||
testRateMu sync.Mutex
|
||||
testPings []time.Time
|
||||
}
|
||||
|
||||
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler, pool *worker.Pool) *Handler {
|
||||
return &Handler{
|
||||
db: database,
|
||||
auth: authService,
|
||||
crawler: cr,
|
||||
parser: parser.NewParser(),
|
||||
pool: pool,
|
||||
db: database,
|
||||
auth: authService,
|
||||
crawler: cr,
|
||||
parser: parser.NewParser(),
|
||||
pool: pool,
|
||||
aiClient: ai.NewClient(30 * time.Second),
|
||||
testPings: []time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,3 +778,192 @@ func (h *Handler) ApplyRule(w http.ResponseWriter, r *http.Request) {
|
||||
"queued_jobs": queuedJobs,
|
||||
})
|
||||
}
|
||||
|
||||
// AI Settings Handlers
|
||||
type aiSettingsResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Prompts []ai.PromptTemplate `json:"prompts"`
|
||||
}
|
||||
|
||||
func maskAPIKey(key string) string {
|
||||
if len(key) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(key) <= 6 {
|
||||
return "••••••"
|
||||
}
|
||||
return key[:3] + "••••••••" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
func (h *Handler) GetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := h.db.GetSettingsMap()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to load settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
enabled := settings["ai_enabled"] == "true" || settings["ai_enabled"] == "1"
|
||||
provider := settings["ai_provider"]
|
||||
if provider == "" {
|
||||
provider = "openai"
|
||||
}
|
||||
baseURL := settings["ai_base_url"]
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
model := settings["ai_model"]
|
||||
if model == "" {
|
||||
model = "gpt-4o-mini"
|
||||
}
|
||||
temp, _ := strconv.ParseFloat(settings["ai_temperature"], 64)
|
||||
if temp <= 0 {
|
||||
temp = 0.3
|
||||
}
|
||||
maxTokens, _ := strconv.Atoi(settings["ai_max_tokens"])
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 1000
|
||||
}
|
||||
|
||||
var prompts []ai.PromptTemplate
|
||||
if promptsJSON := settings["ai_prompts"]; promptsJSON != "" {
|
||||
_ = json.Unmarshal([]byte(promptsJSON), &prompts)
|
||||
}
|
||||
if len(prompts) == 0 {
|
||||
prompts = ai.DefaultPromptTemplates()
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, aiSettingsResponse{
|
||||
Enabled: enabled,
|
||||
Provider: provider,
|
||||
BaseURL: baseURL,
|
||||
APIKey: maskAPIKey(settings["ai_api_key"]),
|
||||
Model: model,
|
||||
Temperature: temp,
|
||||
MaxTokens: maxTokens,
|
||||
Prompts: prompts,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Prompts []ai.PromptTemplate `json:"prompts"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid JSON request")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
val := "false"
|
||||
if *req.Enabled {
|
||||
val = "true"
|
||||
}
|
||||
_ = h.db.SetSetting("ai_enabled", val)
|
||||
}
|
||||
if req.Provider != "" {
|
||||
_ = h.db.SetSetting("ai_provider", req.Provider)
|
||||
}
|
||||
if req.BaseURL != "" {
|
||||
_ = h.db.SetSetting("ai_base_url", req.BaseURL)
|
||||
}
|
||||
if req.Model != "" {
|
||||
_ = h.db.SetSetting("ai_model", req.Model)
|
||||
}
|
||||
if req.Temperature > 0 {
|
||||
_ = h.db.SetSetting("ai_temperature", fmt.Sprintf("%.2f", req.Temperature))
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
_ = h.db.SetSetting("ai_max_tokens", strconv.Itoa(req.MaxTokens))
|
||||
}
|
||||
|
||||
// Update API key only if not masked with bullets
|
||||
if req.APIKey != "" && !strings.Contains(req.APIKey, "••••") {
|
||||
_ = h.db.SetSetting("ai_api_key", strings.TrimSpace(req.APIKey))
|
||||
}
|
||||
|
||||
if len(req.Prompts) > 0 {
|
||||
promptsBytes, _ := json.Marshal(req.Prompts)
|
||||
_ = h.db.SetSetting("ai_prompts", string(promptsBytes))
|
||||
}
|
||||
|
||||
h.GetAISettings(w, r)
|
||||
}
|
||||
|
||||
// TestAIConnection checks LLM connectivity with strict rate limiting & token caps
|
||||
func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) {
|
||||
// Rate limiting: Max 5 test pings per minute to prevent token exhaustion
|
||||
h.testRateMu.Lock()
|
||||
now := time.Now()
|
||||
var recentPings []time.Time
|
||||
for _, t := range h.testPings {
|
||||
if now.Sub(t) < time.Minute {
|
||||
recentPings = append(recentPings, t)
|
||||
}
|
||||
}
|
||||
if len(recentPings) >= 5 {
|
||||
h.testRateMu.Unlock()
|
||||
h.writeError(w, http.StatusTooManyRequests, "Rate limit exceeded. Maximum 5 connection tests per minute.")
|
||||
return
|
||||
}
|
||||
h.testPings = append(recentPings, now)
|
||||
h.testRateMu.Unlock()
|
||||
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
settings, _ := h.db.GetSettingsMap()
|
||||
|
||||
apiKey := req.APIKey
|
||||
if apiKey == "" || strings.Contains(apiKey, "••••") {
|
||||
apiKey = settings["ai_api_key"]
|
||||
}
|
||||
baseURL := req.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = settings["ai_base_url"]
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = settings["ai_model"]
|
||||
}
|
||||
if model == "" {
|
||||
model = "gpt-4o-mini"
|
||||
}
|
||||
|
||||
cfg := ai.Config{
|
||||
Enabled: true,
|
||||
Provider: req.Provider,
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
Model: model,
|
||||
}
|
||||
|
||||
res, err := h.aiClient.TestConnection(r.Context(), cfg)
|
||||
if err != nil {
|
||||
h.writeJSON(w, http.StatusOK, res)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,11 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
||||
r.Delete("/rules/{id}", h.DeleteRule)
|
||||
r.Post("/rules/{id}/apply", h.ApplyRule)
|
||||
|
||||
// AI Settings
|
||||
r.Get("/settings/ai", h.GetAISettings)
|
||||
r.Put("/settings/ai", h.UpdateAISettings)
|
||||
r.Post("/settings/ai/test", h.TestAIConnection)
|
||||
|
||||
// OPML
|
||||
r.Post("/opml/import", h.ImportOPML)
|
||||
r.Get("/opml/export", h.ExportOPML)
|
||||
|
||||
Reference in New Issue
Block a user