Files
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project;
the project page provides an infinite canvas with node drag, zoom/pan,
a design chat panel, history replay, image asset upload, and project
save/regenerate.

- Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory
  or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage;
  sky-valley/pi agent runtime adapter.
- Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n,
  shadcn/ui components, auth (OTP/Turnstile/Google/WeChat).
- Deploy: Docker Compose and k3s manifests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:15:37 +08:00

267 lines
6.7 KiB
Go

package job
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"img_infinite_canvas/internal/domain/design"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"github.com/zeromicro/go-zero/core/logx"
)
const (
defaultQueueName = "default"
defaultConcurrency = 10
defaultMaxRetry = 3
defaultTimeout = 10 * time.Minute
defaultShutdownTimeout = 30 * time.Second
defaultCompletedTaskRetention = 24 * time.Hour
)
var ErrMissingProcessor = errors.New("job processor is required")
type AsynqOptions struct {
Addr string
Password string
DB int
Queue string
Concurrency int
MaxRetry int
TimeoutSeconds int
ShutdownTimeoutSeconds int
}
type AsynqQueue struct {
client *asynq.Client
queue string
maxRetry int
timeout time.Duration
retention time.Duration
}
type AsynqWorker struct {
server *asynq.Server
mux *asynq.ServeMux
}
func NewAsynqQueue(ctx context.Context, opts AsynqOptions) (*AsynqQueue, error) {
normalized := normalizeAsynqOptions(opts)
if err := pingRedis(ctx, normalized); err != nil {
return nil, err
}
return &AsynqQueue{
client: asynq.NewClient(redisClientOpt(normalized)),
queue: normalized.Queue,
maxRetry: normalized.MaxRetry,
timeout: timeoutDuration(normalized),
retention: defaultCompletedTaskRetention,
}, nil
}
func (q *AsynqQueue) Enqueue(ctx context.Context, job design.Job) error {
if job.Kind == "" {
return fmt.Errorf("%w: job kind is required", design.ErrInvalidInput)
}
payload, err := json.Marshal(job)
if err != nil {
return err
}
task := asynq.NewTask(string(job.Kind), payload)
_, err = q.client.EnqueueContext(
ctx,
task,
asynq.Queue(q.queue),
asynq.MaxRetry(maxRetryForJob(job, q.maxRetry)),
asynq.Timeout(q.timeout),
asynq.Retention(q.retention),
)
return err
}
func (q *AsynqQueue) Close() error {
if q == nil || q.client == nil {
return nil
}
return q.client.Close()
}
func NewAsynqWorker(opts AsynqOptions, processor design.JobProcessor) (*AsynqWorker, error) {
if processor == nil {
return nil, ErrMissingProcessor
}
normalized := normalizeAsynqOptions(opts)
server := asynq.NewServer(redisClientOpt(normalized), asynq.Config{
Concurrency: normalized.Concurrency,
Queues: map[string]int{normalized.Queue: 1},
ShutdownTimeout: shutdownTimeoutDuration(normalized),
ErrorHandler: asynq.ErrorHandlerFunc(func(ctx context.Context, task *asynq.Task, err error) {
handleAsynqTaskFailure(ctx, task, err, processor)
}),
})
mux := asynq.NewServeMux()
worker := &AsynqWorker{server: server, mux: mux}
for _, kind := range knownJobKinds() {
mux.HandleFunc(string(kind), worker.handle(processor))
}
return worker, nil
}
func (w *AsynqWorker) Start() error {
if w == nil || w.server == nil || w.mux == nil {
return nil
}
return w.server.Start(w.mux)
}
func (w *AsynqWorker) Run() error {
if w == nil || w.server == nil || w.mux == nil {
return nil
}
return w.server.Run(w.mux)
}
func (w *AsynqWorker) Shutdown() {
if w == nil || w.server == nil {
return
}
w.server.Shutdown()
}
func (w *AsynqWorker) handle(processor design.JobProcessor) func(context.Context, *asynq.Task) error {
return func(ctx context.Context, task *asynq.Task) error {
job, err := decodeTaskJob(task)
if err != nil {
return err
}
return processor.ProcessJob(ctx, job)
}
}
func handleAsynqTaskFailure(ctx context.Context, task *asynq.Task, failure error, processor design.JobProcessor) {
retried, retryCountOK := asynq.GetRetryCount(ctx)
maxRetry, maxRetryOK := asynq.GetMaxRetry(ctx)
logx.Errorf("asynq task %s failed retry=%d/%d: %v", task.Type(), retried, maxRetry, failure)
handler, ok := processor.(design.JobFailureHandler)
if !ok {
return
}
job, err := decodeTaskJob(task)
if err != nil {
logx.Errorf("decode failed asynq task %s payload: %v", task.Type(), err)
return
}
if !shouldNotifyJobFailure(job, retried, maxRetry, retryCountOK && maxRetryOK) {
return
}
if err := handler.HandleJobFailure(context.Background(), job, failure); err != nil {
logx.Errorf("handle failed asynq task %s failure: %v", task.Type(), err)
}
}
func decodeTaskJob(task *asynq.Task) (design.Job, error) {
var job design.Job
if err := json.Unmarshal(task.Payload(), &job); err != nil {
return design.Job{}, err
}
taskKind := design.JobKind(task.Type())
if job.Kind == "" {
job.Kind = taskKind
}
if job.Kind != taskKind {
return design.Job{}, fmt.Errorf("%w: payload kind %s does not match task type %s", design.ErrInvalidInput, job.Kind, taskKind)
}
return job, nil
}
func shouldNotifyJobFailure(job design.Job, retried int, maxRetry int, retryMetadataOK bool) bool {
if isVisualGenerationJob(job.Kind) {
return true
}
return retryMetadataOK && maxRetry >= 0 && retried >= maxRetry
}
func maxRetryForJob(job design.Job, fallback int) int {
if isVisualGenerationJob(job.Kind) {
return 0
}
return fallback
}
func isVisualGenerationJob(kind design.JobKind) bool {
switch kind {
case design.JobCreateProjectGeneration, design.JobFollowupGeneration, design.JobNodeActionGeneration, design.JobMockupModelGeneration:
return true
default:
return false
}
}
func knownJobKinds() []design.JobKind {
return []design.JobKind{
design.JobCreateProjectGeneration,
design.JobFollowupGeneration,
design.JobNodeActionGeneration,
design.JobAgentConversation,
design.JobTextExtraction,
design.JobMockupModelGeneration,
design.JobLayerMergeGeneration,
design.JobAssetCleanup,
}
}
func normalizeAsynqOptions(opts AsynqOptions) AsynqOptions {
opts.Addr = strings.TrimSpace(opts.Addr)
opts.Queue = strings.TrimSpace(opts.Queue)
if opts.Queue == "" {
opts.Queue = defaultQueueName
}
if opts.Concurrency <= 0 {
opts.Concurrency = defaultConcurrency
}
if opts.MaxRetry < 0 {
opts.MaxRetry = 0
}
if opts.MaxRetry == 0 {
opts.MaxRetry = defaultMaxRetry
}
return opts
}
func redisClientOpt(opts AsynqOptions) asynq.RedisClientOpt {
return asynq.RedisClientOpt{
Addr: opts.Addr,
Password: opts.Password,
DB: opts.DB,
}
}
func timeoutDuration(opts AsynqOptions) time.Duration {
if opts.TimeoutSeconds <= 0 {
return defaultTimeout
}
return time.Duration(opts.TimeoutSeconds) * time.Second
}
func shutdownTimeoutDuration(opts AsynqOptions) time.Duration {
if opts.ShutdownTimeoutSeconds <= 0 {
return defaultShutdownTimeout
}
return time.Duration(opts.ShutdownTimeoutSeconds) * time.Second
}
func pingRedis(ctx context.Context, opts AsynqOptions) error {
client := redis.NewClient(&redis.Options{
Addr: opts.Addr,
Password: opts.Password,
DB: opts.DB,
})
defer client.Close()
return client.Ping(ctx).Err()
}