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