Files
root 44406b72db 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>
2026-07-07 23:15:37 +08:00

364 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}