feat: implement AI integration with backend configuration and rule-based enrichment actions
This commit is contained in:
+3
-67
@@ -5,11 +5,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"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/api"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/config"
|
"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/db"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
|
"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/rules"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -52,70 +50,8 @@ func main() {
|
|||||||
bus := events.NewEventBus()
|
bus := events.NewEventBus()
|
||||||
pool := worker.NewPool(database, bus, 5, 1000)
|
pool := worker.NewPool(database, bus, 5, 1000)
|
||||||
|
|
||||||
// Register Core Action Handlers
|
// Register Core Action Handlers (readability, status, variants, AI enrich & triage)
|
||||||
scraper := readability.NewScraper(15 * time.Second)
|
actions.RegisterDefaultHandlers(pool, database, log)
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
pool.Start(ctx)
|
pool.Start(ctx)
|
||||||
|
|
||||||
// 4. Initialize Rules Engine
|
// 4. Initialize Rules Engine
|
||||||
|
|||||||
@@ -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).
|
- 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}/`).
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
@@ -28,6 +30,9 @@ type Handler struct {
|
|||||||
crawler *crawler.Crawler
|
crawler *crawler.Crawler
|
||||||
parser *parser.Parser
|
parser *parser.Parser
|
||||||
pool *worker.Pool
|
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 {
|
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler, pool *worker.Pool) *Handler {
|
||||||
@@ -37,6 +42,8 @@ func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Craw
|
|||||||
crawler: cr,
|
crawler: cr,
|
||||||
parser: parser.NewParser(),
|
parser: parser.NewParser(),
|
||||||
pool: pool,
|
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,
|
"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.Delete("/rules/{id}", h.DeleteRule)
|
||||||
r.Post("/rules/{id}/apply", h.ApplyRule)
|
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
|
// OPML
|
||||||
r.Post("/opml/import", h.ImportOPML)
|
r.Post("/opml/import", h.ImportOPML)
|
||||||
r.Get("/opml/export", h.ExportOPML)
|
r.Get("/opml/export", h.ExportOPML)
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ func (d *DB) migrate() error {
|
|||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_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 {
|
if _, err := d.Exec(schema); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1067,3 +1073,46 @@ func (d *DB) GetStats() (*models.Stats, error) {
|
|||||||
_ = d.QueryRow("SELECT COUNT(*) FROM rules").Scan(&s.TotalRules)
|
_ = d.QueryRow("SELECT COUNT(*) FROM rules").Scan(&s.TotalRules)
|
||||||
return &s, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { api } from '../services/api';
|
||||||
import {
|
import {
|
||||||
Rss,
|
Rss,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Timer,
|
Timer,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Bot,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SettingsViewProps {
|
interface SettingsViewProps {
|
||||||
@@ -53,6 +54,13 @@ interface BuilderActionsState {
|
|||||||
variantKey: string;
|
variantKey: string;
|
||||||
variantValue: string;
|
variantValue: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
// AI Actions
|
||||||
|
aiEnrich: boolean;
|
||||||
|
aiPrompt: string;
|
||||||
|
aiVariantKey: string;
|
||||||
|
aiAgentAction: boolean;
|
||||||
|
aiAgentPrompt: string;
|
||||||
|
aiAgentAllowedActions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsView: React.FC<SettingsViewProps> = ({
|
export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||||
@@ -63,7 +71,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
onRefreshData,
|
onRefreshData,
|
||||||
onOpenAddFeed,
|
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<boolean>(() => {
|
const [autoReadBottom, setAutoReadBottom] = useState<boolean>(() => {
|
||||||
return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
|
return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
|
||||||
});
|
});
|
||||||
@@ -72,6 +80,24 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// AI Settings State
|
||||||
|
const [aiSettings, setAiSettings] = useState<AISettings>({
|
||||||
|
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<AITestResult | null>(null);
|
||||||
|
const [aiSaving, setAiSaving] = useState(false);
|
||||||
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
|
const [editingPrompt, setEditingPrompt] = useState<PromptTemplate | null>(null);
|
||||||
|
|
||||||
// Rules State
|
// Rules State
|
||||||
const [rules, setRules] = useState<Rule[]>([]);
|
const [rules, setRules] = useState<Rule[]>([]);
|
||||||
const [rulesLoading, setRulesLoading] = useState(false);
|
const [rulesLoading, setRulesLoading] = useState(false);
|
||||||
@@ -108,6 +134,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
variantKey: 'tags',
|
variantKey: 'tags',
|
||||||
variantValue: '',
|
variantValue: '',
|
||||||
contentType: 'text',
|
contentType: 'text',
|
||||||
|
aiEnrich: false,
|
||||||
|
aiPrompt: '',
|
||||||
|
aiVariantKey: 'ai_summary',
|
||||||
|
aiAgentAction: false,
|
||||||
|
aiAgentPrompt: '',
|
||||||
|
aiAgentAllowedActions: ['mark_read', 'mark_starred'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit Feed Modal State
|
// Edit Feed Modal State
|
||||||
@@ -172,6 +204,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRules();
|
loadRules();
|
||||||
|
loadAISettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadRules = async () => {
|
const loadRules = async () => {
|
||||||
@@ -186,6 +219,61 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
const openCreateRuleModal = () => {
|
||||||
setEditingRuleId(null);
|
setEditingRuleId(null);
|
||||||
setRuleName('');
|
setRuleName('');
|
||||||
@@ -212,6 +300,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
variantKey: 'tags',
|
variantKey: 'tags',
|
||||||
variantValue: '',
|
variantValue: '',
|
||||||
contentType: 'text',
|
contentType: 'text',
|
||||||
|
aiEnrich: false,
|
||||||
|
aiPrompt: aiSettings.prompts?.[0]?.prompt || '',
|
||||||
|
aiVariantKey: 'ai_summary',
|
||||||
|
aiAgentAction: false,
|
||||||
|
aiAgentPrompt: '',
|
||||||
|
aiAgentAllowedActions: ['mark_read', 'mark_starred'],
|
||||||
});
|
});
|
||||||
setSaveDropdownOpen(false);
|
setSaveDropdownOpen(false);
|
||||||
setShowRuleModal(true);
|
setShowRuleModal(true);
|
||||||
@@ -267,6 +361,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
let vKey = 'tags';
|
let vKey = 'tags';
|
||||||
let vVal = '';
|
let vVal = '';
|
||||||
let cType = 'text';
|
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) => {
|
rule.actions?.forEach((a) => {
|
||||||
if (a.type === 'readability_scrape') scrape = true;
|
if (a.type === 'readability_scrape') scrape = true;
|
||||||
@@ -278,6 +378,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
vVal = String(a.params?.value || '');
|
vVal = String(a.params?.value || '');
|
||||||
cType = String(a.params?.content_type || 'text');
|
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({
|
setBuilderActions({
|
||||||
@@ -288,6 +400,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
variantKey: vKey,
|
variantKey: vKey,
|
||||||
variantValue: vVal,
|
variantValue: vVal,
|
||||||
contentType: cType,
|
contentType: cType,
|
||||||
|
aiEnrich: aiEnrichVal,
|
||||||
|
aiPrompt: aiPromptVal || aiSettings.prompts?.[0]?.prompt || '',
|
||||||
|
aiVariantKey: aiVariantKeyVal,
|
||||||
|
aiAgentAction: aiAgentVal,
|
||||||
|
aiAgentPrompt: aiAgentPromptVal,
|
||||||
|
aiAgentAllowedActions: aiAgentAllowedVal,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSaveDropdownOpen(false);
|
setSaveDropdownOpen(false);
|
||||||
@@ -336,6 +454,25 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
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) {
|
if (compiledActions.length === 0) {
|
||||||
alert('Please select at least one action to execute.');
|
alert('Please select at least one action to execute.');
|
||||||
@@ -590,6 +727,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
<span>Rules & Hooks ({rules.length})</span>
|
<span>Rules & Hooks ({rules.length})</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`settings-nav-item ${activeTab === 'ai' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('ai')}
|
||||||
|
>
|
||||||
|
<Bot size={15} />
|
||||||
|
<span>AI & Integrations</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`settings-nav-item ${activeTab === 'viewer' ? 'active' : ''}`}
|
className={`settings-nav-item ${activeTab === 'viewer' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('viewer')}
|
onClick={() => setActiveTab('viewer')}
|
||||||
@@ -997,6 +1142,308 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI & Integrations Tab */}
|
||||||
|
{activeTab === 'ai' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||||
|
AI & Integrations
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||||
|
Configure OpenAI-compatible LLM providers for automated summarization, keyword extraction, and autonomous triage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiLoading && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '1rem', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
<span>Loading AI configuration...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Provider Config Card */}
|
||||||
|
<div className="settings-card" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem', borderBottom: '1px solid var(--border)', paddingBottom: '0.85rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Bot size={18} style={{ color: '#34d399' }} />
|
||||||
|
<h3 className="settings-card-title" style={{ margin: 0 }}>LLM Provider Settings</h3>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={aiSettings.enabled}
|
||||||
|
onChange={(e) => setAiSettings({ ...aiSettings, enabled: e.target.checked })}
|
||||||
|
style={{ accentColor: '#10b981', width: 16, height: 16 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: aiSettings.enabled ? '#34d399' : 'var(--text-muted)' }}>
|
||||||
|
{aiSettings.enabled ? 'AI Enabled' : 'AI Disabled'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider Quick Presets */}
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<label className="form-label" style={{ marginBottom: '0.4rem' }}>Quick Provider Presets</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'openai', label: 'OpenAI', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
||||||
|
{ id: 'deepseek', label: 'DeepSeek', base: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
|
||||||
|
{ id: 'gemini', label: 'Google Gemini', base: 'https://generativelanguage.googleapis.com/v1beta/openai/', model: 'gemini-2.0-flash' },
|
||||||
|
{ id: 'ollama', label: 'Ollama (Local)', base: 'http://localhost:11434/v1', model: 'llama3.2' },
|
||||||
|
{ id: 'custom', label: 'Custom Endpoint', base: '', model: '' },
|
||||||
|
].map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn-secondary ${aiSettings.provider === p.id ? 'active' : ''}`}
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '0.35rem 0.75rem',
|
||||||
|
borderColor: aiSettings.provider === p.id ? '#10b981' : undefined,
|
||||||
|
color: aiSettings.provider === p.id ? '#34d399' : undefined,
|
||||||
|
background: aiSettings.provider === p.id ? 'rgba(16, 185, 129, 0.08)' : undefined,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setAiSettings({
|
||||||
|
...aiSettings,
|
||||||
|
provider: p.id,
|
||||||
|
base_url: p.base || aiSettings.base_url,
|
||||||
|
model: p.model || aiSettings.model,
|
||||||
|
});
|
||||||
|
setAiTestResult(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveAISettings}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">API Base URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={aiSettings.base_url}
|
||||||
|
onChange={(e) => setAiSettings({ ...aiSettings, base_url: e.target.value })}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Model Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={aiSettings.model}
|
||||||
|
onChange={(e) => setAiSettings({ ...aiSettings, model: e.target.value })}
|
||||||
|
placeholder="e.g. gpt-4o-mini, deepseek-chat, llama3.2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
|
||||||
|
<label className="form-label" style={{ margin: 0 }}>API Key</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ fontSize: '0.72rem', color: 'var(--text-muted)', height: 'auto', width: 'auto', padding: '2px 6px' }}
|
||||||
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
|
>
|
||||||
|
{showApiKey ? 'Hide Key' : 'Show Key'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={showApiKey ? 'text' : 'password'}
|
||||||
|
className="form-input"
|
||||||
|
value={aiSettings.api_key}
|
||||||
|
onChange={(e) => setAiSettings({ ...aiSettings, api_key: e.target.value })}
|
||||||
|
placeholder={aiSettings.provider === 'ollama' ? 'Optional for local Ollama' : 'sk-...'}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
|
Stored securely in local SQLite database. Keys are masked during display.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1.25rem' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<label className="form-label">Temperature: {aiSettings.temperature}</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value={aiSettings.temperature}
|
||||||
|
onChange={(e) => setAiSettings({ ...aiSettings, temperature: parseFloat(e.target.value) })}
|
||||||
|
style={{ width: '100%', accentColor: '#10b981' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
|
||||||
|
<span>0.0 (Precise / Deterministic)</span>
|
||||||
|
<span>1.0 (Creative)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Max Tokens</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={aiSettings.max_tokens}
|
||||||
|
onChange={(e) => setAiSettings({ ...aiSettings, max_tokens: parseInt(e.target.value) || 1000 })}
|
||||||
|
min={100}
|
||||||
|
max={8192}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Test Banner */}
|
||||||
|
{aiTestResult && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: '1rem',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
background: aiTestResult.success ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: `1px solid ${aiTestResult.success ? 'rgba(16, 185, 129, 0.3)' : 'rgba(239, 68, 68, 0.3)'}`,
|
||||||
|
color: aiTestResult.success ? '#34d399' : '#f87171',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{aiTestResult.success ? '✓ Connection Verified' : '✕ Connection Error'}:</strong>{' '}
|
||||||
|
<span>{aiTestResult.message}</span>
|
||||||
|
</div>
|
||||||
|
{aiTestResult.latency_ms > 0 && (
|
||||||
|
<span style={{ fontSize: '0.72rem', opacity: 0.8 }}>{aiTestResult.latency_ms}ms</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={handleTestAIConnection}
|
||||||
|
disabled={aiTesting}
|
||||||
|
>
|
||||||
|
{aiTesting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span>Testing Connection...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap size={14} />
|
||||||
|
<span>Test Connection</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary" disabled={aiSaving}>
|
||||||
|
{aiSaving ? 'Saving...' : 'Save AI Settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt Template Library Card */}
|
||||||
|
<div className="settings-card">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem', borderBottom: '1px solid var(--border)', paddingBottom: '0.85rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Sparkles size={18} style={{ color: '#38bdf8' }} />
|
||||||
|
<h3 className="settings-card-title" style={{ margin: 0 }}>Prompt Template Library</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
||||||
|
onClick={() => {
|
||||||
|
const newPrompt: PromptTemplate = {
|
||||||
|
id: `custom_${Date.now()}`,
|
||||||
|
name: 'New Custom Prompt',
|
||||||
|
description: 'Custom summarization prompt',
|
||||||
|
prompt: 'Summarize the following in 3 points:\n\nTitle: {{title}}\nContent:\n{{content}}',
|
||||||
|
};
|
||||||
|
const updated = { ...aiSettings, prompts: [...aiSettings.prompts, newPrompt] };
|
||||||
|
setAiSettings(updated);
|
||||||
|
setEditingPrompt(newPrompt);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
<span>Add Template</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{aiSettings.prompts.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.4rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<strong style={{ fontSize: '0.88rem', color: 'var(--text-main)' }}>{p.name}</strong>
|
||||||
|
{p.is_default && (
|
||||||
|
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'var(--bg-card)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
title="Edit Template"
|
||||||
|
onClick={() => setEditingPrompt(p)}
|
||||||
|
>
|
||||||
|
<Edit2 size={13} />
|
||||||
|
</button>
|
||||||
|
{!p.is_default && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
title="Delete Template"
|
||||||
|
onClick={() => {
|
||||||
|
const filtered = aiSettings.prompts.filter((item) => item.id !== p.id);
|
||||||
|
const updated = { ...aiSettings, prompts: filtered };
|
||||||
|
setAiSettings(updated);
|
||||||
|
api.updateAISettings(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.description && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||||
|
{p.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', background: 'var(--bg-card)', padding: '0.5rem', borderRadius: 4, margin: 0, whiteSpace: 'pre-wrap', maxHeight: 80, overflowY: 'auto' }}>
|
||||||
|
{p.prompt}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 3. Viewer Preferences Tab */}
|
{/* 3. Viewer Preferences Tab */}
|
||||||
{activeTab === 'viewer' && (
|
{activeTab === 'viewer' && (
|
||||||
<div>
|
<div>
|
||||||
@@ -1335,8 +1782,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
>
|
>
|
||||||
<option value={5}>5 seconds (Fast)</option>
|
<option value={5}>5 seconds (Fast)</option>
|
||||||
<option value={10}>10 seconds (Standard)</option>
|
<option value={10}>10 seconds (Standard)</option>
|
||||||
<option value={15}>15 seconds</option>
|
<option value={30}>30 seconds</option>
|
||||||
<option value={30}>30 seconds (Slow)</option>
|
<option value={60}>60 seconds (Slow)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2225,6 +2672,177 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* AI Variant Enrichment Action */}
|
||||||
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={builderActions.aiEnrich}
|
||||||
|
onChange={(e) => setBuilderActions({ ...builderActions, aiEnrich: e.target.checked })}
|
||||||
|
style={{ accentColor: '#10b981', width: 16, height: 16, marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<strong style={{ color: '#34d399' }}>✨ Generate AI Summary / Enrichment</strong>
|
||||||
|
<span style={{ fontSize: '0.65rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.12)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.25)' }}>
|
||||||
|
LLM Powered
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||||
|
Run prompt through configured LLM provider and save structured summary to variants.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{builderActions.aiEnrich && (
|
||||||
|
<div style={{ marginTop: '0.6rem', padding: '0.75rem', background: 'var(--bg-tertiary)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||||
|
Prompt Template Preset
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
onChange={(e) => {
|
||||||
|
const selected = aiSettings.prompts.find((p) => p.id === e.target.value);
|
||||||
|
if (selected) {
|
||||||
|
setBuilderActions({ ...builderActions, aiPrompt: selected.prompt });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">(Select a saved template...)</option>
|
||||||
|
{aiSettings.prompts.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} {p.is_default ? '(Default)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||||
|
Target Variant Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="ai_summary"
|
||||||
|
value={builderActions.aiVariantKey}
|
||||||
|
onChange={(e) => setBuilderActions({ ...builderActions, aiVariantKey: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '0.72rem', margin: 0 }}>
|
||||||
|
Prompt Instructions (Supports variable tags)
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 3 }}>
|
||||||
|
{['{{title}}', '{{content}}', '{{feed_title}}'].map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{ fontSize: '0.65rem', padding: '1px 5px' }}
|
||||||
|
onClick={() => setBuilderActions({ ...builderActions, aiPrompt: builderActions.aiPrompt + ' ' + v })}
|
||||||
|
>
|
||||||
|
+{v}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}
|
||||||
|
value={builderActions.aiPrompt}
|
||||||
|
onChange={(e) => setBuilderActions({ ...builderActions, aiPrompt: e.target.value })}
|
||||||
|
placeholder="You are an expert technical editor. Summarize the following in 3 points:\n\nTitle: {{title}}\nContent:\n{{content}}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* AI Autonomous Triage Action */}
|
||||||
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={builderActions.aiAgentAction}
|
||||||
|
onChange={(e) => setBuilderActions({ ...builderActions, aiAgentAction: e.target.checked })}
|
||||||
|
style={{ accentColor: '#38bdf8', width: 16, height: 16, marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<strong style={{ color: '#38bdf8' }}>🤖 AI Autonomous Triage (Action Recommender)</strong>
|
||||||
|
<span style={{ fontSize: '0.65rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(56, 189, 248, 0.12)', color: '#38bdf8', border: '1px solid rgba(56, 189, 248, 0.25)' }}>
|
||||||
|
Decision Agent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||||
|
AI evaluates article against your policy and triggers actions restricted strictly to the allowed action pool.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{builderActions.aiAgentAction && (
|
||||||
|
<div style={{ marginTop: '0.6rem', padding: '0.75rem', background: 'var(--bg-tertiary)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ marginBottom: '0.6rem' }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||||
|
Policy Prompt / Decision Criteria
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
style={{ fontSize: '0.75rem' }}
|
||||||
|
value={builderActions.aiAgentPrompt}
|
||||||
|
onChange={(e) => setBuilderActions({ ...builderActions, aiAgentPrompt: e.target.value })}
|
||||||
|
placeholder="e.g. If this post discusses critical Kubernetes security alerts or breaking CVEs, star it and tag 'security-alert'. If it is promotional marketing fluff, mark as read."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label" style={{ fontSize: '0.72rem', marginBottom: '0.35rem' }}>
|
||||||
|
Restricted Allowed Action Pool (Security Guardrail)
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.4rem', fontSize: '0.75rem' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'mark_starred', label: '⭐ Star Post' },
|
||||||
|
{ id: 'mark_read', label: '✓ Mark as Read' },
|
||||||
|
{ id: 'readability_scrape', label: '⚡ Scrape Full Text' },
|
||||||
|
{ id: 'set_variant:tags:to_share', label: '🏷️ Tag: to_share' },
|
||||||
|
{ id: 'set_variant:tags:urgent', label: '🏷️ Tag: urgent' },
|
||||||
|
{ id: 'set_variant:tags:security-alert', label: '🏷️ Tag: security-alert' },
|
||||||
|
{ id: 'set_variant:tags:k8s+gpu', label: '🏷️ Tag: k8s+gpu' },
|
||||||
|
].map((act) => {
|
||||||
|
const isChecked = builderActions.aiAgentAllowedActions.includes(act.id);
|
||||||
|
return (
|
||||||
|
<label key={act.id} style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setBuilderActions({
|
||||||
|
...builderActions,
|
||||||
|
aiAgentAllowedActions: [...builderActions.aiAgentAllowedActions, act.id],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setBuilderActions({
|
||||||
|
...builderActions,
|
||||||
|
aiAgentAllowedActions: builderActions.aiAgentAllowedActions.filter((a) => a !== act.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ accentColor: '#38bdf8', width: 14, height: 14 }}
|
||||||
|
/>
|
||||||
|
<span>{act.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2273,6 +2891,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
builderActions.markStarred && 'Star Post',
|
builderActions.markStarred && 'Star Post',
|
||||||
builderActions.markRead && 'Mark Read',
|
builderActions.markRead && 'Mark Read',
|
||||||
builderActions.setVariant && `Set [${builderActions.variantKey || 'tags'}]`,
|
builderActions.setVariant && `Set [${builderActions.variantKey || 'tags'}]`,
|
||||||
|
builderActions.aiEnrich && `AI Enrich [${builderActions.aiVariantKey || 'ai_summary'}]`,
|
||||||
|
builderActions.aiAgentAction && 'AI Autonomous Triage',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(', ') || 'No Actions Selected'}
|
.join(', ') || 'No Actions Selected'}
|
||||||
@@ -2416,6 +3036,109 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Prompt Template Modal */}
|
||||||
|
{editingPrompt && (
|
||||||
|
<div className="modal-overlay" onClick={() => setEditingPrompt(null)}>
|
||||||
|
<div className="modal-card" style={{ maxWidth: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Sparkles size={17} style={{ color: '#38bdf8' }} />
|
||||||
|
<h3>Edit Prompt Template</h3>
|
||||||
|
</div>
|
||||||
|
<button className="btn-icon" onClick={() => setEditingPrompt(null)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const existingIdx = aiSettings.prompts.findIndex((p) => p.id === editingPrompt.id);
|
||||||
|
let updatedList = [...aiSettings.prompts];
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
updatedList[existingIdx] = editingPrompt;
|
||||||
|
} else {
|
||||||
|
updatedList.push(editingPrompt);
|
||||||
|
}
|
||||||
|
const updatedSettings = { ...aiSettings, prompts: updatedList };
|
||||||
|
setAiSettings(updatedSettings);
|
||||||
|
api.updateAISettings(updatedSettings);
|
||||||
|
setEditingPrompt(null);
|
||||||
|
setMessage(`Saved prompt template "${editingPrompt.name}".`);
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Template Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={editingPrompt.name}
|
||||||
|
onChange={(e) => setEditingPrompt({ ...editingPrompt, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Description (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={editingPrompt.description || ''}
|
||||||
|
onChange={(e) => setEditingPrompt({ ...editingPrompt, description: e.target.value })}
|
||||||
|
placeholder="Brief description of what this prompt is designed for"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||||
|
<label className="form-label" style={{ margin: 0 }}>Prompt Instructions</label>
|
||||||
|
<div style={{ display: 'flex', gap: 3 }}>
|
||||||
|
{['{{title}}', '{{content}}', '{{feed_title}}', '{{author}}'].map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{ fontSize: '0.65rem', padding: '1px 5px' }}
|
||||||
|
onClick={() => setEditingPrompt({ ...editingPrompt, prompt: editingPrompt.prompt + ' ' + v })}
|
||||||
|
>
|
||||||
|
+{v}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={6}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.78rem' }}
|
||||||
|
value={editingPrompt.prompt}
|
||||||
|
onChange={(e) => setEditingPrompt({ ...editingPrompt, prompt: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '0.3rem' }}>
|
||||||
|
Use variable placeholders like <code>{`{{title}}`}</code> and <code>{`{{content}}`}</code> which will be replaced dynamically at runtime.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => setEditingPrompt(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
Save Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags } from '../utils';
|
import { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import {
|
import {
|
||||||
Star,
|
Star,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Volume2,
|
Volume2,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface ThreePaneViewProps {
|
interface ThreePaneViewProps {
|
||||||
@@ -53,7 +54,9 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const readerPaneRef = useRef<HTMLDivElement>(null);
|
const readerPaneRef = useRef<HTMLDivElement>(null);
|
||||||
|
const summaryCardRef = useRef<HTMLDivElement>(null);
|
||||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||||
|
const [showAISummary, setShowAISummary] = useState<boolean>(true);
|
||||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
const [isScraping, setIsScraping] = useState<boolean>(false);
|
||||||
|
|
||||||
const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
|
const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
|
||||||
@@ -165,9 +168,6 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|||||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
||||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
return selectedEntry.variants['readability_full_text'].text_value || '';
|
||||||
}
|
}
|
||||||
if (selectedVariantKey === 'ai_summary' && selectedEntry.variants?.['ai_summary']) {
|
|
||||||
return `<div style="padding: 1rem; border-left: 3px solid var(--accent); background: rgba(56, 189, 248, 0.05); margin-bottom: 1.5rem; border-radius: 4px;"><strong>AI Summary:</strong><p>${selectedEntry.variants['ai_summary'].text_value}</p></div>` + (selectedEntry.content || selectedEntry.raw_content || '');
|
|
||||||
}
|
|
||||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
return selectedEntry.content || selectedEntry.raw_content || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,11 +240,23 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|||||||
|
|
||||||
{snippet && <p className="article-item-snippet">{snippet}</p>}
|
{snippet && <p className="article-item-snippet">{snippet}</p>}
|
||||||
|
|
||||||
{getEntryTags(entry.variants).length > 0 && (
|
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||||
{getEntryTags(entry.variants).map((tag) => (
|
{getEntryTags(entry.variants).map((tag) => (
|
||||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||||
))}
|
))}
|
||||||
|
{entry.variants?.['ai_summary'] && (
|
||||||
|
<span
|
||||||
|
className="variant-tag-chip"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(16, 185, 129, 0.08)',
|
||||||
|
color: '#34d399',
|
||||||
|
borderColor: 'rgba(16, 185, 129, 0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✨ AI Summary
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -341,22 +353,34 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|||||||
{hasAISummary && (
|
{hasAISummary && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn-secondary ${selectedVariantKey === 'ai_summary' ? 'active' : ''}`}
|
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: '1px solid',
|
||||||
|
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
padding: '3px 9px',
|
padding: '3px 9px',
|
||||||
fontSize: '0.72rem',
|
fontSize: '0.72rem',
|
||||||
background: selectedVariantKey === 'ai_summary' ? 'var(--card-bg)' : 'transparent',
|
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
|
||||||
color: selectedVariantKey === 'ai_summary' ? '#a855f7' : 'var(--text-muted)',
|
color: showAISummary ? '#34d399' : 'var(--text-muted)',
|
||||||
boxShadow: selectedVariantKey === 'ai_summary' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||||
marginLeft: '0.4rem',
|
marginLeft: '0.4rem',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
}}
|
}}
|
||||||
onClick={() => setSelectedVariantKey('ai_summary')}
|
onClick={() => {
|
||||||
title="AI Summary"
|
const next = !showAISummary;
|
||||||
|
setShowAISummary(next);
|
||||||
|
if (next) {
|
||||||
|
setTimeout(() => {
|
||||||
|
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
|
||||||
>
|
>
|
||||||
<Sparkles size={12} style={{ marginRight: 4 }} />
|
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
|
||||||
Summary
|
<span>Summary</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -463,6 +487,66 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Summary Card if present and visible */}
|
||||||
|
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
|
||||||
|
<div
|
||||||
|
ref={summaryCardRef}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(16, 185, 129, 0.05)',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.22)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '1rem 1.15rem',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
|
||||||
|
<Sparkles size={15} />
|
||||||
|
<span>AI Summary & Takeaways</span>
|
||||||
|
{selectedEntry.variants.ai_summary.metadata?.model && (
|
||||||
|
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||||
|
{String(selectedEntry.variants.ai_summary.metadata.model)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||||
|
title="Copy Summary"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedEntry.variants?.ai_summary?.text_value) {
|
||||||
|
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||||
|
title="Hide Summary"
|
||||||
|
onClick={() => setShowAISummary(false)}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="reader-body"
|
className="reader-body"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import { Category, FeedEntry, FilterState } from '../types';
|
import { Category, FeedEntry, FilterState } from '../types';
|
||||||
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags } from '../utils';
|
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
|
||||||
import {
|
import {
|
||||||
Star,
|
Star,
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Volume2,
|
Volume2,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface WaterfallViewProps {
|
interface WaterfallViewProps {
|
||||||
@@ -42,8 +43,10 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
|
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
|
||||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||||
const [isScraping, setIsScraping] = useState(false);
|
const [showAISummary, setShowAISummary] = useState<boolean>(true);
|
||||||
|
const [isScraping, setIsScraping] = useState<boolean>(false);
|
||||||
const readerScrollRef = useRef<HTMLDivElement>(null);
|
const readerScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const summaryCardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Reset variant selection when selected article changes
|
// Reset variant selection when selected article changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,12 +77,6 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
|||||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
||||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
return selectedEntry.variants['readability_full_text'].text_value || '';
|
||||||
}
|
}
|
||||||
if (selectedVariantKey === 'ai_summary' && selectedEntry.variants?.['ai_summary']) {
|
|
||||||
return (
|
|
||||||
`<div style="padding: 1rem; border-left: 3px solid var(--accent); background: rgba(56, 189, 248, 0.05); margin-bottom: 1.5rem; border-radius: 4px;"><strong>AI Summary:</strong><p>${selectedEntry.variants['ai_summary'].text_value}</p></div>` +
|
|
||||||
(selectedEntry.content || selectedEntry.raw_content || '')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
return selectedEntry.content || selectedEntry.raw_content || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,11 +210,23 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
|||||||
<h3 className="xhs-card-title">{entry.title}</h3>
|
<h3 className="xhs-card-title">{entry.title}</h3>
|
||||||
{snippet && <p className="xhs-card-snippet">{snippet}</p>}
|
{snippet && <p className="xhs-card-snippet">{snippet}</p>}
|
||||||
|
|
||||||
{getEntryTags(entry.variants).length > 0 && (
|
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4, marginBottom: 4 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4, marginBottom: 4 }}>
|
||||||
{getEntryTags(entry.variants).map((tag) => (
|
{getEntryTags(entry.variants).map((tag) => (
|
||||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||||
))}
|
))}
|
||||||
|
{entry.variants?.['ai_summary'] && (
|
||||||
|
<span
|
||||||
|
className="variant-tag-chip"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(16, 185, 129, 0.08)',
|
||||||
|
color: '#34d399',
|
||||||
|
borderColor: 'rgba(16, 185, 129, 0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✨ AI Summary
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -338,21 +347,33 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
|||||||
{hasAISummary && (
|
{hasAISummary && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn-secondary ${selectedVariantKey === 'ai_summary' ? 'active' : ''}`}
|
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: '1px solid',
|
||||||
|
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
padding: '3px 8px',
|
padding: '3px 8px',
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
background: selectedVariantKey === 'ai_summary' ? 'var(--card-bg)' : 'transparent',
|
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
|
||||||
color: selectedVariantKey === 'ai_summary' ? '#a855f7' : 'var(--text-muted)',
|
color: showAISummary ? '#34d399' : 'var(--text-muted)',
|
||||||
boxShadow: selectedVariantKey === 'ai_summary' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 3,
|
||||||
}}
|
}}
|
||||||
onClick={() => setSelectedVariantKey('ai_summary')}
|
onClick={() => {
|
||||||
title="AI Summary"
|
const next = !showAISummary;
|
||||||
|
setShowAISummary(next);
|
||||||
|
if (next) {
|
||||||
|
setTimeout(() => {
|
||||||
|
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
|
||||||
>
|
>
|
||||||
<Sparkles size={12} style={{ marginRight: 3 }} />
|
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
|
||||||
Summary
|
<span>Summary</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -468,6 +489,66 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI Summary Card if present and visible */}
|
||||||
|
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
|
||||||
|
<div
|
||||||
|
ref={summaryCardRef}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(16, 185, 129, 0.05)',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.22)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '1rem 1.15rem',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
|
||||||
|
<Sparkles size={15} />
|
||||||
|
<span>AI Summary & Takeaways</span>
|
||||||
|
{selectedEntry.variants.ai_summary.metadata?.model && (
|
||||||
|
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||||
|
{String(selectedEntry.variants.ai_summary.metadata.model)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||||
|
title="Copy Summary"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedEntry.variants?.ai_summary?.text_value) {
|
||||||
|
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||||
|
title="Hide Summary"
|
||||||
|
onClick={() => setShowAISummary(false)}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dynamic Content Variant Rendering */}
|
{/* Dynamic Content Variant Rendering */}
|
||||||
{selectedVariantKey === 'tts' && hasTTS ? (
|
{selectedVariantKey === 'tts' && hasTTS ? (
|
||||||
<div className="audio-player-container" style={{ margin: '1.5rem 0', padding: '1rem', background: 'var(--bg-card-hover)', borderRadius: '12px' }}>
|
<div className="audio-player-container" style={{ margin: '1.5rem 0', padding: '1rem', background: 'var(--bg-card-hover)', borderRadius: '12px' }}>
|
||||||
|
|||||||
@@ -243,6 +243,26 @@ export const api = {
|
|||||||
exportOPMLUrl(): string {
|
exportOPMLUrl(): string {
|
||||||
const token = localStorage.getItem('rsspipe_jwt');
|
const token = localStorage.getItem('rsspipe_jwt');
|
||||||
return `${API_BASE}/opml/export?token=${token || ''}`;
|
return `${API_BASE}/opml/export?token=${token || ''}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI Settings
|
||||||
|
async getAISettings(): Promise<import('../types').AISettings> {
|
||||||
|
return request<import('../types').AISettings>('/settings/ai');
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAISettings(data: Partial<import('../types').AISettings>): Promise<import('../types').AISettings> {
|
||||||
|
return request<import('../types').AISettings>('/settings/ai', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async testAIConnection(data?: Partial<import('../types').AISettings>): Promise<import('../types').AITestResult> {
|
||||||
|
return request<import('../types').AITestResult>('/settings/ai/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data || {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -105,3 +105,29 @@ export interface FilterState {
|
|||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PromptTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
prompt: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AISettings {
|
||||||
|
enabled: boolean;
|
||||||
|
provider: string; // 'openai' | 'deepseek' | 'gemini' | 'ollama' | 'custom'
|
||||||
|
base_url: string;
|
||||||
|
api_key: string;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
max_tokens: number;
|
||||||
|
prompts: PromptTemplate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AITestResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
latency_ms: number;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,3 +75,53 @@ export function getEntryTags(variants?: Record<string, { text_value?: string }>)
|
|||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderMarkdownText(markdown?: string): string {
|
||||||
|
if (!markdown) return '';
|
||||||
|
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
const output: string[] = [];
|
||||||
|
let inList = false;
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
if (inList) {
|
||||||
|
output.push('</ul>');
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if line is a bullet point: starts with "* ", "- ", "• "
|
||||||
|
const bulletMatch = trimmed.match(/^[\*\-•]\s+(.+)$/);
|
||||||
|
if (bulletMatch) {
|
||||||
|
if (!inList) {
|
||||||
|
output.push('<ul style="margin: 0.6rem 0; padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.35rem;">');
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
let content = bulletMatch[1];
|
||||||
|
// Format bold and code
|
||||||
|
content = content
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-main); font-weight: 600;">$1</strong>')
|
||||||
|
.replace(/`(.+?)`/g, '<code style="background: var(--bg-card); padding: 2px 4px; border-radius: 4px; font-size: 0.8em;">$1</code>');
|
||||||
|
output.push(`<li style="line-height: 1.55; color: var(--text-secondary);">${content}</li>`);
|
||||||
|
} else {
|
||||||
|
if (inList) {
|
||||||
|
output.push('</ul>');
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
let content = trimmed
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-main); font-weight: 600;">$1</strong>')
|
||||||
|
.replace(/`(.+?)`/g, '<code style="background: var(--bg-card); padding: 2px 4px; border-radius: 4px; font-size: 0.8em;">$1</code>');
|
||||||
|
output.push(`<p style="margin: 0.5rem 0; line-height: 1.6; color: var(--text-secondary);">${content}</p>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inList) {
|
||||||
|
output.push('</ul>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user