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

309 lines
9.5 KiB
Go

package application
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode/utf8"
"img_infinite_canvas/internal/domain/design"
)
const (
shortMemoryMessageLimit = 6
shortMemoryNodeLimit = 8
longMemoryArtifactLimit = 16
longMemoryDecisionLimit = 10
shortMemoryMaxRunes = 2400
longMemoryMaxRunes = 3200
)
var agentMemoryURLPattern = regexp.MustCompile(`https?://\S+`)
func buildAgentMemory(project design.Project, prompt string, mode string, messages []design.AgentChatMessage) design.AgentMemory {
return design.AgentMemory{
ShortTerm: buildShortMemory(project, prompt, mode, messages),
LongTerm: buildLongMemory(project),
}
}
func buildShortMemory(project design.Project, prompt string, mode string, messages []design.AgentChatMessage) string {
memoryMessages := visibleAgentMemoryMessages(project.Messages)
sections := []string{
"Current turn:",
"- mode: " + strings.TrimSpace(mode),
"- user request: " + strings.TrimSpace(prompt),
}
if inline := inlineInputMemory(messages); inline != "" {
sections = append(sections, "Inline input:", inline)
}
if recent := recentMessageMemory(memoryMessages, shortMemoryMessageLimit); recent != "" {
sections = append(sections, "Recent conversation:", recent)
}
if canvas := canvasNodeMemory(project.Nodes, shortMemoryNodeLimit); canvas != "" {
sections = append(sections, "Visible canvas:", canvas)
}
return clampMemoryBlock(strings.Join(sections, "\n"), shortMemoryMaxRunes)
}
func buildLongMemory(project design.Project) string {
memoryMessages := visibleAgentMemoryMessages(project.Messages)
sections := []string{}
if strings.TrimSpace(project.Title) != "" || strings.TrimSpace(project.Brief) != "" {
sections = append(sections, "Project identity:")
if strings.TrimSpace(project.Title) != "" {
sections = append(sections, "- title: "+strings.TrimSpace(project.Title))
}
if strings.TrimSpace(project.Brief) != "" {
sections = append(sections, "- brief: "+shortenMemoryText(project.Brief, 360))
}
}
if decisions := durableDecisionMemory(memoryMessages, longMemoryDecisionLimit); decisions != "" {
sections = append(sections, "Durable user decisions and preferences:", decisions)
}
if artifacts := completedArtifactMemory(project.Nodes, longMemoryArtifactLimit); artifacts != "" {
sections = append(sections, "Completed canvas artifacts:", artifacts)
}
if research := researchMemory(memoryMessages); research != "" {
sections = append(sections, "Prior web research:", research)
}
if len(sections) == 0 {
return "none"
}
return clampMemoryBlock(strings.Join(sections, "\n"), longMemoryMaxRunes)
}
func visibleAgentMemoryMessages(messages []design.Message) []design.Message {
hiddenThreads := make(map[string]bool)
for _, message := range messages {
if message.ThreadID == "" {
continue
}
if isCanvasActionMemoryMessage(message) || message.Name == "text_extraction" || message.Type == textExtractionResultMessageType {
hiddenThreads[message.ThreadID] = true
}
}
visible := make([]design.Message, 0, len(messages))
for _, message := range messages {
if message.ThreadID != "" && hiddenThreads[message.ThreadID] {
continue
}
visible = append(visible, message)
}
return visible
}
func isCanvasActionMemoryMessage(message design.Message) bool {
return message.Name == "canvas_action" || message.ToolHint == "canvas_action"
}
func inlineInputMemory(messages []design.AgentChatMessage) string {
var lines []string
for _, message := range messages {
for _, content := range message.Contents {
switch strings.ToLower(strings.TrimSpace(content.Type)) {
case "text":
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
continue
}
if text := strings.TrimSpace(content.Text); text != "" {
text = stripImageGeneratorDirectiveLines(text)
if text == "" {
continue
}
lines = append(lines, "- text: "+shortenMemoryText(text, 280))
}
case "image":
if imageURL := strings.TrimSpace(content.ImageURL); imageURL != "" {
size := ""
if content.ImageWidth > 0 && content.ImageHeight > 0 {
size = fmt.Sprintf(" (%dx%d)", content.ImageWidth, content.ImageHeight)
}
lines = append(lines, "- reference image"+size)
}
}
}
}
return strings.Join(lines, "\n")
}
func recentMessageMemory(messages []design.Message, limit int) string {
var lines []string
start := 0
if len(messages) > limit {
start = len(messages) - limit
}
omitted := start
for _, message := range messages[start:] {
if message.Role == "tool" || message.Role == "error" {
continue
}
content := strings.TrimSpace(message.Content)
if content == "" {
content = strings.TrimSpace(message.Title)
}
if content == "" {
continue
}
lines = append(lines, fmt.Sprintf("- %s: %s", message.Role, shortenMemoryText(content, 260)))
}
if omitted > 0 && len(lines) > 0 {
lines = append([]string{fmt.Sprintf("- older conversation omitted: %d messages", omitted)}, lines...)
}
return strings.Join(lines, "\n")
}
func canvasNodeMemory(nodes []design.Node, limit int) string {
var candidates []design.Node
for _, node := range nodes {
if node.Status == "generating" || node.LayerRole == "pen-stroke" {
continue
}
candidates = append(candidates, node)
}
if len(candidates) > limit {
candidates = candidates[len(candidates)-limit:]
}
var lines []string
for _, node := range candidates {
content := strings.TrimSpace(node.Content)
if isGeneratedImageContent(content) || strings.HasPrefix(content, "/canvas-assets/") || strings.HasPrefix(content, "http") {
content = "asset reference"
}
if content != "" {
content = " | " + shortenMemoryText(content, 160)
}
lines = append(lines, fmt.Sprintf("- %s %q at %.0f,%.0f %.0fx%.0f%s", node.Type, node.Title, node.X, node.Y, node.Width, node.Height, content))
}
return strings.Join(lines, "\n")
}
func completedArtifactMemory(nodes []design.Node, limit int) string {
var lines []string
for _, node := range nodes {
if node.Status != "success" && node.Status != "ready" && node.Status != "" {
continue
}
if node.Type != design.NodeTypeImage && node.Type != design.NodeTypeFrame && node.Type != design.NodeTypeText {
continue
}
content := strings.TrimSpace(node.Content)
if len(content) > 120 {
content = shortenMemoryText(content, 120)
}
line := fmt.Sprintf("- %s: %s", node.Type, strings.TrimSpace(node.Title))
if content != "" && (strings.HasPrefix(content, "http") || strings.HasPrefix(content, "/canvas-assets/")) {
line += " | asset reference"
}
lines = append(lines, line)
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
return strings.Join(lines, "\n")
}
func durableDecisionMemory(messages []design.Message, limit int) string {
keywords := []string{
"保留", "不要", "必须", "参考", "风格", "品牌", "颜色", "色彩", "字体", "logo", "主视觉", "电商", "官网",
"keep", "avoid", "must", "reference", "style", "brand", "color", "typography", "logo", "hero", "ecommerce",
}
var lines []string
for _, message := range messages {
if message.Role != "user" {
continue
}
content := strings.TrimSpace(message.Content)
if content == "" || !containsAnyFold(content, keywords) {
continue
}
lines = append(lines, "- "+shortenMemoryText(content, 260))
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
return strings.Join(lines, "\n")
}
func researchMemory(messages []design.Message) string {
var lines []string
for _, message := range messages {
if message.Role != "tool" || message.Title != "Synthesize Skills" {
continue
}
var report struct {
Query string `json:"query"`
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
} `json:"results"`
}
if err := json.Unmarshal([]byte(message.Content), &report); err != nil {
continue
}
if strings.TrimSpace(report.Query) != "" {
lines = append(lines, "- query: "+shortenMemoryText(report.Query, 160))
}
for _, result := range report.Results {
title := strings.TrimSpace(result.Title)
if title == "" {
continue
}
line := " result: " + shortenMemoryText(title, 140)
if snippet := strings.TrimSpace(result.Snippet); snippet != "" {
line += " | " + shortenMemoryText(snippet, 160)
}
lines = append(lines, line)
if len(lines) >= 10 {
break
}
}
}
if len(lines) > 10 {
lines = lines[len(lines)-10:]
}
return strings.Join(lines, "\n")
}
func containsAnyFold(value string, keywords []string) bool {
value = strings.ToLower(value)
for _, keyword := range keywords {
if strings.Contains(value, strings.ToLower(keyword)) {
return true
}
}
return false
}
func shortenMemoryText(value string, limit int) string {
value = sanitizeMemoryText(value)
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
if limit <= 0 {
return value
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit]) + "..."
}
func sanitizeMemoryText(value string) string {
return agentMemoryURLPattern.ReplaceAllString(strings.TrimSpace(value), "[reference]")
}
func clampMemoryBlock(value string, limit int) string {
value = strings.TrimSpace(sanitizeMemoryText(value))
if limit <= 0 || utf8.RuneCountInString(value) <= limit {
return value
}
runes := []rune(value)
clamped := strings.TrimSpace(string(runes[:limit]))
if cut := strings.LastIndex(clamped, "\n"); cut > limit*3/4 {
clamped = strings.TrimSpace(clamped[:cut])
}
return clamped + "\n- memory window truncated; older low-signal context omitted"
}