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
@@ -0,0 +1,116 @@
package cache
import (
"context"
"fmt"
"time"
"img_infinite_canvas/internal/domain/design"
)
const projectListKeyPrefix = "design:projects:list"
type ProjectRepository struct {
next design.Repository
store Store
ttl time.Duration
}
func NewProjectRepository(next design.Repository, store Store, ttl time.Duration) *ProjectRepository {
return &ProjectRepository{
next: next,
store: store,
ttl: ttl,
}
}
func (r *ProjectRepository) List(ctx context.Context) ([]design.ProjectSummary, error) {
var projects []design.ProjectSummary
ok, err := r.store.Get(ctx, projectListKey(ctx), &projects)
if err == nil && ok {
return projects, nil
}
projects, err = r.next.List(ctx)
if err != nil {
return nil, err
}
_ = r.store.Set(ctx, projectListKey(ctx), projects, r.ttl)
return projects, nil
}
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) {
var project design.Project
ok, err := r.store.Get(ctx, projectKey(ctx, id), &project)
if err == nil && ok {
return project, nil
}
project, err = r.next.Get(ctx, id)
if err != nil {
return design.Project{}, err
}
_ = r.store.Set(ctx, projectKey(ctx, id), project, r.ttl)
return project, nil
}
func (r *ProjectRepository) Save(ctx context.Context, project design.Project) error {
if err := r.next.Save(ctx, project); err != nil {
return err
}
_ = r.store.Set(ctx, projectKey(ctx, project.ID), project, r.ttl)
_ = r.store.Delete(ctx, projectListKey(ctx))
return nil
}
func (r *ProjectRepository) SaveCanvas(ctx context.Context, project design.Project, baseVersion string) error {
next, ok := r.next.(design.CanvasRepository)
if !ok {
return r.Save(ctx, project)
}
if err := next.SaveCanvas(ctx, project, baseVersion); err != nil {
return err
}
_ = r.store.Delete(ctx, projectKey(ctx, project.ID))
_ = r.store.Delete(ctx, projectListKey(ctx))
return nil
}
func (r *ProjectRepository) Delete(ctx context.Context, id string) error {
if err := r.next.Delete(ctx, id); err != nil {
return err
}
_ = r.store.Delete(ctx, projectKey(ctx, id))
_ = r.store.Delete(ctx, projectListKey(ctx))
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 projectListKey(ctx context.Context) string {
return fmt.Sprintf("%s:%s", projectListKeyPrefix, design.UserIDFromContext(ctx))
}
func projectKey(ctx context.Context, id string) string {
return fmt.Sprintf("design:projects:%s:%s", design.UserIDFromContext(ctx), id)
}
@@ -0,0 +1,87 @@
package cache
import (
"context"
"testing"
"time"
"img_infinite_canvas/internal/domain/design"
"img_infinite_canvas/internal/infrastructure/memory"
)
func TestProjectRepositoryCachesProjectReads(t *testing.T) {
ctx := context.Background()
base := memory.NewProjectRepository()
store := NewMemoryStore()
repo := NewProjectRepository(base, store, time.Minute)
project := design.Project{
ID: "project-1",
Title: "Original",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Now(),
}
if err := base.Save(ctx, project); err != nil {
t.Fatal(err)
}
first, err := repo.Get(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
project.Title = "Changed behind cache"
if err := base.Save(ctx, project); err != nil {
t.Fatal(err)
}
second, err := repo.Get(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
if second.Title != first.Title {
t.Fatalf("expected cached title %q, got %q", first.Title, second.Title)
}
}
func TestProjectRepositoryInvalidatesListOnSave(t *testing.T) {
ctx := context.Background()
base := memory.NewProjectRepository()
repo := NewProjectRepository(base, NewMemoryStore(), time.Minute)
if err := repo.Save(ctx, design.Project{
ID: "project-1",
Title: "First",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
first, err := repo.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(first) != 1 {
t.Fatalf("expected first list length 1, got %d", len(first))
}
if err := repo.Save(ctx, design.Project{
ID: "project-2",
Title: "Second",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
second, err := repo.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(second) != 2 {
t.Fatalf("expected invalidated list length 2, got %d", len(second))
}
}
+57
View File
@@ -0,0 +1,57 @@
package cache
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
type RedisOptions struct {
Addr string
Password string
DB int
}
type RedisStore struct {
client *redis.Client
}
func NewRedisStore(ctx context.Context, opts RedisOptions) (*RedisStore, error) {
client := redis.NewClient(&redis.Options{
Addr: opts.Addr,
Password: opts.Password,
DB: opts.DB,
})
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return &RedisStore{client: client}, nil
}
func (s *RedisStore) Get(ctx context.Context, key string, target any) (bool, error) {
value, err := s.client.Get(ctx, key).Bytes()
if err != nil {
if err == redis.Nil {
return false, nil
}
return false, err
}
if err := json.Unmarshal(value, target); err != nil {
return false, err
}
return true, nil
}
func (s *RedisStore) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return s.client.Set(ctx, key, data, ttl).Err()
}
func (s *RedisStore) Delete(ctx context.Context, key string) error {
return s.client.Del(ctx, key).Err()
}
+89
View File
@@ -0,0 +1,89 @@
package cache
import (
"context"
"encoding/json"
"errors"
"sync"
"time"
)
var ErrUnsupportedDriver = errors.New("unsupported cache driver")
type Store interface {
Get(ctx context.Context, key string, target any) (bool, error)
Set(ctx context.Context, key string, value any, ttl time.Duration) error
Delete(ctx context.Context, key string) error
}
type memoryItem struct {
value []byte
expiresAt time.Time
}
type MemoryStore struct {
mu sync.RWMutex
items map[string]memoryItem
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
items: make(map[string]memoryItem),
}
}
func (s *MemoryStore) Get(ctx context.Context, key string, target any) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
s.mu.RLock()
item, ok := s.items[key]
s.mu.RUnlock()
if !ok {
return false, nil
}
if !item.expiresAt.IsZero() && time.Now().After(item.expiresAt) {
s.mu.Lock()
delete(s.items, key)
s.mu.Unlock()
return false, nil
}
if err := json.Unmarshal(item.value, target); err != nil {
return false, err
}
return true, nil
}
func (s *MemoryStore) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
data, err := json.Marshal(value)
if err != nil {
return err
}
var expiresAt time.Time
if ttl > 0 {
expiresAt = time.Now().Add(ttl)
}
s.mu.Lock()
s.items[key] = memoryItem{value: data, expiresAt: expiresAt}
s.mu.Unlock()
return nil
}
func (s *MemoryStore) Delete(ctx context.Context, key string) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
delete(s.items, key)
s.mu.Unlock()
return nil
}