From dfbf646332092dd4360984c4f4400ebcaba61e79 Mon Sep 17 00:00:00 2001 From: haopengzhan Date: Wed, 26 Aug 2026 22:42:23 -0700 Subject: [PATCH] feat: implement AI integration with backend configuration and rule-based enrichment actions --- cmd/server/main.go | 70 +- docs/003_backlog.md | 10 + docs/006_ai_rules_and_autonomous_triage.md | 140 ++++ internal/actions/ai/enrich.go | 98 +++ internal/actions/ai/triage.go | 88 +++ internal/actions/register.go | 36 + internal/actions/status/status.go | 28 + internal/actions/variants/set_variant.go | 60 ++ internal/ai/client.go | 356 ++++++++++ internal/ai/client_test.go | 212 ++++++ internal/api/handlers.go | 216 +++++- internal/api/server.go | 5 + internal/db/db.go | 49 ++ web/src/components/SettingsView.tsx | 733 ++++++++++++++++++++- web/src/components/ThreePaneView.tsx | 112 +++- web/src/components/WaterfallView.tsx | 117 +++- web/src/services/api.ts | 20 + web/src/types.ts | 26 + web/src/utils.ts | 50 ++ 19 files changed, 2312 insertions(+), 114 deletions(-) create mode 100644 docs/006_ai_rules_and_autonomous_triage.md create mode 100644 internal/actions/ai/enrich.go create mode 100644 internal/actions/ai/triage.go create mode 100644 internal/actions/register.go create mode 100644 internal/actions/status/status.go create mode 100644 internal/actions/variants/set_variant.go create mode 100644 internal/ai/client.go create mode 100644 internal/ai/client_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 96856e4..9d6af85 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,11 +5,10 @@ import ( "net/http" "os" "os/signal" - "strings" "syscall" "time" - "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/readability" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions" "git.pengzhan.dev/haopengzhan/rsspipe/internal/api" "git.pengzhan.dev/haopengzhan/rsspipe/internal/auth" "git.pengzhan.dev/haopengzhan/rsspipe/internal/config" @@ -17,7 +16,6 @@ import ( "git.pengzhan.dev/haopengzhan/rsspipe/internal/db" "git.pengzhan.dev/haopengzhan/rsspipe/internal/events" "git.pengzhan.dev/haopengzhan/rsspipe/internal/logger" - "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" "git.pengzhan.dev/haopengzhan/rsspipe/internal/rules" "git.pengzhan.dev/haopengzhan/rsspipe/internal/worker" "go.uber.org/zap" @@ -52,70 +50,8 @@ func main() { bus := events.NewEventBus() pool := worker.NewPool(database, bus, 5, 1000) - // Register Core Action Handlers - scraper := readability.NewScraper(15 * time.Second) - pool.RegisterHandler("readability_scrape", scraper.Handler()) - - pool.RegisterHandler("set_variant", func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) { - key, _ := params["variant_key"].(string) - if key == "" { - key, _ = params["key"].(string) - } - if key == "" { - return nil, nil - } - val, _ := params["value"].(string) - contentType, _ := params["content_type"].(string) - if contentType == "" { - contentType = "text" - } - - // If key == "tags", merge with existing tags so multiple rules append tags - if key == "tags" { - existingVariant, err := database.GetVariant(entry.ID, "tags") - if err == nil && existingVariant != nil && existingVariant.TextValue != "" { - existingTags := strings.Split(existingVariant.TextValue, ",") - newTags := strings.Split(val, ",") - tagSet := make(map[string]bool) - var merged []string - for _, t := range append(existingTags, newTags...) { - trimmed := strings.TrimSpace(t) - if trimmed != "" && !tagSet[strings.ToLower(trimmed)] { - tagSet[strings.ToLower(trimmed)] = true - merged = append(merged, trimmed) - } - } - val = strings.Join(merged, ", ") - } - } - - variant := models.EntryVariant{ - EntryID: entry.ID, - VariantKey: key, - ContentType: contentType, - TextValue: val, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - return &worker.ActionResult{ - ResultVariant: &variant, - }, nil - }) - - pool.RegisterHandler("mark_read", func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) { - trueVal := true - return &worker.ActionResult{ - MarkRead: &trueVal, - }, nil - }) - - pool.RegisterHandler("mark_starred", func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) { - trueVal := true - return &worker.ActionResult{ - MarkStarred: &trueVal, - }, nil - }) - + // Register Core Action Handlers (readability, status, variants, AI enrich & triage) + actions.RegisterDefaultHandlers(pool, database, log) pool.Start(ctx) // 4. Initialize Rules Engine diff --git a/docs/003_backlog.md b/docs/003_backlog.md index 5a36115..d55a7bb 100644 --- a/docs/003_backlog.md +++ b/docs/003_backlog.md @@ -132,3 +132,13 @@ This document records planned features, UX enhancements, and architectural exten - Retention triggers (e.g. delete unstarred articles older than 90 days, or limit feeds to latest 500 posts). - Background garbage collection cleaning associated `entry_variants` and on-disk audio blobs (`./data/blobs/{entry_id}/`). +--- + +## 14. Outbound Webhook Dispatcher (P4) + +- **Goal**: Allow rule-triggered notifications and payload delivery to external platforms. +- **Scope**: + - Outbound webhook action in rule engine supporting Telegram, Discord, Slack, and generic JSON HTTP POST. + - Template engine with interpolation of article variables (`title`, `feed_title`, `url`, `published_at`, `ai_summary`, `tags`). + - Retry mechanism with exponential backoff on delivery failure. + diff --git a/docs/006_ai_rules_and_autonomous_triage.md b/docs/006_ai_rules_and_autonomous_triage.md new file mode 100644 index 0000000..87c5fbb --- /dev/null +++ b/docs/006_ai_rules_and_autonomous_triage.md @@ -0,0 +1,140 @@ +# RSSPipe AI Rules & Autonomous Triage Specification (`006_ai_rules_and_autonomous_triage.md`) + +This document defines the architecture, data structures, security guardrails, and execution models for integrating AI capabilities into RSSPipe's event-driven rules engine. + +--- + +## 1. Core Principles + +1. **AI as Standard Rule Actions**: AI is not a separate silo; it is integrated directly as actions within the rule pipeline (`Trigger ➜ Conditions ➜ Actions`). +2. **Dual AI Modes**: + - **Mode A: Variant Enrichment (`ai_enrich`)**: Extracts structured summaries, key takeaways, sentiment, or translations into `entry_variants` (`variant_key: "ai_summary"`). + - **Mode B: Autonomous Triage Agent (`ai_agent_action`)**: Evaluates content and proposes actions restricted strictly to a user-defined allowed action pool (e.g., auto-archiving, starring, tagging). +3. **Flexible Prompt Hierarchy**: + - **Global Prompt Library**: Saved reusable templates configured in `Settings > AI & Integrations`. + - **Rule-Level Custom Prompts**: Individual rules can override or write feed-specific prompts (e.g. "Summarize for Kubernetes engineers" vs "Summarize for general news"). +4. **Security & Cost Guardrails**: + - Authentication-enforced settings and testing endpoints. + - Rate-limited and token-capped test pings (`max_tokens: 15`). + - Strict action validator discarding any hallucinated or unallowed agent actions. + +--- + +## 2. Architecture & Data Flow + +```mermaid +flowchart TD + subgraph TriggerLayer ["1. Trigger & Evaluation"] + Event[Event Ingested / Retroactive Apply] --> Evaluator{Conditions Match?} + end + + subgraph ActionExecution ["2. Action Routing & AI Dispatch"] + Evaluator -- Yes --> ActionRouter[Action Router] + ActionRouter --> StandardActions[Standard Actions: Star, Read, Scrape, Set Tag] + ActionRouter --> EnrichAction["AI Variant Enrichment (ai_enrich)"] + ActionRouter --> AgentAction["AI Autonomous Triage (ai_agent_action)"] + end + + subgraph AIClient ["3. LLM Gateway (internal/ai)"] + Settings[(system_settings Table)] --> Client[OpenAI-Compatible Client] + EnrichAction --> Client + AgentAction --> Client + Client --> RemoteLLM["LLM Provider (OpenAI, Gemini, DeepSeek, Ollama)"] + end + + subgraph PersistenceLayer ["4. Persistence & Chaining"] + EnrichAction --> VariantSave["Save to entry_variants (e.g. ai_summary)"] + VariantSave --> ChainedEvent["Publish EventVariantUpdated"] + AgentAction --> ActionValidator["Strict Action Pool Validator"] + ActionValidator -- Allowed Only --> DispatchPool["Worker Pool Dispatch"] + end +``` + +--- + +## 3. Configuration & Settings Schema + +### 3.1 Database: `system_settings` Table +```sql +CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL +); +``` + +### 3.2 Stored Settings Keys +| Key | Type | Description | Example | +|---|---|---|---| +| `ai_enabled` | `bool` | Master toggle for AI processing | `true` | +| `ai_provider` | `string` | Provider preset identifier | `openai`, `deepseek`, `gemini`, `ollama`, `custom` | +| `ai_base_url` | `string` | OpenAI-compatible endpoint URL | `https://api.openai.com/v1`, `http://localhost:11434/v1` | +| `ai_api_key` | `string` | Provider API Key (masked in GET responses) | `sk-...` | +| `ai_model` | `string` | Model identifier | `gpt-4o-mini`, `deepseek-chat`, `gemini-2.0-flash`, `llama3.2` | +| `ai_temperature` | `float` | Sampling temperature | `0.3` | +| `ai_max_tokens` | `int` | Maximum response tokens | `1000` | +| `ai_prompts` | `json` | Array of saved prompt template objects | `[{"id": "tech_brief", "name": "Tech Brief", "prompt": "..."}]` | + +--- + +## 4. AI Action Specifications + +### 4.1 Action `ai_enrich` (Variant Enrichment) +* **Goal**: Generate text/markdown and save as an `EntryVariant`. +* **Parameters**: + ```json + { + "type": "ai_enrich", + "params": { + "variant_key": "ai_summary", + "content_type": "text", + "prompt": "You are an expert technical editor. Summarize the following article in 3 concise bullet points followed by 1 key takeaway.\n\nTitle: {{title}}\nContent:\n{{content}}" + } + } + ``` +* **Variables Available for Prompt Interpolation**: + - `{{title}}`: Article headline + - `{{content}}`: Article body (uses full-text / clean RSS content) + - `{{feed_title}}`: Source feed name + - `{{author}}`: Article author + - `{{url}}`: Original article URL + - `{{published_at}}`: Publication date + +### 4.2 Action `ai_agent_action` (Autonomous Triage) +* **Goal**: Analyze the article against a policy and execute allowed actions automatically. +* **Parameters**: + ```json + { + "type": "ai_agent_action", + "params": { + "prompt": "Evaluate this engineering post. If it contains a critical zero-day or high-impact CVE, tag as 'security-urgent' and star it. If it is promotional marketing fluff, mark as read.", + "allowed_actions": [ + "mark_read", + "mark_starred", + "set_variant:tags:security-urgent", + "set_variant:tags:newsletter" + ] + } + } + ``` +* **Enforced Output Schema**: + ```json + { + "reasoning": "Post discusses a critical remote execution CVE in Kubernetes ingress controllers.", + "actions": [ + { "type": "mark_starred" }, + { "type": "set_variant", "params": { "variant_key": "tags", "value": "security-urgent" } } + ] + } + ``` +* **Security Validation**: The backend strictly matches proposed actions against `allowed_actions`. Any unallowed action is logged as a rejection and discarded. + +--- + +## 5. Security & DDoS Protection + +1. **Authentication Requirement**: All AI configuration and testing endpoints (`/api/settings/ai`, `/api/settings/ai/test`) require valid JWT bearer tokens with administrative privileges. +2. **Test Endpoint Token Cap & Rate Limiting**: + - `POST /api/settings/ai/test` forces `max_tokens: 15` and a lightweight prompt ("Reply with OK in 2 words") to prevent token exhaustion. + - Enforced rate limit: Maximum 5 connection tests per minute per IP/user session. +3. **API Key Masking**: The `GET /api/settings/ai` endpoint never returns full API keys in plaintext (returns `sk-••••••••abcd`). diff --git a/internal/actions/ai/enrich.go b/internal/actions/ai/enrich.go new file mode 100644 index 0000000..64fd9ac --- /dev/null +++ b/internal/actions/ai/enrich.go @@ -0,0 +1,98 @@ +package ai + +import ( + "context" + "time" + + "git.pengzhan.dev/haopengzhan/rsspipe/internal/ai" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/db" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/worker" + "go.uber.org/zap" +) + +// LoadConfig fetches current AI provider configuration from system settings. +func LoadConfig(database *db.DB) ai.Config { + settings, _ := database.GetSettingsMap() + 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" + } + return ai.Config{ + Enabled: enabled, + Provider: provider, + BaseURL: baseURL, + APIKey: settings["ai_api_key"], + Model: model, + } +} + +// NewEnrichmentHandler creates a worker action handler for AI variant enrichment. +func NewEnrichmentHandler(database *db.DB, client *ai.Client, log *zap.Logger) worker.ActionHandler { + return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) { + cfg := LoadConfig(database) + if cfg.APIKey == "" && cfg.Provider != "ollama" { + log.Warn("AI action skipped: API key is not configured in Settings > AI & Integrations", + zap.String("entry_id", entry.ID), + zap.String("provider", cfg.Provider), + ) + return nil, nil + } + + prompt, _ := params["prompt"].(string) + if prompt == "" { + prompt, _ = params["custom_prompt"].(string) + } + vKey, _ := params["variant_key"].(string) + if vKey == "" { + vKey = "ai_summary" + } + cType, _ := params["content_type"].(string) + if cType == "" { + cType = "text" + } + + resultText, err := client.GenerateEnrichment(ctx, cfg, prompt, entry) + if err != nil { + log.Error("AI enrichment generation failed", + zap.String("entry_id", entry.ID), + zap.String("model", cfg.Model), + zap.Error(err), + ) + return nil, err + } + + variant := models.EntryVariant{ + EntryID: entry.ID, + VariantKey: vKey, + ContentType: cType, + TextValue: resultText, + Metadata: map[string]interface{}{ + "model": cfg.Model, + "provider": cfg.Provider, + "prompt_used": prompt, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + log.Info("AI enrichment completed", + zap.String("entry_id", entry.ID), + zap.String("variant_key", vKey), + zap.String("model", cfg.Model), + ) + + return &worker.ActionResult{ + ResultVariant: &variant, + }, nil + } +} diff --git a/internal/actions/ai/triage.go b/internal/actions/ai/triage.go new file mode 100644 index 0000000..51f12b5 --- /dev/null +++ b/internal/actions/ai/triage.go @@ -0,0 +1,88 @@ +package ai + +import ( + "context" + "fmt" + "time" + + "git.pengzhan.dev/haopengzhan/rsspipe/internal/ai" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/db" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/worker" + "go.uber.org/zap" +) + +// NewTriageHandler creates a worker action handler for AI autonomous triage decisions. +func NewTriageHandler(database *db.DB, client *ai.Client, pool *worker.Pool, log *zap.Logger) worker.ActionHandler { + return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) { + cfg := LoadConfig(database) + if cfg.APIKey == "" && cfg.Provider != "ollama" { + log.Warn("AI triage action skipped: API key is not configured in Settings > AI & Integrations", + zap.String("entry_id", entry.ID), + ) + return nil, nil + } + + policyPrompt, _ := params["prompt"].(string) + var allowedActions []string + if rawAllowed, ok := params["allowed_actions"].([]interface{}); ok { + for _, a := range rawAllowed { + allowedActions = append(allowedActions, fmt.Sprintf("%v", a)) + } + } else if strList, ok := params["allowed_actions"].([]string); ok { + allowedActions = strList + } + + if len(allowedActions) == 0 { + allowedActions = []string{"mark_read", "mark_starred", "set_variant"} + } + + decision, err := client.GenerateAgentDecision(ctx, cfg, policyPrompt, allowedActions, entry) + if err != nil { + log.Error("AI triage decision failed", + zap.String("entry_id", entry.ID), + zap.Error(err), + ) + return nil, err + } + + log.Info("AI Triage agent executed successfully", + zap.String("entry_id", entry.ID), + zap.String("reasoning", decision.Reasoning), + zap.Int("actions_approved", len(decision.Actions)), + ) + + res := &worker.ActionResult{} + for _, act := range decision.Actions { + switch act.Type { + case "mark_read": + trueVal := true + res.MarkRead = &trueVal + case "mark_starred": + trueVal := true + res.MarkStarred = &trueVal + case "set_variant": + vKey, _ := act.Params["variant_key"].(string) + if vKey == "" { + vKey, _ = act.Params["key"].(string) + } + vVal, _ := act.Params["value"].(string) + if vKey != "" { + v := models.EntryVariant{ + EntryID: entry.ID, + VariantKey: vKey, + ContentType: "text", + TextValue: vVal, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + res.ResultVariant = &v + } + default: + pool.Dispatch(ctx, act, entry, 0) + } + } + + return res, nil + } +} diff --git a/internal/actions/register.go b/internal/actions/register.go new file mode 100644 index 0000000..c43a694 --- /dev/null +++ b/internal/actions/register.go @@ -0,0 +1,36 @@ +package actions + +import ( + "time" + + "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/readability" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/status" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/variants" + aiClientPkg "git.pengzhan.dev/haopengzhan/rsspipe/internal/ai" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/db" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/worker" + "go.uber.org/zap" +) + +// RegisterDefaultHandlers registers all core action handlers with the worker pool. +func RegisterDefaultHandlers(pool *worker.Pool, database *db.DB, log *zap.Logger) { + // 1. Readability Full-Text Scraper + scraper := readability.NewScraper(15 * time.Second) + pool.RegisterHandler("readability_scrape", scraper.Handler()) + + // 2. Status Handlers (mark_read, mark_starred) + pool.RegisterHandler("mark_read", status.NewMarkReadHandler()) + pool.RegisterHandler("mark_starred", status.NewMarkStarredHandler()) + + // 3. Variant Handlers (set_variant with tag merging) + pool.RegisterHandler("set_variant", variants.NewSetVariantHandler(database)) + + // 4. AI Subsystem Handlers + aiClient := aiClientPkg.NewClient(45 * time.Second) + enrichHandler := ai.NewEnrichmentHandler(database, aiClient, log) + pool.RegisterHandler("ai_enrich", enrichHandler) + pool.RegisterHandler("ai_summary", enrichHandler) // Backward compatibility alias + + pool.RegisterHandler("ai_agent_action", ai.NewTriageHandler(database, aiClient, pool, log)) +} diff --git a/internal/actions/status/status.go b/internal/actions/status/status.go new file mode 100644 index 0000000..31978ea --- /dev/null +++ b/internal/actions/status/status.go @@ -0,0 +1,28 @@ +package status + +import ( + "context" + + "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/worker" +) + +// NewMarkReadHandler marks an entry as read. +func NewMarkReadHandler() worker.ActionHandler { + return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) { + trueVal := true + return &worker.ActionResult{ + MarkRead: &trueVal, + }, nil + } +} + +// NewMarkStarredHandler marks an entry as starred. +func NewMarkStarredHandler() worker.ActionHandler { + return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) { + trueVal := true + return &worker.ActionResult{ + MarkStarred: &trueVal, + }, nil + } +} diff --git a/internal/actions/variants/set_variant.go b/internal/actions/variants/set_variant.go new file mode 100644 index 0000000..657b700 --- /dev/null +++ b/internal/actions/variants/set_variant.go @@ -0,0 +1,60 @@ +package variants + +import ( + "context" + "strings" + "time" + + "git.pengzhan.dev/haopengzhan/rsspipe/internal/db" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/worker" +) + +// NewSetVariantHandler creates a worker action handler for attaching extensible variant metadata. +func NewSetVariantHandler(database *db.DB) worker.ActionHandler { + return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) { + key, _ := params["variant_key"].(string) + if key == "" { + key, _ = params["key"].(string) + } + if key == "" { + return nil, nil + } + val, _ := params["value"].(string) + contentType, _ := params["content_type"].(string) + if contentType == "" { + contentType = "text" + } + + // If key == "tags", merge with existing tags so multiple rules append tags + if key == "tags" { + existingVariant, err := database.GetVariant(entry.ID, "tags") + if err == nil && existingVariant != nil && existingVariant.TextValue != "" { + existingTags := strings.Split(existingVariant.TextValue, ",") + newTags := strings.Split(val, ",") + tagSet := make(map[string]bool) + var merged []string + for _, t := range append(existingTags, newTags...) { + trimmed := strings.TrimSpace(t) + if trimmed != "" && !tagSet[strings.ToLower(trimmed)] { + tagSet[strings.ToLower(trimmed)] = true + merged = append(merged, trimmed) + } + } + val = strings.Join(merged, ", ") + } + } + + variant := models.EntryVariant{ + EntryID: entry.ID, + VariantKey: key, + ContentType: contentType, + TextValue: val, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + return &worker.ActionResult{ + ResultVariant: &variant, + }, nil + } +} diff --git a/internal/ai/client.go b/internal/ai/client.go new file mode 100644 index 0000000..7329b28 --- /dev/null +++ b/internal/ai/client.go @@ -0,0 +1,356 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" +) + +type Config 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"` +} + +type PromptTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Prompt string `json:"prompt"` + IsDefault bool `json:"is_default,omitempty"` +} + +type TestResult struct { + Success bool `json:"success"` + Message string `json:"message"` + LatencyMs int64 `json:"latency_ms"` + Model string `json:"model"` + Duration time.Duration `json:"duration"` +} + +type AgentDecision struct { + Reasoning string `json:"reasoning"` + Actions []models.RuleAction `json:"actions"` +} + +type Client struct { + httpClient *http.Client +} + +func NewClient(timeout time.Duration) *Client { + if timeout <= 0 { + timeout = 45 * time.Second + } + return &Client{ + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +// DefaultPromptTemplates returns built-in prompt templates. +func DefaultPromptTemplates() []PromptTemplate { + return []PromptTemplate{ + { + ID: "exec_summary", + Name: "3-Bullet Executive Brief", + Description: "3 concise bullet points followed by a single key takeaway.", + Prompt: "You are an expert technical editor. Summarize the following article in exactly 3 concise bullet points, followed by a bold 'Key Takeaway:' sentence.\n\nTitle: {{title}}\nFeed: {{feed_title}}\nContent:\n{{content}}", + IsDefault: true, + }, + { + ID: "tech_deep_dive", + Name: "Technical Deep Dive", + Description: "Architecture, key technical concepts, and implementation highlights.", + Prompt: "Analyze the following technical post. Highlight:\n1. Core Architecture & Concepts\n2. Key Innovations / Techniques\n3. Practical Implications for Engineers\n\nTitle: {{title}}\nContent:\n{{content}}", + IsDefault: false, + }, + { + ID: "news_general", + Name: "General News & Context", + Description: "Accessible summary with necessary background context for general readers.", + Prompt: "Summarize this news article for a general audience. Explain what happened, why it matters, and provide any helpful context in 2-3 short paragraphs.\n\nTitle: {{title}}\nContent:\n{{content}}", + IsDefault: false, + }, + } +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatCompletionRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` +} + +type chatCompletionResponse struct { + Choices []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error,omitempty"` +} + +func (c *Client) callChatCompletion(ctx context.Context, cfg Config, req chatCompletionRequest) (string, error) { + baseURL := strings.TrimRight(cfg.BaseURL, "/") + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + endpoint := baseURL + "/chat/completions" + + bodyBytes, err := json.Marshal(req) + if err != nil { + return "", err + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return "", err + } + + httpReq.Header.Set("Content-Type", "application/json") + if cfg.APIKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("HTTP request failed: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var chatResp chatCompletionResponse + if err := json.Unmarshal(respBody, &chatResp); err != nil { + return "", fmt.Errorf("failed to decode response JSON: %w", err) + } + + if chatResp.Error != nil && chatResp.Error.Message != "" { + return "", fmt.Errorf("LLM error: %s", chatResp.Error.Message) + } + + if len(chatResp.Choices) == 0 { + return "", fmt.Errorf("LLM returned no choices") + } + + return chatResp.Choices[0].Message.Content, nil +} + +// TestConnection pings the LLM with a strictly capped request (max_tokens: 15) to verify credentials. +func (c *Client) TestConnection(ctx context.Context, cfg Config) (*TestResult, error) { + start := time.Now() + req := chatCompletionRequest{ + Model: cfg.Model, + Messages: []chatMessage{ + {Role: "user", Content: "Ping test. Reply with 'OK' in exactly two words."}, + }, + Temperature: 0.1, + MaxTokens: 15, // Strictly capped to prevent token exhaustion + } + + reply, err := c.callChatCompletion(ctx, cfg, req) + duration := time.Since(start) + + if err != nil { + return &TestResult{ + Success: false, + Message: err.Error(), + LatencyMs: duration.Milliseconds(), + Model: cfg.Model, + Duration: duration, + }, err + } + + return &TestResult{ + Success: true, + Message: strings.TrimSpace(reply), + LatencyMs: duration.Milliseconds(), + Model: cfg.Model, + Duration: duration, + }, nil +} + +// GenerateEnrichment interpolates the prompt template and queries the LLM to generate variant content. +func (c *Client) GenerateEnrichment(ctx context.Context, cfg Config, promptTemplate string, entry models.FeedEntry) (string, error) { + if promptTemplate == "" { + promptTemplate = DefaultPromptTemplates()[0].Prompt + } + + content := entry.Content + if entry.RawContent != "" && len(entry.RawContent) > len(content) { + content = entry.RawContent + } + if entry.Variants != nil { + if v, ok := entry.Variants["readability_full_text"]; ok && len(v.TextValue) > len(content) { + content = v.TextValue + } + } + + // Truncate content to avoid exceeding token windows (e.g. max ~24,000 characters) + if len(content) > 24000 { + content = content[:24000] + "... [truncated]" + } + + interpolated := promptTemplate + interpolated = strings.ReplaceAll(interpolated, "{{title}}", entry.Title) + interpolated = strings.ReplaceAll(interpolated, "{{content}}", content) + interpolated = strings.ReplaceAll(interpolated, "{{feed_title}}", entry.FeedTitle) + interpolated = strings.ReplaceAll(interpolated, "{{author}}", entry.Author) + interpolated = strings.ReplaceAll(interpolated, "{{url}}", entry.URL) + interpolated = strings.ReplaceAll(interpolated, "{{published_at}}", entry.PublishedAt.Format("2006-01-02 15:04:05")) + + maxTokens := cfg.MaxTokens + if maxTokens <= 0 { + maxTokens = 1000 + } + temp := cfg.Temperature + if temp <= 0 { + temp = 0.3 + } + + req := chatCompletionRequest{ + Model: cfg.Model, + Messages: []chatMessage{ + {Role: "user", Content: interpolated}, + }, + Temperature: temp, + MaxTokens: maxTokens, + } + + return c.callChatCompletion(ctx, cfg, req) +} + +// GenerateAgentDecision asks the LLM to evaluate the article and propose actions from allowedActions only. +func (c *Client) GenerateAgentDecision(ctx context.Context, cfg Config, policyPrompt string, allowedActions []string, entry models.FeedEntry) (*AgentDecision, error) { + allowedListStr := strings.Join(allowedActions, ", ") + + content := entry.Content + if entry.RawContent != "" && len(entry.RawContent) > len(content) { + content = entry.RawContent + } + if entry.Variants != nil { + if v, ok := entry.Variants["readability_full_text"]; ok && len(v.TextValue) > len(content) { + content = v.TextValue + } + } + if len(content) > 16000 { + content = content[:16000] + "... [truncated]" + } + + systemInstruction := fmt.Sprintf(`You are an autonomous RSS triage agent. +Your goal is to evaluate the provided article and propose actions strictly from the Allowed Action Pool. + +Allowed Action Pool: +%s + +Rules: +1. You may ONLY propose actions that exist in the Allowed Action Pool. +2. Output ONLY a valid JSON object matching this schema: +{ + "reasoning": "brief explanation of your decision", + "actions": [ + {"type": "action_type", "params": {"key": "value"}} + ] +} +If no action is warranted, return {"reasoning": "...", "actions": []}. +Do not wrap in markdown backticks or other text. Return raw JSON only.`, allowedListStr) + + userContent := fmt.Sprintf("Evaluation Policy:\n%s\n\nArticle Title: %s\nFeed: %s\nContent:\n%s", policyPrompt, entry.Title, entry.FeedTitle, content) + + req := chatCompletionRequest{ + Model: cfg.Model, + Messages: []chatMessage{ + {Role: "system", Content: systemInstruction}, + {Role: "user", Content: userContent}, + }, + Temperature: 0.1, + MaxTokens: 800, + } + + rawJSON, err := c.callChatCompletion(ctx, cfg, req) + if err != nil { + return nil, err + } + + // Clean code fence formatting if returned + rawJSON = strings.TrimSpace(rawJSON) + rawJSON = strings.TrimPrefix(rawJSON, "```json") + rawJSON = strings.TrimPrefix(rawJSON, "```") + rawJSON = strings.TrimSuffix(rawJSON, "```") + rawJSON = strings.TrimSpace(rawJSON) + + var parsed struct { + Reasoning string `json:"reasoning"` + Actions []struct { + Type string `json:"type"` + Params map[string]interface{} `json:"params,omitempty"` + } `json:"actions"` + } + + if err := json.Unmarshal([]byte(rawJSON), &parsed); err != nil { + return nil, fmt.Errorf("failed to parse AI decision JSON: %w (raw: %s)", err, rawJSON) + } + + // Strict Guardrail: Validate all actions against allowedActions + allowedSet := make(map[string]bool) + for _, a := range allowedActions { + allowedSet[strings.ToLower(strings.TrimSpace(a))] = true + } + + var validatedActions []models.RuleAction + for _, act := range parsed.Actions { + actTypeLower := strings.ToLower(strings.TrimSpace(act.Type)) + + // Check basic action type or composite key (e.g. set_variant:tags:xxx) + isAllowed := allowedSet[actTypeLower] + if !isAllowed && actTypeLower == "set_variant" && act.Params != nil { + vKey := fmt.Sprintf("%v", act.Params["variant_key"]) + vVal := fmt.Sprintf("%v", act.Params["value"]) + compositeKey := strings.ToLower(fmt.Sprintf("set_variant:%s:%s", vKey, vVal)) + isAllowed = allowedSet[compositeKey] || allowedSet["set_variant"] + } + + if isAllowed { + validatedActions = append(validatedActions, models.RuleAction{ + Type: act.Type, + Params: act.Params, + }) + } + } + + return &AgentDecision{ + Reasoning: parsed.Reasoning, + Actions: validatedActions, + }, nil +} diff --git a/internal/ai/client_test.go b/internal/ai/client_test.go new file mode 100644 index 0000000..aa50c02 --- /dev/null +++ b/internal/ai/client_test.go @@ -0,0 +1,212 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" +) + +type roundTripFunc func(req *http.Request) *http.Response + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req), nil +} + +func TestTestConnection(t *testing.T) { + client := &Client{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) *http.Response { + if req.Header.Get("Authorization") != "Bearer test-key" { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewBufferString(`{"error": {"message": "unauthorized"}}`)), + Header: make(http.Header), + } + } + + resp := chatCompletionResponse{ + Choices: []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + }{ + { + Message: struct { + Role string `json:"role"` + Content string `json:"content"` + }{ + Role: "assistant", + Content: "OK connection", + }, + FinishReason: "stop", + }, + }, + } + data, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + Header: make(http.Header), + } + }), + }, + } + + cfg := Config{ + Enabled: true, + Provider: "custom", + BaseURL: "https://mock.api", + APIKey: "test-key", + Model: "test-model", + } + + res, err := client.TestConnection(context.Background(), cfg) + if err != nil { + t.Fatalf("TestConnection failed: %v", err) + } + if !res.Success { + t.Fatalf("Expected success true, got false") + } + if res.Message != "OK connection" { + t.Fatalf("Expected 'OK connection', got %s", res.Message) + } +} + +func TestGenerateEnrichment(t *testing.T) { + client := &Client{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) *http.Response { + resp := chatCompletionResponse{ + Choices: []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + }{ + { + Message: struct { + Role string `json:"role"` + Content string `json:"content"` + }{ + Role: "assistant", + Content: "- Point 1\n- Point 2\n- Point 3\n\n**Key Takeaway**: Great post.", + }, + FinishReason: "stop", + }, + }, + } + data, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + Header: make(http.Header), + } + }), + }, + } + + cfg := Config{ + Enabled: true, + BaseURL: "https://mock.api", + Model: "gpt-4o-mini", + MaxTokens: 500, + } + + entry := models.FeedEntry{ + ID: "e-1", + Title: "Kubernetes & GPU Optimization", + FeedTitle: "Cloud Native Blog", + Content: "Detailed technical content about GPU sharing in Kubernetes.", + PublishedAt: time.Now(), + } + + summary, err := client.GenerateEnrichment(context.Background(), cfg, "Summarize: {{title}}\n{{content}}", entry) + if err != nil { + t.Fatalf("GenerateEnrichment failed: %v", err) + } + if summary == "" { + t.Fatalf("Expected summary string, got empty") + } +} + +func TestGenerateAgentDecision(t *testing.T) { + client := &Client{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) *http.Response { + resp := chatCompletionResponse{ + Choices: []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + }{ + { + Message: struct { + Role string `json:"role"` + Content string `json:"content"` + }{ + Role: "assistant", + Content: `{ + "reasoning": "High priority kubernetes security patch", + "actions": [ + {"type": "mark_starred"}, + {"type": "set_variant", "params": {"variant_key": "tags", "value": "security-alert"}}, + {"type": "malicious_unallowed_action"} + ] + }`, + }, + FinishReason: "stop", + }, + }, + } + data, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + Header: make(http.Header), + } + }), + }, + } + + cfg := Config{ + Enabled: true, + BaseURL: "https://mock.api", + Model: "gpt-4o-mini", + } + + entry := models.FeedEntry{ + ID: "e-1", + Title: "Critical CVE in Kubelet", + FeedTitle: "Kubernetes Security", + Content: "Remote code execution flaw patched in v1.36.", + } + + allowed := []string{"mark_starred", "set_variant:tags:security-alert"} + decision, err := client.GenerateAgentDecision(context.Background(), cfg, "Evaluate urgency", allowed, entry) + if err != nil { + t.Fatalf("GenerateAgentDecision failed: %v", err) + } + + if decision.Reasoning == "" { + t.Fatalf("Expected reasoning, got empty") + } + + // Verify strict validation: malicious_unallowed_action was stripped + if len(decision.Actions) != 2 { + t.Fatalf("Expected 2 validated actions, got %d", len(decision.Actions)) + } + if decision.Actions[0].Type != "mark_starred" { + t.Fatalf("Expected mark_starred, got %s", decision.Actions[0].Type) + } +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3911697..db0eb06 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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) +} diff --git a/internal/api/server.go b/internal/api/server.go index 9af9f94..b01b613 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/db/db.go b/internal/db/db.go index ac33430..245b674 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -139,6 +139,12 @@ func (d *DB) migrate() error { created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); + + CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL + ); ` if _, err := d.Exec(schema); err != nil { return err @@ -1067,3 +1073,46 @@ func (d *DB) GetStats() (*models.Stats, error) { _ = d.QueryRow("SELECT COUNT(*) FROM rules").Scan(&s.TotalRules) return &s, nil } + +// System Settings CRUD +func (d *DB) GetSetting(key string) (string, error) { + var val string + err := d.QueryRow("SELECT value FROM system_settings WHERE key = ?", key).Scan(&val) + if err != nil { + if err == sql.ErrNoRows { + return "", nil + } + return "", err + } + return val, nil +} + +func (d *DB) SetSetting(key, value string) error { + now := time.Now().UTC() + _, err := d.Exec(` + INSERT INTO system_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, key, value, now) + return err +} + +func (d *DB) GetSettingsMap() (map[string]string, error) { + rows, err := d.Query("SELECT key, value FROM system_settings") + if err != nil { + return nil, err + } + defer func() { + _ = rows.Close() + }() + + res := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + res[k] = v + } + return res, nil +} diff --git a/web/src/components/SettingsView.tsx b/web/src/components/SettingsView.tsx index ae148c2..25e2ddd 100644 --- a/web/src/components/SettingsView.tsx +++ b/web/src/components/SettingsView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Category, Feed, Rule, RuleAction, RuleCondition, Stats, User } from '../types'; +import { Category, Feed, Rule, RuleAction, RuleCondition, Stats, User, AISettings, PromptTemplate, AITestResult } from '../types'; import { api } from '../services/api'; import { Rss, @@ -25,6 +25,7 @@ import { Clock, Timer, ChevronDown, + Bot, } from 'lucide-react'; interface SettingsViewProps { @@ -53,6 +54,13 @@ interface BuilderActionsState { variantKey: string; variantValue: string; contentType: string; + // AI Actions + aiEnrich: boolean; + aiPrompt: string; + aiVariantKey: string; + aiAgentAction: boolean; + aiAgentPrompt: string; + aiAgentAllowedActions: string[]; } export const SettingsView: React.FC = ({ @@ -63,7 +71,7 @@ export const SettingsView: React.FC = ({ onRefreshData, onOpenAddFeed, }) => { - const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'rules' | 'viewer' | 'opml' | 'account'>('feeds'); + const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'rules' | 'ai' | 'viewer' | 'opml' | 'account'>('feeds'); const [autoReadBottom, setAutoReadBottom] = useState(() => { return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false'; }); @@ -72,6 +80,24 @@ export const SettingsView: React.FC = ({ const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); + // AI Settings State + const [aiSettings, setAiSettings] = useState({ + enabled: false, + provider: 'openai', + base_url: 'https://api.openai.com/v1', + api_key: '', + model: 'gpt-4o-mini', + temperature: 0.3, + max_tokens: 1000, + prompts: [], + }); + const [aiLoading, setAiLoading] = useState(false); + const [aiTesting, setAiTesting] = useState(false); + const [aiTestResult, setAiTestResult] = useState(null); + const [aiSaving, setAiSaving] = useState(false); + const [showApiKey, setShowApiKey] = useState(false); + const [editingPrompt, setEditingPrompt] = useState(null); + // Rules State const [rules, setRules] = useState([]); const [rulesLoading, setRulesLoading] = useState(false); @@ -86,7 +112,7 @@ export const SettingsView: React.FC = ({ const [variantSubEvent, setVariantSubEvent] = useState<'key_added' | 'value_changed' | 'key_deleted'>('key_added'); const [targetVariantKey, setTargetVariantKey] = useState('keywords'); const [ruleMatchMode, setRuleMatchMode] = useState<'all' | 'any'>('all'); - + const [builderConditions, setBuilderConditions] = useState([ { id: 'cond-1', @@ -108,6 +134,12 @@ export const SettingsView: React.FC = ({ variantKey: 'tags', variantValue: '', contentType: 'text', + aiEnrich: false, + aiPrompt: '', + aiVariantKey: 'ai_summary', + aiAgentAction: false, + aiAgentPrompt: '', + aiAgentAllowedActions: ['mark_read', 'mark_starred'], }); // Edit Feed Modal State @@ -172,6 +204,7 @@ export const SettingsView: React.FC = ({ useEffect(() => { loadRules(); + loadAISettings(); }, []); const loadRules = async () => { @@ -186,6 +219,61 @@ export const SettingsView: React.FC = ({ } }; + const loadAISettings = async () => { + setAiLoading(true); + try { + const data = await api.getAISettings(); + if (data) { + setAiSettings(data); + } + } catch { + // ignore + } finally { + setAiLoading(false); + } + }; + + const handleSaveAISettings = async (e: React.FormEvent) => { + e.preventDefault(); + setAiSaving(true); + try { + const payload = { + ...aiSettings, + // Auto-enable if API key or Ollama is configured unless explicitly set + enabled: aiSettings.enabled || Boolean(aiSettings.api_key.trim()) || aiSettings.provider === 'ollama', + }; + const updated = await api.updateAISettings(payload); + setAiSettings(updated); + setMessage('AI Configuration saved successfully.'); + setTimeout(() => setMessage(null), 3000); + } catch (err: any) { + alert(err.message || 'Failed to save AI settings'); + } finally { + setAiSaving(false); + } + }; + + const handleTestAIConnection = async () => { + setAiTesting(true); + setAiTestResult(null); + try { + const res = await api.testAIConnection(aiSettings); + setAiTestResult(res); + if (res.success) { + setAiSettings((prev) => ({ ...prev, enabled: true })); + } + } catch (err: any) { + setAiTestResult({ + success: false, + message: err.message || 'Connection test failed', + latency_ms: 0, + model: aiSettings.model, + }); + } finally { + setAiTesting(false); + } + }; + const openCreateRuleModal = () => { setEditingRuleId(null); setRuleName(''); @@ -212,6 +300,12 @@ export const SettingsView: React.FC = ({ variantKey: 'tags', variantValue: '', contentType: 'text', + aiEnrich: false, + aiPrompt: aiSettings.prompts?.[0]?.prompt || '', + aiVariantKey: 'ai_summary', + aiAgentAction: false, + aiAgentPrompt: '', + aiAgentAllowedActions: ['mark_read', 'mark_starred'], }); setSaveDropdownOpen(false); setShowRuleModal(true); @@ -267,6 +361,12 @@ export const SettingsView: React.FC = ({ let vKey = 'tags'; let vVal = ''; let cType = 'text'; + let aiEnrichVal = false; + let aiPromptVal = ''; + let aiVariantKeyVal = 'ai_summary'; + let aiAgentVal = false; + let aiAgentPromptVal = ''; + let aiAgentAllowedVal: string[] = ['mark_read', 'mark_starred']; rule.actions?.forEach((a) => { if (a.type === 'readability_scrape') scrape = true; @@ -278,6 +378,18 @@ export const SettingsView: React.FC = ({ vVal = String(a.params?.value || ''); cType = String(a.params?.content_type || 'text'); } + if (a.type === 'ai_enrich' || a.type === 'ai_summary') { + aiEnrichVal = true; + aiPromptVal = String(a.params?.prompt || a.params?.custom_prompt || ''); + aiVariantKeyVal = String(a.params?.variant_key || 'ai_summary'); + } + if (a.type === 'ai_agent_action') { + aiAgentVal = true; + aiAgentPromptVal = String(a.params?.prompt || ''); + if (Array.isArray(a.params?.allowed_actions)) { + aiAgentAllowedVal = a.params.allowed_actions.map(String); + } + } }); setBuilderActions({ @@ -288,6 +400,12 @@ export const SettingsView: React.FC = ({ variantKey: vKey, variantValue: vVal, contentType: cType, + aiEnrich: aiEnrichVal, + aiPrompt: aiPromptVal || aiSettings.prompts?.[0]?.prompt || '', + aiVariantKey: aiVariantKeyVal, + aiAgentAction: aiAgentVal, + aiAgentPrompt: aiAgentPromptVal, + aiAgentAllowedActions: aiAgentAllowedVal, }); setSaveDropdownOpen(false); @@ -336,6 +454,25 @@ export const SettingsView: React.FC = ({ }, }); } + if (builderActions.aiEnrich) { + compiledActions.push({ + type: 'ai_enrich', + params: { + variant_key: builderActions.aiVariantKey.trim() || 'ai_summary', + prompt: builderActions.aiPrompt, + content_type: 'text', + }, + }); + } + if (builderActions.aiAgentAction) { + compiledActions.push({ + type: 'ai_agent_action', + params: { + prompt: builderActions.aiAgentPrompt, + allowed_actions: builderActions.aiAgentAllowedActions, + }, + }); + } if (compiledActions.length === 0) { alert('Please select at least one action to execute.'); @@ -590,6 +727,14 @@ export const SettingsView: React.FC = ({ Rules & Hooks ({rules.length}) + + + ))} + + + +
+
+
+ + setAiSettings({ ...aiSettings, base_url: e.target.value })} + placeholder="https://api.openai.com/v1" + required + /> +
+ +
+ + setAiSettings({ ...aiSettings, model: e.target.value })} + placeholder="e.g. gpt-4o-mini, deepseek-chat, llama3.2" + required + /> +
+
+ +
+
+ + +
+ setAiSettings({ ...aiSettings, api_key: e.target.value })} + placeholder={aiSettings.provider === 'ollama' ? 'Optional for local Ollama' : 'sk-...'} + /> +
+ Stored securely in local SQLite database. Keys are masked during display. +
+
+ +
+
+
+ +
+ setAiSettings({ ...aiSettings, temperature: parseFloat(e.target.value) })} + style={{ width: '100%', accentColor: '#10b981' }} + /> +
+ 0.0 (Precise / Deterministic) + 1.0 (Creative) +
+
+ +
+ + setAiSettings({ ...aiSettings, max_tokens: parseInt(e.target.value) || 1000 })} + min={100} + max={8192} + /> +
+
+ + {/* Connection Test Banner */} + {aiTestResult && ( +
+
+ {aiTestResult.success ? '✓ Connection Verified' : '✕ Connection Error'}:{' '} + {aiTestResult.message} +
+ {aiTestResult.latency_ms > 0 && ( + {aiTestResult.latency_ms}ms + )} +
+ )} + +
+ + +
+
+ + + {/* Prompt Template Library Card */} +
+
+
+ +

Prompt Template Library

+
+ +
+ +
+ {aiSettings.prompts.map((p) => ( +
+
+
+ {p.name} + {p.is_default && ( + + Default + + )} +
+
+ + {!p.is_default && ( + + )} +
+
+ {p.description && ( +
+ {p.description} +
+ )} +
+                      {p.prompt}
+                    
+
+ ))} +
+
+ + )} + {/* 3. Viewer Preferences Tab */} {activeTab === 'viewer' && (
@@ -1335,8 +1782,8 @@ export const SettingsView: React.FC = ({ > - - + +
@@ -2225,6 +2672,177 @@ export const SettingsView: React.FC = ({ )} + + {/* AI Variant Enrichment Action */} +