feat: implement AI integration with backend configuration and rule-based enrichment actions
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 47s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 1m19s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 3h11m47s

This commit is contained in:
2026-08-26 22:42:23 -07:00
parent 9bc3423009
commit dfbf646332
19 changed files with 2312 additions and 114 deletions
+206 -10
View File
@@ -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)
}
+5
View File
@@ -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)