feat: AI job tracking, range support, auto-prompt, and resume
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 55s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 3m21s
Release / Docker / runner (push) Successful in 2m41s
Release / Upload source maps (push) Successful in 3m44s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m22s
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 55s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 3m21s
Release / Docker / runner (push) Successful in 2m41s
Release / Upload source maps (push) Successful in 3m44s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m22s
- New `ai_jobs` PocketBase collection tracks all long-running AI tasks (batch-covers, chapter-names) with status, progress, and cancellation - `handlers_aijobs.go`: GET/cancel endpoints for ai_jobs; centralised cancel registry (moved from handlers_catalogue) - Batch-covers and chapter-names SSE handlers now create/resume ai_job records, support from_item/to_item ranges, and resume from items_done on restart via job_id - New `POST /api/admin/image-gen/auto-prompt`: generates an image prompt from book description (cover) or chapter title (chapter) via LLM - image-gen page: "Auto-prompt" button calls auto-prompt API when a slug is selected; falls back gracefully if TextGen not configured - text-gen chapter-names: from/to chapter range inputs + job ID display - catalogue-tools batch-covers: from/to item range + resume job ID input - pb-init-v3.sh: adds ai_jobs collection (idempotent) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ var _ bookstore.PresignStore = (*Store)(nil)
|
||||
var _ bookstore.ProgressStore = (*Store)(nil)
|
||||
var _ bookstore.CoverStore = (*Store)(nil)
|
||||
var _ bookstore.TranslationStore = (*Store)(nil)
|
||||
var _ bookstore.AIJobStore = (*Store)(nil)
|
||||
var _ taskqueue.Producer = (*Store)(nil)
|
||||
var _ taskqueue.Consumer = (*Store)(nil)
|
||||
var _ taskqueue.Reader = (*Store)(nil)
|
||||
@@ -1063,3 +1064,107 @@ func (s *Store) GetTranslation(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ── AIJobStore ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) CreateAIJob(ctx context.Context, job domain.AIJob) (string, error) {
|
||||
payload := map[string]any{
|
||||
"kind": job.Kind,
|
||||
"slug": job.Slug,
|
||||
"status": string(job.Status),
|
||||
"from_item": job.FromItem,
|
||||
"to_item": job.ToItem,
|
||||
"items_done": job.ItemsDone,
|
||||
"items_total": job.ItemsTotal,
|
||||
"model": job.Model,
|
||||
"payload": job.Payload,
|
||||
"started": job.Started.Format(time.RFC3339),
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.pb.post(ctx, "/api/collections/ai_jobs/records", payload, &out); err != nil {
|
||||
return "", fmt.Errorf("CreateAIJob: %w", err)
|
||||
}
|
||||
return out.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error) {
|
||||
var raw json.RawMessage
|
||||
if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), &raw); err != nil {
|
||||
if strings.Contains(err.Error(), "404") {
|
||||
return domain.AIJob{}, false, nil
|
||||
}
|
||||
return domain.AIJob{}, false, fmt.Errorf("GetAIJob: %w", err)
|
||||
}
|
||||
job, err := parseAIJob(raw)
|
||||
if err != nil {
|
||||
return domain.AIJob{}, false, err
|
||||
}
|
||||
return job, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAIJob(ctx context.Context, id string, fields map[string]any) error {
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), fields)
|
||||
}
|
||||
|
||||
func (s *Store) ListAIJobs(ctx context.Context) ([]domain.AIJob, error) {
|
||||
items, err := s.pb.listAll(ctx, "ai_jobs", "", "-started")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListAIJobs: %w", err)
|
||||
}
|
||||
out := make([]domain.AIJob, 0, len(items))
|
||||
for _, raw := range items {
|
||||
j, err := parseAIJob(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseAIJob(raw json.RawMessage) (domain.AIJob, error) {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
FromItem int `json:"from_item"`
|
||||
ToItem int `json:"to_item"`
|
||||
ItemsDone int `json:"items_done"`
|
||||
ItemsTotal int `json:"items_total"`
|
||||
Model string `json:"model"`
|
||||
Payload string `json:"payload"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
HeartbeatAt string `json:"heartbeat_at"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return domain.AIJob{}, fmt.Errorf("parseAIJob: %w", err)
|
||||
}
|
||||
parseT := func(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, _ := time.Parse(time.RFC3339, s)
|
||||
return t
|
||||
}
|
||||
return domain.AIJob{
|
||||
ID: r.ID,
|
||||
Kind: r.Kind,
|
||||
Slug: r.Slug,
|
||||
Status: domain.TaskStatus(r.Status),
|
||||
FromItem: r.FromItem,
|
||||
ToItem: r.ToItem,
|
||||
ItemsDone: r.ItemsDone,
|
||||
ItemsTotal: r.ItemsTotal,
|
||||
Model: r.Model,
|
||||
Payload: r.Payload,
|
||||
ErrorMessage: r.ErrorMessage,
|
||||
Started: parseT(r.Started),
|
||||
Finished: parseT(r.Finished),
|
||||
HeartbeatAt: parseT(r.HeartbeatAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user