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>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
ThreadID string `json:"threadId,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Bus interface {
|
||||
Publish(ctx context.Context, event Event) error
|
||||
Subscribe(ctx context.Context, projectID string) (Subscription, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Subscription interface {
|
||||
C() <-chan Event
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type MemoryBus struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[string]map[*memorySubscription]struct{}
|
||||
}
|
||||
|
||||
type memorySubscription struct {
|
||||
bus *MemoryBus
|
||||
projectID string
|
||||
ch chan Event
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func NewMemoryBus() *MemoryBus {
|
||||
return &MemoryBus{subscribers: make(map[string]map[*memorySubscription]struct{})}
|
||||
}
|
||||
|
||||
func (b *MemoryBus) Publish(ctx context.Context, event Event) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
b.mu.RLock()
|
||||
subscribers := make([]*memorySubscription, 0, len(b.subscribers[event.ProjectID]))
|
||||
for sub := range b.subscribers[event.ProjectID] {
|
||||
subscribers = append(subscribers, sub)
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
for _, sub := range subscribers {
|
||||
select {
|
||||
case sub.ch <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *MemoryBus) Subscribe(ctx context.Context, projectID string) (Subscription, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub := &memorySubscription{
|
||||
bus: b,
|
||||
projectID: projectID,
|
||||
ch: make(chan Event, 32),
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.subscribers[projectID] == nil {
|
||||
b.subscribers[projectID] = make(map[*memorySubscription]struct{})
|
||||
}
|
||||
b.subscribers[projectID][sub] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = sub.Close()
|
||||
}()
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (b *MemoryBus) Close() error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for _, subscribers := range b.subscribers {
|
||||
for sub := range subscribers {
|
||||
sub.once.Do(func() {
|
||||
sub.closeLocked()
|
||||
})
|
||||
}
|
||||
}
|
||||
b.subscribers = make(map[string]map[*memorySubscription]struct{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memorySubscription) C() <-chan Event {
|
||||
return s.ch
|
||||
}
|
||||
|
||||
func (s *memorySubscription) Close() error {
|
||||
s.once.Do(func() {
|
||||
s.bus.mu.Lock()
|
||||
defer s.bus.mu.Unlock()
|
||||
s.closeLocked()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memorySubscription) closeLocked() {
|
||||
if subscribers := s.bus.subscribers[s.projectID]; subscribers != nil {
|
||||
delete(subscribers, s)
|
||||
if len(subscribers) == 0 {
|
||||
delete(s.bus.subscribers, s.projectID)
|
||||
}
|
||||
}
|
||||
close(s.ch)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOutboxInterval = time.Second
|
||||
defaultOutboxBatch = 100
|
||||
)
|
||||
|
||||
type OutboxOptions struct {
|
||||
DataSource string
|
||||
IntervalMillis int
|
||||
BatchSize int
|
||||
}
|
||||
|
||||
type OutboxPublisher struct {
|
||||
pool *pgxpool.Pool
|
||||
bus Bus
|
||||
interval time.Duration
|
||||
batch int
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewOutboxPublisher(ctx context.Context, opts OutboxOptions, bus Bus) (*OutboxPublisher, error) {
|
||||
dataSource := strings.TrimSpace(opts.DataSource)
|
||||
if dataSource == "" || bus == nil {
|
||||
return nil, nil
|
||||
}
|
||||
pool, err := pgxpool.New(ctx, dataSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
interval := time.Duration(opts.IntervalMillis) * time.Millisecond
|
||||
if interval <= 0 {
|
||||
interval = defaultOutboxInterval
|
||||
}
|
||||
batch := opts.BatchSize
|
||||
if batch <= 0 {
|
||||
batch = defaultOutboxBatch
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
return &OutboxPublisher{
|
||||
pool: pool,
|
||||
bus: bus,
|
||||
interval: interval,
|
||||
batch: batch,
|
||||
ctx: runCtx,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OutboxPublisher) Start() error {
|
||||
if p == nil || p.pool == nil {
|
||||
return nil
|
||||
}
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
p.loop()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OutboxPublisher) Run() error {
|
||||
if p == nil || p.pool == nil {
|
||||
return nil
|
||||
}
|
||||
p.loop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OutboxPublisher) Shutdown() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
}
|
||||
p.wg.Wait()
|
||||
if p.pool != nil {
|
||||
p.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OutboxPublisher) loop() {
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := p.publishPending(p.ctx); err != nil && p.ctx.Err() == nil {
|
||||
logx.Errorf("publish outbox events failed: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OutboxPublisher) publishPending(ctx context.Context) error {
|
||||
tx, err := p.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id, user_id, aggregate_id, event_type, thread_id, payload_json, created_at
|
||||
FROM outbox_events
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`, p.batch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (Event, error) {
|
||||
var event Event
|
||||
var payload []byte
|
||||
if err := row.Scan(&event.ID, &event.UserID, &event.ProjectID, &event.Type, &event.ThreadID, &payload, &event.CreatedAt); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
event.Payload = json.RawMessage(payload)
|
||||
return event, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range events {
|
||||
if err := p.bus.Publish(ctx, event); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE outbox_events
|
||||
SET status = 'published', published_at = NOW()
|
||||
WHERE id = $1
|
||||
`, event.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type ProjectRepository struct {
|
||||
next design.Repository
|
||||
bus Bus
|
||||
}
|
||||
|
||||
func NewProjectRepository(next design.Repository, bus Bus) *ProjectRepository {
|
||||
return &ProjectRepository{next: next, bus: bus}
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) List(ctx context.Context) ([]design.ProjectSummary, error) {
|
||||
return r.next.List(ctx)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) ListPage(ctx context.Context, limit int64, offset int64) ([]design.ProjectSummary, error) {
|
||||
return r.next.ListPage(ctx, limit, offset)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Get(ctx context.Context, id string) (design.Project, error) {
|
||||
return r.next.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Save(ctx context.Context, project design.Project) error {
|
||||
if err := r.next.Save(ctx, project); err != nil {
|
||||
return err
|
||||
}
|
||||
r.publish(ctx, project, "project.updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) SaveCanvas(ctx context.Context, project design.Project, baseVersion string) error {
|
||||
if next, ok := r.next.(design.CanvasRepository); ok {
|
||||
if err := next.SaveCanvas(ctx, project, baseVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
r.publish(ctx, project, "canvas.snapshot.created")
|
||||
return nil
|
||||
}
|
||||
return r.Save(ctx, project)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Delete(ctx context.Context, id string) error {
|
||||
project, err := r.next.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.next.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
r.publish(ctx, project, "project.deleted")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) SaveMockupBlob(ctx context.Context, blob design.MockupBlobData) error {
|
||||
next, ok := r.next.(design.MockupBlobRepository)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return next.SaveMockupBlob(ctx, blob)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetMockupBlob(ctx context.Context, dataID string) (design.MockupBlobData, error) {
|
||||
next, ok := r.next.(design.MockupBlobRepository)
|
||||
if !ok {
|
||||
return design.MockupBlobData{}, design.ErrNotFound
|
||||
}
|
||||
return next.GetMockupBlob(ctx, dataID)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) publish(ctx context.Context, project design.Project, eventType string) {
|
||||
if r.bus == nil {
|
||||
return
|
||||
}
|
||||
event := projectEvent(project, eventType)
|
||||
if err := r.bus.Publish(ctx, event); err != nil {
|
||||
logx.Errorf("publish realtime event failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func projectEvent(project design.Project, eventType string) Event {
|
||||
createdAt := project.UpdatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now()
|
||||
}
|
||||
eventID := uuid.NewString()
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"id": eventID,
|
||||
"type": eventType,
|
||||
"userId": project.UserID,
|
||||
"projectId": project.ID,
|
||||
"threadId": project.LastThreadID,
|
||||
"version": project.Version,
|
||||
"snapshotId": project.SnapshotID,
|
||||
"createdAt": createdAt.UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
return Event{
|
||||
ID: eventID,
|
||||
UserID: design.NormalizeUserID(project.UserID),
|
||||
ProjectID: project.ID,
|
||||
ThreadID: project.LastThreadID,
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type RedisOptions struct {
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
Prefix string
|
||||
}
|
||||
|
||||
type RedisBus struct {
|
||||
client *redis.Client
|
||||
prefix string
|
||||
}
|
||||
|
||||
type redisSubscription struct {
|
||||
pubsub *redis.PubSub
|
||||
ch chan Event
|
||||
closeOnce sync.Once
|
||||
chOnce sync.Once
|
||||
}
|
||||
|
||||
func NewRedisBus(ctx context.Context, opts RedisOptions) (*RedisBus, error) {
|
||||
opts.Addr = strings.TrimSpace(opts.Addr)
|
||||
if opts.Addr == "" {
|
||||
opts.Addr = "localhost:6379"
|
||||
}
|
||||
prefix := strings.TrimSpace(opts.Prefix)
|
||||
if prefix == "" {
|
||||
prefix = "canvas:realtime"
|
||||
}
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: opts.Addr,
|
||||
Password: opts.Password,
|
||||
DB: opts.DB,
|
||||
})
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &RedisBus{client: client, prefix: prefix}, nil
|
||||
}
|
||||
|
||||
func (b *RedisBus) Publish(ctx context.Context, event Event) error {
|
||||
if b == nil || b.client == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.client.Publish(ctx, b.channel(event.ProjectID), data).Err()
|
||||
}
|
||||
|
||||
func (b *RedisBus) Subscribe(ctx context.Context, projectID string) (Subscription, error) {
|
||||
if b == nil || b.client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
pubsub := b.client.Subscribe(ctx, b.channel(projectID))
|
||||
if _, err := pubsub.Receive(ctx); err != nil {
|
||||
_ = pubsub.Close()
|
||||
return nil, err
|
||||
}
|
||||
sub := &redisSubscription{
|
||||
pubsub: pubsub,
|
||||
ch: make(chan Event, 32),
|
||||
}
|
||||
go sub.run(ctx)
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (b *RedisBus) Close() error {
|
||||
if b == nil || b.client == nil {
|
||||
return nil
|
||||
}
|
||||
return b.client.Close()
|
||||
}
|
||||
|
||||
func (b *RedisBus) channel(projectID string) string {
|
||||
return fmt.Sprintf("%s:project:%s", b.prefix, strings.TrimSpace(projectID))
|
||||
}
|
||||
|
||||
func (s *redisSubscription) C() <-chan Event {
|
||||
return s.ch
|
||||
}
|
||||
|
||||
func (s *redisSubscription) Close() error {
|
||||
var err error
|
||||
s.closeOnce.Do(func() {
|
||||
err = s.pubsub.Close()
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *redisSubscription) run(ctx context.Context) {
|
||||
defer s.closeChannel()
|
||||
defer s.Close()
|
||||
for {
|
||||
msg, err := s.pubsub.ReceiveMessage(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var event Event
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case s.ch <- event:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisSubscription) closeChannel() {
|
||||
s.chOnce.Do(func() {
|
||||
close(s.ch)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user