- 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.
227 lines
7.0 KiB
Go
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
|
|
})
|
|
}
|