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:
2026-07-07 23:15:37 +08:00
commit 44406b72db
349 changed files with 73265 additions and 0 deletions
+28
View File
@@ -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)
})
}