feat(server): add brand kit management and project binding

Add a brand kit domain with memory and postgres stores, a BrandKitService
for CRUD and resolution, and REST endpoints to list, upsert, and delete
brand kits. Projects can bind a brand kit whose resolved context and
reference images feed into the agent's long-term memory and design prompts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:58:55 +08:00
parent 3653474355
commit 789f51b29e
38 changed files with 2165 additions and 135 deletions
@@ -0,0 +1,48 @@
package piagent
import (
"strings"
"testing"
"img_infinite_canvas/internal/domain/design"
)
func TestBrandKitContextReachesPlannerAndDirectImagePrompts(t *testing.T) {
project := design.Project{
Title: "Campaign",
Brief: "Launch visual",
BrandKitID: "brand-kit-alpha",
BrandContext: "Primary color: Signal Green #28C76F. Typography: Avenir Next.",
}
planner := projectBriefContext(project)
direct := directImagePrompt(design.AgentRequest{Project: project, Prompt: "Create a product hero"})
fallback := fallbackImagePrompt(design.AgentRequest{
Project: project,
Prompt: "Create a product hero",
TaskPlan: design.AgentTaskPlan{ImageTasks: []design.AgentImageTask{
{Title: "Hero", Brief: "Centered product"},
}},
}, 0)
for name, value := range map[string]string{"planner": planner, "direct": direct, "fallback": fallback} {
if !strings.Contains(value, "Signal Green #28C76F") || !strings.Contains(value, "Avenir Next") {
t.Fatalf("%s prompt omitted Brand Kit context: %s", name, value)
}
}
}
func TestBrandKitImagesFollowUserReferences(t *testing.T) {
req := design.AgentRequest{
Project: design.Project{BrandImages: []string{
"data:image/png;base64,YnJhbmQtbG9nbw==",
"data:image/webp;base64,YnJhbmQtcmVmZXJlbmNl",
}},
Messages: []design.AgentChatMessage{{Contents: []design.AgentContent{{Type: "image", ImageURL: "https://example.com/user-reference.png"}}}},
}
images := referenceImageURLs(req)
if len(images) != 3 {
t.Fatalf("expected user and Brand Kit references, got %#v", images)
}
if images[0] != "https://example.com/user-reference.png" || images[1] != req.Project.BrandImages[0] || images[2] != req.Project.BrandImages[1] {
t.Fatalf("expected user references before Brand Kit images, got %#v", images)
}
}
@@ -843,6 +843,11 @@ func projectBriefContext(project design.Project) string {
builder.WriteString(project.Brief)
builder.WriteString("\n")
}
if strings.TrimSpace(project.BrandContext) != "" {
builder.WriteString("Brand Kit context:\n")
builder.WriteString(strings.TrimSpace(project.BrandContext))
builder.WriteString("\n")
}
builder.WriteString(fmt.Sprintf("Canvas nodes: %d", len(project.Nodes)))
return builder.String()
}
@@ -210,7 +210,7 @@ func directImagePlan(req design.AgentRequest) design.AgentPlan {
func directImagePrompts(req design.AgentRequest) []design.AgentImagePrompt {
count := plannedGenerationCount(req)
prompt := strings.TrimSpace(req.Prompt)
prompt := directImagePrompt(req)
if prompt == "" {
prompt = fallbackDirectImagePrompt(req)
}
@@ -257,7 +257,7 @@ func directImageTasksSharePrompt(req design.AgentRequest) bool {
}
func directImageBatchPrompt(req design.AgentRequest) design.AgentImagePrompt {
prompt := strings.TrimSpace(req.Prompt)
prompt := directImagePrompt(req)
if prompt == "" {
prompt = fallbackDirectImagePrompt(req)
}
@@ -614,6 +614,12 @@ func referenceImageURLs(req design.AgentRequest) []string {
}
}
}
for _, image := range req.Project.BrandImages {
add(image)
if len(urls) >= 16 {
return urls[:16]
}
}
if len(urls) > 16 {
return urls[:16]
}
@@ -662,9 +668,9 @@ func fallbackImagePrompt(req design.AgentRequest, index int) string {
task := req.TaskPlan.ImageTasks[index]
if brief := strings.TrimSpace(task.Brief); brief != "" {
if title := strings.TrimSpace(task.Title); title != "" && !strings.Contains(brief, title) {
return title + ": " + brief
return imagePromptWithBrandKit(title+": "+brief, req)
}
return brief
return imagePromptWithBrandKit(brief, req)
}
}
return fallbackDirectImagePrompt(req)
@@ -1064,6 +1070,9 @@ func fallbackDirectImagePrompt(req design.AgentRequest) string {
if brief := strings.TrimSpace(req.Project.Brief); brief != "" {
lines = append(lines, "Project brief: "+truncateRunes(brief, 180))
}
if brandContext := strings.TrimSpace(req.Project.BrandContext); brandContext != "" {
lines = append(lines, "Binding Brand Kit: "+truncateRunes(brandContext, 520))
}
if memory := compactFallbackMemory(req.Memory); memory != "" {
lines = append(lines, "Relevant memory: "+memory)
}
@@ -1080,6 +1089,19 @@ func fallbackDirectImagePrompt(req design.AgentRequest) string {
return compactImageGenerationPrompt(strings.Join(lines, "\n"), imageGenerationFallbackMaxRunes)
}
func directImagePrompt(req design.AgentRequest) string {
return imagePromptWithBrandKit(req.Prompt, req)
}
func imagePromptWithBrandKit(prompt string, req design.AgentRequest) string {
prompt = strings.TrimSpace(prompt)
brandContext := strings.TrimSpace(req.Project.BrandContext)
if brandContext == "" {
return prompt
}
return strings.TrimSpace(prompt + "\n\nBinding Brand Kit requirements:\n" + brandContext)
}
func compactFallbackMemory(memory design.AgentMemory) string {
if memory.IsZero() {
return ""