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,363 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
assetCleanupWorkers = 5
|
||||
assetCleanupQueueSize = 4096
|
||||
assetCleanupMaxRetries = 3
|
||||
assetCleanupTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
promptImageTokenPattern = regexp.MustCompile(`\[@image:#\d+:[^:\]]+:(?:\[[^\]]+\]\(([^)]+)\)|([^\]]+))\]`)
|
||||
promptImageDirectivePattern = regexp.MustCompile(`(?i)(?:参考图|Reference image)\s*\d?\s*(?:[((][^))\n]+[))])?\s*[::]\s*(https?://[^\s\]]+)`)
|
||||
messageImageURLPattern = regexp.MustCompile(`(?i)(https?://[^\s"'<>)\]]*\.(?:png|jpe?g|webp|gif|svg)(?:\?[^\s"'<>)\]]*)?|/[^\s"'<>)\]]*\.(?:png|jpe?g|webp|gif|svg)(?:\?[^\s"'<>)\]]*)?)`)
|
||||
)
|
||||
|
||||
type assetCleanupQueue struct {
|
||||
storage design.AssetStorage
|
||||
jobs chan string
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func newAssetCleanupQueue(storage design.AssetStorage) *assetCleanupQueue {
|
||||
if storage == nil {
|
||||
return nil
|
||||
}
|
||||
queue := &assetCleanupQueue{
|
||||
storage: storage,
|
||||
jobs: make(chan string, assetCleanupQueueSize),
|
||||
maxRetries: assetCleanupMaxRetries,
|
||||
}
|
||||
for i := 0; i < assetCleanupWorkers; i++ {
|
||||
go queue.run()
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
func (s *DesignService) enqueueAssetDeletion(publicURLs []string) {
|
||||
publicURLs = normalizeAssetURLs(publicURLs)
|
||||
if len(publicURLs) == 0 {
|
||||
return
|
||||
}
|
||||
if s.jobs != nil {
|
||||
if err := s.jobs.Enqueue(context.Background(), design.Job{
|
||||
Kind: design.JobAssetCleanup,
|
||||
PublicURLs: publicURLs,
|
||||
}); err == nil {
|
||||
return
|
||||
} else {
|
||||
logx.Errorf("enqueue asset cleanup failed: %v", err)
|
||||
}
|
||||
}
|
||||
if s.assetCleanup == nil {
|
||||
return
|
||||
}
|
||||
s.assetCleanup.enqueue(publicURLs)
|
||||
}
|
||||
|
||||
func (q *assetCleanupQueue) enqueue(publicURLs []string) {
|
||||
for _, publicURL := range normalizeAssetURLs(publicURLs) {
|
||||
select {
|
||||
case q.jobs <- publicURL:
|
||||
default:
|
||||
go q.enqueueSlow(publicURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *assetCleanupQueue) enqueueSlow(publicURL string) {
|
||||
select {
|
||||
case q.jobs <- publicURL:
|
||||
case <-time.After(2 * time.Second):
|
||||
logx.Errorf("asset cleanup queue is full, dropping delete job for %s", publicURL)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *assetCleanupQueue) run() {
|
||||
for publicURL := range q.jobs {
|
||||
if err := q.deleteWithRetry(publicURL); err != nil {
|
||||
logx.Errorf("asset cleanup failed for %s: %v", publicURL, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *assetCleanupQueue) deleteWithRetry(publicURL string) error {
|
||||
var err error
|
||||
for attempt := 1; attempt <= q.maxRetries; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), assetCleanupTimeout)
|
||||
err = q.storage.DeleteObject(ctx, publicURL)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(time.Duration(attempt*attempt) * 200 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *DesignService) deleteAssetURLsWithRetry(publicURLs []string) error {
|
||||
if s.assets == nil {
|
||||
return nil
|
||||
}
|
||||
var failures []string
|
||||
for _, publicURL := range normalizeAssetURLs(publicURLs) {
|
||||
queue := &assetCleanupQueue{
|
||||
storage: s.assets,
|
||||
maxRetries: assetCleanupMaxRetries,
|
||||
}
|
||||
if err := queue.deleteWithRetry(publicURL); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", publicURL, err))
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("asset cleanup failed: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DesignService) assetReferencedByProjects(ctx context.Context, publicURL string) (bool, error) {
|
||||
if strings.TrimSpace(publicURL) == "" {
|
||||
return false, nil
|
||||
}
|
||||
summaries, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
project, err := s.repo.Get(ctx, summary.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if projectReferencesAssetURL(project, publicURL) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func normalizeAssetURLs(publicURLs []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
urls := make([]string, 0, len(publicURLs))
|
||||
for _, publicURL := range publicURLs {
|
||||
publicURL = strings.TrimSpace(publicURL)
|
||||
if publicURL == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[publicURL]; ok {
|
||||
continue
|
||||
}
|
||||
seen[publicURL] = struct{}{}
|
||||
urls = append(urls, publicURL)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func projectReferencesAssetURL(project design.Project, publicURL string) bool {
|
||||
protectedURLs := canvasAssetURLSet(project.Nodes)
|
||||
for _, url := range canvasSnapshotAssetURLs(project.Canvas) {
|
||||
addAssetURLKeys(protectedURLs, url)
|
||||
}
|
||||
for _, message := range project.Messages {
|
||||
for _, url := range messageAssetURLs(message) {
|
||||
addAssetURLKeys(protectedURLs, url)
|
||||
}
|
||||
}
|
||||
return assetURLSetContains(protectedURLs, publicURL)
|
||||
}
|
||||
|
||||
func deletedCanvasAssetURLs(previous []design.Node, next []design.Node, messages []design.Message) []string {
|
||||
protectedURLs := canvasAssetURLSet(next)
|
||||
for _, message := range messages {
|
||||
for _, url := range messageAssetURLs(message) {
|
||||
addAssetURLKeys(protectedURLs, url)
|
||||
}
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var deleted []string
|
||||
for _, node := range previous {
|
||||
url := nodeAssetURL(node)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if assetURLSetContains(protectedURLs, url) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[url]; ok {
|
||||
continue
|
||||
}
|
||||
seen[url] = struct{}{}
|
||||
deleted = append(deleted, url)
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
func projectAssetURLs(project design.Project) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var urls []string
|
||||
add := func(url string) {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[url]; ok {
|
||||
return
|
||||
}
|
||||
seen[url] = struct{}{}
|
||||
urls = append(urls, url)
|
||||
}
|
||||
for _, node := range project.Nodes {
|
||||
add(nodeAssetURL(node))
|
||||
}
|
||||
for _, url := range canvasSnapshotAssetURLs(project.Canvas) {
|
||||
add(url)
|
||||
}
|
||||
for _, message := range project.Messages {
|
||||
for _, url := range messageAssetURLs(message) {
|
||||
add(url)
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func messageAssetURLs(message design.Message) []string {
|
||||
var urls []string
|
||||
content := strings.TrimSpace(message.Content)
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
var events []struct {
|
||||
EventData struct {
|
||||
Artifact []struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
} `json:"artifact"`
|
||||
} `json:"event_data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &events); err == nil {
|
||||
for _, event := range events {
|
||||
for _, artifact := range event.EventData.Artifact {
|
||||
if strings.TrimSpace(artifact.ImageURL) != "" {
|
||||
urls = append(urls, artifact.ImageURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
urls = append(urls, promptMessageAssetURLs(content)...)
|
||||
urls = append(urls, looseMessageImageURLs(content)...)
|
||||
return urls
|
||||
}
|
||||
|
||||
func canvasSnapshotAssetURLs(canvas string) []string {
|
||||
snapshot, err := design.DecodeCanvasSnapshot(canvas)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
urls := make([]string, 0, len(snapshot.Nodes))
|
||||
for _, node := range snapshot.Nodes {
|
||||
if url := nodeAssetURL(node); url != "" {
|
||||
urls = append(urls, url)
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func canvasAssetURLSet(nodes []design.Node) map[string]struct{} {
|
||||
items := make(map[string]struct{})
|
||||
for _, node := range nodes {
|
||||
if url := nodeAssetURL(node); url != "" {
|
||||
addAssetURLKeys(items, url)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func nodeAssetURL(node design.Node) string {
|
||||
if node.Type != design.NodeTypeImage && node.Type != design.NodeTypeFrame {
|
||||
return ""
|
||||
}
|
||||
content := strings.TrimSpace(node.Content)
|
||||
if content == "" || strings.HasPrefix(content, "data:image/") || !isGeneratedImageContent(content) {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func promptMessageAssetURLs(content string) []string {
|
||||
var urls []string
|
||||
for _, match := range promptImageTokenPattern.FindAllStringSubmatch(content, -1) {
|
||||
if len(match) > 1 && strings.TrimSpace(match[1]) != "" {
|
||||
urls = append(urls, strings.TrimSpace(match[1]))
|
||||
continue
|
||||
}
|
||||
if len(match) > 2 && strings.TrimSpace(match[2]) != "" {
|
||||
urls = append(urls, strings.TrimSpace(match[2]))
|
||||
}
|
||||
}
|
||||
for _, match := range promptImageDirectivePattern.FindAllStringSubmatch(content, -1) {
|
||||
if len(match) > 1 && strings.TrimSpace(match[1]) != "" {
|
||||
urls = append(urls, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func looseMessageImageURLs(content string) []string {
|
||||
matches := messageImageURLPattern.FindAllString(content, -1)
|
||||
urls := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
url := strings.TrimRight(strings.TrimSpace(match), ".,,。;;")
|
||||
if url != "" {
|
||||
urls = append(urls, url)
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func assetURLSetContains(items map[string]struct{}, publicURL string) bool {
|
||||
for key := range assetURLKeys(publicURL) {
|
||||
if _, ok := items[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addAssetURLKeys(items map[string]struct{}, publicURL string) {
|
||||
for key := range assetURLKeys(publicURL) {
|
||||
items[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func assetURLKeys(publicURL string) map[string]struct{} {
|
||||
publicURL = strings.TrimSpace(publicURL)
|
||||
keys := make(map[string]struct{})
|
||||
if publicURL == "" || strings.HasPrefix(publicURL, "data:image/") || strings.HasPrefix(publicURL, "blob:") {
|
||||
return keys
|
||||
}
|
||||
keys[publicURL] = struct{}{}
|
||||
if queryIndex := strings.Index(publicURL, "?"); queryIndex > 0 {
|
||||
keys[publicURL[:queryIndex]] = struct{}{}
|
||||
}
|
||||
parsed, err := url.Parse(publicURL)
|
||||
if err == nil && parsed.Path != "" {
|
||||
keys[parsed.Path] = struct{}{}
|
||||
pathWithoutQuery := parsed.Path
|
||||
if parsed.RawQuery != "" {
|
||||
keys[pathWithoutQuery+"?"+parsed.RawQuery] = struct{}{}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
Reference in New Issue
Block a user