44406b72db
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>
364 lines
9.1 KiB
Go
364 lines
9.1 KiB
Go
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
|
||
}
|