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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user