Files
rsspipe/internal/actions/register.go
T
haopengzhan ebcaa5d72a
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 1m59s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 2m26s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 2m37s
refactor(core): overhaul state transition architecture, persistent job queue, and API layer
- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission.
- Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication.
- Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse).
- Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization.
- Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5).
- Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations.
- Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets.
- Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels.
- Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar.
- Add architectural documentation and worklog in docs/009_worklog.md.
2026-09-01 00:41:05 -07:00

227 lines
7.0 KiB
Go

package actions
import (
"context"
"fmt"
"strings"
"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/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
"go.uber.org/zap"
)
// RegisterDefaultHandlers registers legacy action handlers with 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))
}
// RegisterJobEngineHandlers registers async task handlers with the persistent JobEngine
func RegisterJobEngineHandlers(engine *worker.JobEngine, database *db.DB, log *zap.Logger) {
scraper := readability.NewScraper(20 * time.Second)
aiClient := aiClientPkg.NewClient(60 * time.Second)
// 1. Readability Scrape Handler
engine.RegisterHandler("readability_scrape", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
entryID, _ := job.Payload["entry_id"].(string)
if entryID == "" {
return nil, nil, fmt.Errorf("missing entry_id in job payload")
}
entry, err := database.GetEntry(entryID)
if err != nil {
return nil, nil, fmt.Errorf("entry not found: %w", err)
}
res, err := scraper.Handler()(ctx, *entry, job.Payload)
if err != nil {
return nil, nil, err
}
if res == nil || res.ResultVariant == nil {
return nil, nil, nil
}
patch := &models.PatchEntryRequest{
Variants: map[string]*models.EntryVariant{
"readability_full_text": res.ResultVariant,
},
}
return patch, map[string]interface{}{
"variant_key": "readability_full_text",
"length": len(res.ResultVariant.TextValue),
}, nil
})
// 2. AI Summary & AI Enrich Handler
aiHandler := func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
entryID, _ := job.Payload["entry_id"].(string)
if entryID == "" {
return nil, nil, fmt.Errorf("missing entry_id in job payload")
}
entry, err := database.GetEntry(entryID)
if err != nil {
return nil, nil, fmt.Errorf("entry not found: %w", err)
}
cfg, prompts := ai.LoadConfig(database)
if !cfg.Enabled {
return nil, nil, fmt.Errorf("AI features are disabled. Please enable AI in Settings > AI & Integrations")
}
if cfg.Provider != "ollama" && strings.TrimSpace(cfg.APIKey) == "" {
return nil, nil, fmt.Errorf("AI API key is missing. Please configure an API key in Settings > AI & Integrations")
}
prompt, _ := job.Payload["prompt"].(string)
if prompt == "" {
prompt, _ = job.Payload["custom_prompt"].(string)
}
if prompt == "" {
for _, p := range prompts {
if p.IsDefault {
prompt = p.Prompt
break
}
}
if prompt == "" && len(prompts) > 0 {
prompt = prompts[0].Prompt
}
}
if prompt == "" {
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}}"
}
vKey, _ := job.Payload["variant_key"].(string)
if vKey == "" {
vKey = "ai_summary"
}
cType, _ := job.Payload["content_type"].(string)
if cType == "" {
cType = "text"
}
resultText, err := aiClient.GenerateEnrichment(ctx, cfg, prompt, *entry)
if err != nil {
return nil, nil, fmt.Errorf("ai generation failed: %w", 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().UTC(),
UpdatedAt: time.Now().UTC(),
}
patch := &models.PatchEntryRequest{
Variants: map[string]*models.EntryVariant{
vKey: &variant,
},
}
return patch, map[string]interface{}{
"variant_key": vKey,
"model": cfg.Model,
"word_count": len(strings.Fields(resultText)),
}, nil
}
engine.RegisterHandler("ai_summary", aiHandler)
engine.RegisterHandler("ai_enrich", aiHandler)
// 3. Add Tags Handler
engine.RegisterHandler("add_tags", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
entryID, _ := job.Payload["entry_id"].(string)
if entryID == "" {
return nil, nil, fmt.Errorf("missing entry_id in job payload")
}
var tagsToAdd []string
if rawTags, ok := job.Payload["tags"].([]interface{}); ok {
for _, t := range rawTags {
tagsToAdd = append(tagsToAdd, fmt.Sprintf("%v", t))
}
} else if rawTagsStr, ok := job.Payload["tags"].(string); ok {
for _, t := range strings.Split(rawTagsStr, ",") {
if tr := strings.TrimSpace(t); tr != "" {
tagsToAdd = append(tagsToAdd, tr)
}
}
}
entry, err := database.GetEntry(entryID)
if err != nil {
return nil, nil, fmt.Errorf("entry not found: %w", err)
}
existingTags := make(map[string]bool)
if entry.Metadata != nil {
if curr, ok := entry.Metadata["tags"].([]interface{}); ok {
for _, t := range curr {
existingTags[fmt.Sprintf("%v", t)] = true
}
}
}
for _, t := range tagsToAdd {
existingTags[t] = true
}
var merged []string
for t := range existingTags {
merged = append(merged, t)
}
patch := &models.PatchEntryRequest{
Metadata: map[string]interface{}{
"tags": merged,
},
}
return patch, map[string]interface{}{"tags": merged}, nil
})
// 4. Mark Read Handler
engine.RegisterHandler("mark_read", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
read := true
return &models.PatchEntryRequest{IsRead: &read}, map[string]interface{}{"is_read": true}, nil
})
// 5. Mark Starred Handler
engine.RegisterHandler("mark_starred", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
starred := true
return &models.PatchEntryRequest{IsStarred: &starred}, map[string]interface{}{"is_starred": true}, nil
})
}