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,219 @@
package memory
import (
"context"
"sync"
"img_infinite_canvas/internal/domain/design"
)
type ProjectRepository struct {
mu sync.RWMutex
projects map[string]design.Project
mockupBlobs map[string]design.MockupBlobData
}
func NewProjectRepository() *ProjectRepository {
return &ProjectRepository{
projects: make(map[string]design.Project),
mockupBlobs: make(map[string]design.MockupBlobData),
}
}
func (r *ProjectRepository) List(ctx context.Context) ([]design.ProjectSummary, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]design.ProjectSummary, 0, len(r.projects))
userID := design.UserIDFromContext(ctx)
for _, project := range r.projects {
if project.UserID == "" {
project.UserID = design.DefaultUserID
}
if project.UserID != userID {
continue
}
items = append(items, design.ProjectSummary{
ID: project.ID,
UserID: project.UserID,
Title: project.Title,
Brief: project.Brief,
Status: project.Status,
Thumbnail: project.Thumbnail,
UpdatedAt: project.UpdatedAt,
})
}
return items, nil
}
func (r *ProjectRepository) ListPage(ctx context.Context, limit int64, offset int64) ([]design.ProjectSummary, error) {
items, err := r.List(ctx)
if err != nil {
return nil, err
}
design.SortSummaries(items)
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = int64(len(items))
}
start := int(offset)
if start >= len(items) {
return []design.ProjectSummary{}, nil
}
end := start + int(limit)
if end > len(items) {
end = len(items)
}
return items[start:end], nil
}
func (r *ProjectRepository) Get(ctx context.Context, id string) (design.Project, error) {
if err := ctx.Err(); err != nil {
return design.Project{}, err
}
r.mu.RLock()
defer r.mu.RUnlock()
project, ok := r.projects[id]
if !ok {
return design.Project{}, design.ErrNotFound
}
if project.UserID == "" {
project.UserID = design.DefaultUserID
}
if project.UserID != design.UserIDFromContext(ctx) {
return design.Project{}, design.ErrNotFound
}
return cloneProject(project), nil
}
func (r *ProjectRepository) Save(ctx context.Context, project design.Project) error {
if err := ctx.Err(); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
project.UserID = repositoryUserID(ctx, project.UserID)
r.projects[project.ID] = cloneProject(project)
return nil
}
func (r *ProjectRepository) SaveCanvas(ctx context.Context, project design.Project, baseVersion string) error {
if err := ctx.Err(); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
userID := repositoryUserID(ctx, project.UserID)
current, ok := r.projects[project.ID]
if !ok {
return design.ErrNotFound
}
if current.UserID == "" {
current.UserID = design.DefaultUserID
}
if current.UserID != userID {
return design.ErrNotFound
}
if baseVersion != "" && current.Version != "" && baseVersion != current.Version {
return design.ErrConflict
}
current.UserID = userID
current.Title = project.Title
current.Brief = project.Brief
current.Status = project.Status
current.Thumbnail = project.Thumbnail
current.UpdatedAt = project.UpdatedAt
current.Version = project.Version
current.SnapshotID = project.SnapshotID
current.Canvas = project.Canvas
current.LastThreadID = project.LastThreadID
current.Viewport = project.Viewport
current.Nodes = append([]design.Node(nil), project.Nodes...)
current.Connections = append([]design.Connection(nil), project.Connections...)
r.projects[project.ID] = cloneProject(current)
return nil
}
func (r *ProjectRepository) Delete(ctx context.Context, id string) error {
if err := ctx.Err(); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.projects[id]; !ok {
return design.ErrNotFound
}
project := r.projects[id]
if project.UserID == "" {
project.UserID = design.DefaultUserID
}
if project.UserID != design.UserIDFromContext(ctx) {
return design.ErrNotFound
}
delete(r.projects, id)
return nil
}
func (r *ProjectRepository) SaveMockupBlob(ctx context.Context, blob design.MockupBlobData) error {
if err := ctx.Err(); err != nil {
return err
}
if blob.DataID == "" {
return design.ErrInvalidInput
}
r.mu.Lock()
defer r.mu.Unlock()
r.mockupBlobs[blob.DataID] = cloneMockupBlob(blob)
return nil
}
func (r *ProjectRepository) GetMockupBlob(ctx context.Context, dataID string) (design.MockupBlobData, error) {
if err := ctx.Err(); err != nil {
return design.MockupBlobData{}, err
}
r.mu.RLock()
defer r.mu.RUnlock()
blob, ok := r.mockupBlobs[dataID]
if !ok {
return design.MockupBlobData{}, design.ErrNotFound
}
return cloneMockupBlob(blob), nil
}
func repositoryUserID(ctx context.Context, userID string) string {
if userID != "" {
return design.NormalizeUserID(userID)
}
return design.UserIDFromContext(ctx)
}
func cloneProject(project design.Project) design.Project {
project.UserID = repositoryUserID(context.Background(), project.UserID)
project.Nodes = append([]design.Node(nil), project.Nodes...)
project.Connections = append([]design.Connection(nil), project.Connections...)
project.Messages = append([]design.Message(nil), project.Messages...)
return project
}
func cloneMockupBlob(blob design.MockupBlobData) design.MockupBlobData {
blob.Data = append([]byte(nil), blob.Data...)
return blob
}
@@ -0,0 +1,73 @@
package memory
import (
"context"
"errors"
"testing"
"time"
"img_infinite_canvas/internal/domain/design"
)
func TestSaveCanvasPreservesMessages(t *testing.T) {
ctx := context.Background()
repo := NewProjectRepository()
project := design.Project{
ID: "project-canvas-save",
UserID: design.DefaultUserID,
Title: "Canvas save",
Status: design.StatusReady,
Version: "v1",
UpdatedAt: time.Now(),
Messages: []design.Message{{ID: "message-1", Role: "assistant", Content: "keep me"}},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
canvasProject := design.Project{
ID: project.ID,
UserID: project.UserID,
Title: project.Title,
Status: design.StatusReady,
Version: "v2",
SnapshotID: "snapshot-2",
UpdatedAt: time.Now(),
Viewport: design.Viewport{K: 1},
Nodes: []design.Node{{ID: "node-1", Type: design.NodeTypeFrame}},
}
if err := repo.SaveCanvas(ctx, canvasProject, "v1"); err != nil {
t.Fatal(err)
}
saved, err := repo.Get(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
if len(saved.Messages) != 1 || saved.Messages[0].ID != "message-1" {
t.Fatalf("expected existing messages to be preserved, got %#v", saved.Messages)
}
if len(saved.Nodes) != 1 || saved.Nodes[0].ID != "node-1" {
t.Fatalf("expected canvas nodes to update, got %#v", saved.Nodes)
}
}
func TestSaveCanvasRejectsStaleVersion(t *testing.T) {
ctx := context.Background()
repo := NewProjectRepository()
project := design.Project{
ID: "project-stale-canvas-save",
UserID: design.DefaultUserID,
Title: "Canvas save",
Status: design.StatusReady,
Version: "v2",
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
err := repo.SaveCanvas(ctx, design.Project{ID: project.ID, UserID: project.UserID, Version: "v3"}, "v1")
if !errors.Is(err, design.ErrConflict) {
t.Fatalf("expected version conflict, got %v", err)
}
}