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.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai"
|
||||
@@ -9,11 +12,12 @@ import (
|
||||
"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 all core action handlers with the worker pool.
|
||||
// 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)
|
||||
@@ -34,3 +38,189 @@ func RegisterDefaultHandlers(pool *worker.Pool, database *db.DB, log *zap.Logger
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user