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
@@ -59,6 +59,9 @@ func buildLongMemory(project design.Project) string {
sections = append(sections, "- brief: "+shortenMemoryText(project.Brief, 360))
}
}
if brandContext := strings.TrimSpace(project.BrandContext); brandContext != "" {
sections = append(sections, "Binding Brand Kit context:", brandContext)
}
if decisions := durableDecisionMemory(memoryMessages, longMemoryDecisionLimit); decisions != "" {
sections = append(sections, "Durable user decisions and preferences:", decisions)
}
@@ -0,0 +1,510 @@
package application
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"unicode/utf8"
"img_infinite_canvas/internal/domain/brandkit"
"img_infinite_canvas/internal/domain/design"
)
const (
maxBrandKitDocumentBytes = 24 << 20
maxBrandKitContextRunes = 12000
)
var (
brandKitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
brandColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$`)
)
type BrandKitService struct {
store brandkit.Store
now func() time.Time
}
type ResolvedBrandKit struct {
ID string
Context string
ReferenceImages []string
}
type brandKitDocument struct {
Version int `json:"version"`
ID string `json:"id"`
Name string `json:"name"`
BrandName string `json:"brandName"`
Tagline string `json:"tagline"`
Summary string `json:"summary"`
Audience string `json:"audience"`
Positioning string `json:"positioning"`
Personality string `json:"personality"`
Voice brandKitVoice `json:"voice"`
Logos []brandKitLogo `json:"logos"`
CoverImage *brandKitReferenceImage `json:"coverImage,omitempty"`
ColorGroups []brandKitColorGroup `json:"colorGroups"`
Typography []brandKitTypography `json:"typography"`
ReferenceImages []brandKitReferenceImage `json:"referenceImages"`
Visual brandKitVisual `json:"visual"`
ApplyToNewProjects bool `json:"applyToNewProjects"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type brandKitVoice struct {
Traits string `json:"traits"`
Guidance string `json:"guidance"`
Sample string `json:"sample"`
}
type brandKitLogo struct {
ID string `json:"id"`
Name string `json:"name"`
Variant string `json:"variant"`
Usage string `json:"usage"`
Source string `json:"source"`
DataURL string `json:"dataUrl,omitempty"`
}
type brandKitColorGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Colors []brandKitColor `json:"colors"`
}
type brandKitColor struct {
ID string `json:"id"`
Name string `json:"name"`
Hex string `json:"hex"`
Usage string `json:"usage"`
}
type brandKitTypography struct {
ID string `json:"id"`
Label string `json:"label"`
Family string `json:"family"`
Weight string `json:"weight"`
Sizes string `json:"sizes"`
Description string `json:"description"`
FontFile *brandKitFontFile `json:"fontFile,omitempty"`
}
type brandKitFontFile struct {
Name string `json:"name"`
Format string `json:"format"`
Size int64 `json:"size"`
DataURL string `json:"dataUrl"`
}
type brandKitReferenceImage struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
DataURL string `json:"dataUrl,omitempty"`
}
type brandKitVisual struct {
Keywords string `json:"keywords"`
Composition string `json:"composition"`
Imagery string `json:"imagery"`
Dos string `json:"dos"`
Donts string `json:"donts"`
Prompt string `json:"prompt"`
}
func NewBrandKitService(store brandkit.Store) *BrandKitService {
return &BrandKitService{store: store, now: time.Now}
}
func (s *BrandKitService) Close() error {
if s == nil || s.store == nil {
return nil
}
return s.store.Close()
}
func (s *BrandKitService) List(ctx context.Context) ([]brandkit.Kit, error) {
if s == nil || s.store == nil {
return []brandkit.Kit{}, nil
}
return s.store.List(ctx, design.UserIDFromContext(ctx))
}
func (s *BrandKitService) Get(ctx context.Context, id string) (brandkit.Kit, error) {
id = strings.TrimSpace(id)
if !brandKitIDPattern.MatchString(id) {
return brandkit.Kit{}, fmt.Errorf("%w: invalid id", brandkit.ErrInvalidInput)
}
return s.store.Get(ctx, design.UserIDFromContext(ctx), id)
}
func (s *BrandKitService) Upsert(ctx context.Context, id string, document string, isDefault bool) (brandkit.Kit, error) {
if s == nil || s.store == nil {
return brandkit.Kit{}, brandkit.ErrNotFound
}
id = strings.TrimSpace(id)
if !brandKitIDPattern.MatchString(id) {
return brandkit.Kit{}, fmt.Errorf("%w: invalid id", brandkit.ErrInvalidInput)
}
if len(document) == 0 || len(document) > maxBrandKitDocumentBytes {
return brandkit.Kit{}, fmt.Errorf("%w: document size is invalid", brandkit.ErrInvalidInput)
}
now := s.now().UTC()
createdAt := now
if existing, err := s.store.Get(ctx, design.UserIDFromContext(ctx), id); err == nil {
createdAt = existing.CreatedAt
} else if !errors.Is(err, brandkit.ErrNotFound) {
return brandkit.Kit{}, err
}
doc, err := normalizeBrandKitDocument(id, []byte(document), isDefault, createdAt, now)
if err != nil {
return brandkit.Kit{}, err
}
canonical, err := json.Marshal(doc)
if err != nil {
return brandkit.Kit{}, fmt.Errorf("%w: encode document", brandkit.ErrInvalidInput)
}
return s.store.Upsert(ctx, brandkit.Kit{
ID: id,
UserID: design.UserIDFromContext(ctx),
Name: doc.Name,
Document: canonical,
IsDefault: isDefault,
CreatedAt: createdAt,
UpdatedAt: now,
})
}
func (s *BrandKitService) Delete(ctx context.Context, id string) error {
id = strings.TrimSpace(id)
if !brandKitIDPattern.MatchString(id) {
return fmt.Errorf("%w: invalid id", brandkit.ErrInvalidInput)
}
return s.store.Delete(ctx, design.UserIDFromContext(ctx), id)
}
func (s *BrandKitService) ResolveContext(ctx context.Context, id string) (string, error) {
resolved, err := s.Resolve(ctx, id)
if err != nil {
return "", err
}
return resolved.Context, nil
}
func (s *BrandKitService) Resolve(ctx context.Context, id string) (ResolvedBrandKit, error) {
kit, err := s.Get(ctx, id)
if err != nil {
return ResolvedBrandKit{}, err
}
compiled, err := compileBrandKitContext(kit.Document)
if err != nil {
return ResolvedBrandKit{}, err
}
references, err := brandKitReferenceImages(kit.Document)
if err != nil {
return ResolvedBrandKit{}, err
}
return ResolvedBrandKit{ID: kit.ID, Context: compiled, ReferenceImages: references}, nil
}
func (s *BrandKitService) ResolveDefaultContext(ctx context.Context) (string, string, error) {
resolved, err := s.ResolveDefault(ctx)
if err != nil {
return "", "", err
}
return resolved.ID, resolved.Context, nil
}
func (s *BrandKitService) ResolveDefault(ctx context.Context) (ResolvedBrandKit, error) {
if s == nil || s.store == nil {
return ResolvedBrandKit{}, brandkit.ErrNotFound
}
kit, err := s.store.GetDefault(ctx, design.UserIDFromContext(ctx))
if err != nil {
return ResolvedBrandKit{}, err
}
compiled, err := compileBrandKitContext(kit.Document)
if err != nil {
return ResolvedBrandKit{}, err
}
references, err := brandKitReferenceImages(kit.Document)
if err != nil {
return ResolvedBrandKit{}, err
}
return ResolvedBrandKit{ID: kit.ID, Context: compiled, ReferenceImages: references}, nil
}
func normalizeBrandKitDocument(id string, raw []byte, isDefault bool, createdAt time.Time, updatedAt time.Time) (brandKitDocument, error) {
var doc brandKitDocument
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&doc); err != nil {
return brandKitDocument{}, fmt.Errorf("%w: malformed document", brandkit.ErrInvalidInput)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return brandKitDocument{}, fmt.Errorf("%w: malformed document", brandkit.ErrInvalidInput)
}
if doc.Version != 1 || strings.TrimSpace(doc.ID) != id {
return brandKitDocument{}, fmt.Errorf("%w: unsupported version or mismatched id", brandkit.ErrInvalidInput)
}
doc.Name = strings.TrimSpace(doc.Name)
if doc.Name == "" || !textWithin(doc.Name, 120) {
return brandKitDocument{}, fmt.Errorf("%w: name is required", brandkit.ErrInvalidInput)
}
if err := validateBrandKitDocument(doc); err != nil {
return brandKitDocument{}, err
}
doc.ID = id
doc.ApplyToNewProjects = isDefault
doc.CreatedAt = createdAt.UTC().Format(time.RFC3339)
doc.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
return doc, nil
}
func validateBrandKitDocument(doc brandKitDocument) error {
for _, value := range []string{doc.BrandName, doc.Tagline, doc.Summary, doc.Audience, doc.Positioning, doc.Personality, doc.Voice.Traits, doc.Voice.Guidance, doc.Voice.Sample} {
if !textWithin(value, 4000) {
return fmt.Errorf("%w: text field is too long", brandkit.ErrInvalidInput)
}
}
if len(doc.Logos) > 12 || len(doc.ColorGroups) > 20 || len(doc.Typography) > 20 || len(doc.ReferenceImages) > 20 {
return fmt.Errorf("%w: too many document items", brandkit.ErrInvalidInput)
}
for _, logo := range doc.Logos {
if !brandKitIDPattern.MatchString(logo.ID) || !oneOf(logo.Variant, "primary", "secondary", "mark") || !oneOf(logo.Source, "upload", "moteva-mark") || !textWithin(logo.Name, 240) || !textWithin(logo.Usage, 4000) || !validImageDataURL(logo.DataURL) {
return fmt.Errorf("%w: invalid logo", brandkit.ErrInvalidInput)
}
}
if doc.CoverImage != nil {
if err := validateReferenceImage(*doc.CoverImage); err != nil {
return err
}
}
for _, group := range doc.ColorGroups {
if !brandKitIDPattern.MatchString(group.ID) || !textWithin(group.Name, 120) || len(group.Colors) > 32 {
return fmt.Errorf("%w: invalid color group", brandkit.ErrInvalidInput)
}
for _, color := range group.Colors {
if !brandKitIDPattern.MatchString(color.ID) || !brandColorPattern.MatchString(strings.TrimSpace(color.Hex)) || !textWithin(color.Name, 120) || !textWithin(color.Usage, 1000) {
return fmt.Errorf("%w: invalid color", brandkit.ErrInvalidInput)
}
}
}
for _, item := range doc.Typography {
if !brandKitIDPattern.MatchString(item.ID) || !textWithin(item.Label, 120) || !textWithin(item.Family, 240) || !textWithin(item.Weight, 80) || !textWithin(item.Sizes, 240) || !textWithin(item.Description, 4000) {
return fmt.Errorf("%w: invalid typography", brandkit.ErrInvalidInput)
}
if item.FontFile != nil && (strings.TrimSpace(item.FontFile.Name) == "" || !textWithin(item.FontFile.Name, 240) || !textWithin(item.FontFile.Format, 32) || item.FontFile.Size < 0 || item.FontFile.Size > 8<<20 || !validDataURL(item.FontFile.DataURL)) {
return fmt.Errorf("%w: invalid font file", brandkit.ErrInvalidInput)
}
}
for _, image := range doc.ReferenceImages {
if err := validateReferenceImage(image); err != nil {
return err
}
}
for _, value := range []string{doc.Visual.Keywords, doc.Visual.Composition, doc.Visual.Imagery, doc.Visual.Dos, doc.Visual.Donts} {
if !textWithin(value, 4000) {
return fmt.Errorf("%w: visual field is too long", brandkit.ErrInvalidInput)
}
}
if !textWithin(doc.Visual.Prompt, 8000) {
return fmt.Errorf("%w: reusable direction is too long", brandkit.ErrInvalidInput)
}
return nil
}
func validateReferenceImage(image brandKitReferenceImage) error {
if !brandKitIDPattern.MatchString(image.ID) || !oneOf(image.Source, "upload", "moteva-board") || !textWithin(image.Name, 240) || !textWithin(image.Description, 4000) || !validImageDataURL(image.DataURL) {
return fmt.Errorf("%w: invalid reference image", brandkit.ErrInvalidInput)
}
return nil
}
func compileBrandKitContext(raw []byte) (string, error) {
var doc brandKitDocument
if err := json.Unmarshal(raw, &doc); err != nil {
return "", fmt.Errorf("%w: malformed stored document", brandkit.ErrInvalidInput)
}
sections := []string{
"Brand Kit: " + doc.Name,
brandKitContextSection("Brand", compactValues(doc.BrandName, doc.Tagline, doc.Summary)),
brandKitContextSection("Audience", compactValues(doc.Audience)),
brandKitContextSection("Positioning and personality", compactValues(doc.Positioning, doc.Personality)),
brandKitContextSection("Voice", compactValues(doc.Voice.Traits, doc.Voice.Guidance, doc.Voice.Sample)),
}
var logoLines []string
for _, logo := range doc.Logos {
logoLines = appendNonEmpty(logoLines, strings.Join(compactValues(logo.Name, logo.Variant, logo.Usage), " | "))
}
sections = append(sections, brandKitContextSection("Logo rules", logoLines))
if doc.CoverImage != nil {
sections = append(sections, brandKitContextSection("Cover image", compactValues(doc.CoverImage.Name, doc.CoverImage.Description)))
}
var colorLines []string
for _, group := range doc.ColorGroups {
for _, color := range group.Colors {
colorLines = append(colorLines, strings.Join(compactValues(group.Name+" / "+color.Name, color.Hex, color.Usage), " | "))
}
}
sections = append(sections, brandKitContextSection("Color system", colorLines))
var typeLines []string
for _, item := range doc.Typography {
fileName := ""
if item.FontFile != nil {
fileName = item.FontFile.Name
}
typeLines = appendNonEmpty(typeLines, strings.Join(compactValues(item.Label, item.Family, fileName, item.Weight, item.Sizes, item.Description), " | "))
}
sections = append(sections, brandKitContextSection("Typography", typeLines))
var referenceLines []string
for _, image := range doc.ReferenceImages {
referenceLines = appendNonEmpty(referenceLines, strings.Join(compactValues(image.Name, image.Description), ": "))
}
sections = append(sections, brandKitContextSection("Reference images", referenceLines))
sections = append(sections, brandKitContextSection("Attached Brand Kit images (appended after user references)", brandKitReferenceImageContext(doc)))
sections = append(sections,
brandKitContextSection("Visual direction", compactValues(doc.Visual.Keywords, doc.Visual.Composition, doc.Visual.Imagery)),
brandKitContextSection("Use", compactValues(doc.Visual.Dos)),
brandKitContextSection("Avoid", compactValues(doc.Visual.Donts)),
brandKitContextSection("Reusable direction", compactValues(doc.Visual.Prompt)),
)
sections = compactValues(sections...)
contextBlock := "Binding creative system for this canvas. Apply it to planning, copy, composition, typography, color, and generated imagery unless the user explicitly overrides a rule in the current request.\n\n" + strings.Join(sections, "\n\n")
return clampRunes(contextBlock, maxBrandKitContextRunes), nil
}
func brandKitReferenceImages(raw []byte) ([]string, error) {
var doc brandKitDocument
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("%w: malformed stored document", brandkit.ErrInvalidInput)
}
images := make([]string, 0, len(doc.Logos)+len(doc.ReferenceImages)+1)
seen := make(map[string]struct{})
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, exists := seen[value]; exists {
return
}
seen[value] = struct{}{}
images = append(images, value)
}
for _, logo := range doc.Logos {
add(logo.DataURL)
}
if doc.CoverImage != nil {
add(doc.CoverImage.DataURL)
}
for _, image := range doc.ReferenceImages {
add(image.DataURL)
}
return images, nil
}
func brandKitReferenceImageContext(doc brandKitDocument) []string {
lines := make([]string, 0, len(doc.Logos)+len(doc.ReferenceImages)+1)
index := 0
for _, logo := range doc.Logos {
if strings.TrimSpace(logo.DataURL) == "" {
continue
}
index++
lines = append(lines, fmt.Sprintf("Brand image %d | logo | %s", index, strings.Join(compactValues(logo.Name, logo.Variant, logo.Usage), " | ")))
}
if doc.CoverImage != nil && strings.TrimSpace(doc.CoverImage.DataURL) != "" {
index++
lines = append(lines, fmt.Sprintf("Brand image %d | cover | %s", index, strings.Join(compactValues(doc.CoverImage.Name, doc.CoverImage.Description), " | ")))
}
for _, image := range doc.ReferenceImages {
if strings.TrimSpace(image.DataURL) == "" {
continue
}
index++
lines = append(lines, fmt.Sprintf("Brand image %d | visual reference | %s", index, strings.Join(compactValues(image.Name, image.Description), " | ")))
}
return lines
}
func brandKitContextSection(title string, values []string) string {
values = compactValues(values...)
if len(values) == 0 {
return ""
}
return title + ":\n- " + strings.Join(values, "\n- ")
}
func compactValues(values ...string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
result = append(result, value)
}
}
return result
}
func appendNonEmpty(values []string, value string) []string {
if value = strings.TrimSpace(value); value != "" {
return append(values, value)
}
return values
}
func textWithin(value string, max int) bool {
return utf8.RuneCountInString(value) <= max
}
func validDataURL(value string) bool {
value = strings.TrimSpace(value)
return value == "" || (strings.HasPrefix(value, "data:") && len(value) <= 12<<20)
}
func validImageDataURL(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return true
}
if len(value) > 12<<20 {
return false
}
for _, prefix := range []string{
"data:image/png;base64,",
"data:image/jpeg;base64,",
"data:image/webp;base64,",
"data:image/svg+xml;base64,",
} {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func oneOf(value string, allowed ...string) bool {
for _, candidate := range allowed {
if value == candidate {
return true
}
}
return false
}
func clampRunes(value string, max int) string {
if max <= 0 || utf8.RuneCountInString(value) <= max {
return value
}
runes := []rune(value)
return string(runes[:max])
}
@@ -0,0 +1,173 @@
package application
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"img_infinite_canvas/internal/domain/brandkit"
"img_infinite_canvas/internal/domain/design"
"img_infinite_canvas/internal/infrastructure/memory"
)
const (
brandKitTestUserA = "11111111-1111-4111-8111-111111111111"
brandKitTestUserB = "22222222-2222-4222-8222-222222222222"
)
func TestBrandKitServiceEnforcesOwnershipAndSingleDefault(t *testing.T) {
service := NewBrandKitService(memory.NewBrandKitStore())
ctxA := design.ContextWithUserID(context.Background(), brandKitTestUserA)
ctxB := design.ContextWithUserID(context.Background(), brandKitTestUserB)
first, err := service.Upsert(ctxA, "brand-kit-alpha", testBrandKitDocument("brand-kit-alpha", "Alpha"), true)
if err != nil {
t.Fatal(err)
}
if !first.IsDefault {
t.Fatal("expected first kit to be default")
}
if _, err := service.Upsert(ctxA, "brand-kit-beta", testBrandKitDocument("brand-kit-beta", "Beta"), true); err != nil {
t.Fatal(err)
}
kits, err := service.List(ctxA)
if err != nil {
t.Fatal(err)
}
defaults := 0
for _, kit := range kits {
if kit.IsDefault {
defaults++
if kit.ID != "brand-kit-beta" {
t.Fatalf("unexpected default kit %q", kit.ID)
}
}
}
if defaults != 1 {
t.Fatalf("expected one default, got %d", defaults)
}
if _, err := service.Get(ctxB, first.ID); !errors.Is(err, brandkit.ErrNotFound) {
t.Fatalf("expected user isolation, got %v", err)
}
}
func TestBrandKitServiceCompilesAuthoritativeContext(t *testing.T) {
service := NewBrandKitService(memory.NewBrandKitStore())
ctx := design.ContextWithUserID(context.Background(), brandKitTestUserA)
kit, err := service.Upsert(ctx, "brand-kit-alpha", testBrandKitDocument("brand-kit-alpha", "Alpha"), false)
if err != nil {
t.Fatal(err)
}
compiled, err := service.ResolveContext(ctx, kit.ID)
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Binding creative system", "Alpha", "Signal Green", "#28C76F", "Display"} {
if !strings.Contains(compiled, expected) {
t.Fatalf("compiled context missing %q: %s", expected, compiled)
}
}
if strings.Contains(compiled, "data:") {
t.Fatal("compiled context must not include embedded asset data")
}
}
func TestProjectBrandKitBindingUsesDefaultAndExplicitNone(t *testing.T) {
store := memory.NewBrandKitStore()
brandKits := NewBrandKitService(store)
ctx := design.ContextWithUserID(context.Background(), brandKitTestUserA)
if _, err := brandKits.Upsert(ctx, "brand-kit-alpha", testBrandKitDocument("brand-kit-alpha", "Alpha"), true); err != nil {
t.Fatal(err)
}
repo := memory.NewProjectRepository()
designs := NewDesignService(repo, nil, nil, nil, nil)
designs.SetBrandKitService(brandKits)
withDefault, err := designs.CreateProjectWithBrandKit(ctx, "Default", "", "", "", "", 0, false, nil)
if err != nil {
t.Fatal(err)
}
if withDefault.BrandKitID != "brand-kit-alpha" || !strings.Contains(withDefault.BrandContext, "Alpha") {
t.Fatalf("default Brand Kit was not bound: %#v", withDefault)
}
none := ""
withoutKit, err := designs.CreateProjectWithBrandKit(ctx, "None", "", "", "", "", 0, false, &none)
if err != nil {
t.Fatal(err)
}
if withoutKit.BrandKitID != "" || withoutKit.BrandContext != "" {
t.Fatalf("explicit None should bypass the default: %#v", withoutKit)
}
bound, err := designs.UpdateProjectBrandKit(ctx, withoutKit.ID, "brand-kit-alpha")
if err != nil {
t.Fatal(err)
}
if bound.BrandKitID != "brand-kit-alpha" {
t.Fatalf("expected explicit binding, got %q", bound.BrandKitID)
}
if err := designs.ClearBrandKitBindings(ctx, "brand-kit-alpha"); err != nil {
t.Fatal(err)
}
cleared, err := repo.Get(ctx, bound.ID)
if err != nil {
t.Fatal(err)
}
if cleared.BrandKitID != "" {
t.Fatalf("expected binding cleanup, got %q", cleared.BrandKitID)
}
}
func TestProjectBrandKitBindingRejectsAnotherUsersKit(t *testing.T) {
brandKits := NewBrandKitService(memory.NewBrandKitStore())
ctxA := design.ContextWithUserID(context.Background(), brandKitTestUserA)
ctxB := design.ContextWithUserID(context.Background(), brandKitTestUserB)
if _, err := brandKits.Upsert(ctxA, "brand-kit-alpha", testBrandKitDocument("brand-kit-alpha", "Alpha"), false); err != nil {
t.Fatal(err)
}
repo := memory.NewProjectRepository()
designs := NewDesignService(repo, nil, nil, nil, nil)
designs.SetBrandKitService(brandKits)
none := ""
project, err := designs.CreateProjectAsyncWithBrandKit(ctxB, "Other user", "", "", "", "", 0, false, &none)
if err != nil {
t.Fatal(err)
}
if _, err := designs.UpdateProjectBrandKit(ctxB, project.ID, "brand-kit-alpha"); !errors.Is(err, brandkit.ErrNotFound) {
t.Fatalf("expected ownership check to hide the Brand Kit, got %v", err)
}
stored, err := repo.Get(ctxB, project.ID)
if err != nil {
t.Fatal(err)
}
if stored.BrandKitID != "" {
t.Fatalf("failed binding must not mutate the project, got %q", stored.BrandKitID)
}
}
func testBrandKitDocument(id string, name string) string {
return fmt.Sprintf(`{
"version": 1,
"id": %q,
"name": %q,
"brandName": %q,
"tagline": "Make the signal clear",
"summary": "A focused product brand.",
"audience": "Creative teams",
"positioning": "Clear and precise",
"personality": "Confident",
"voice": {"traits": "Direct", "guidance": "Lead with outcomes", "sample": "Move with clarity"},
"logos": [{"id": "logo-primary", "name": "Primary", "variant": "primary", "usage": "Light backgrounds", "source": "upload", "dataUrl": "data:image/png;base64,AA=="}],
"colorGroups": [{"id": "colors-primary", "name": "Primary", "colors": [{"id": "color-signal", "name": "Signal Green", "hex": "#28C76F", "usage": "Key actions"}]}],
"typography": [{"id": "type-display", "label": "Display", "family": "Avenir Next", "weight": "700", "sizes": "48", "description": "Headlines"}],
"referenceImages": [],
"visual": {"keywords": "precise", "composition": "strict grid", "imagery": "real product", "dos": "use space", "donts": "avoid noise", "prompt": "Keep the product clear"},
"applyToNewProjects": false,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
}`, id, name, name)
}
+163 -16
View File
@@ -24,6 +24,7 @@ import (
"sync"
"time"
"img_infinite_canvas/internal/domain/brandkit"
"img_infinite_canvas/internal/domain/design"
"github.com/google/uuid"
@@ -65,6 +66,7 @@ type DesignService struct {
textExtractionCache textExtractionCacheStore
textExtractionCacheTTL time.Duration
creativeAgent design.CreativeAgent
brandKits *BrandKitService
cancelledAgentTasks sync.Map
agentTaskCancels sync.Map
generatorTaskMu sync.RWMutex
@@ -132,6 +134,10 @@ func (s *DesignService) SetTextExtractionCache(store textExtractionCacheStore, t
s.textExtractionCacheTTL = ttl
}
func (s *DesignService) SetBrandKitService(service *BrandKitService) {
s.brandKits = service
}
func (s *DesignService) SetBackgroundRemover(remover design.BackgroundRemover) {
s.backgroundRemover = remover
}
@@ -313,10 +319,130 @@ func (s *DesignService) DeleteProjects(ctx context.Context, ids []string) (int64
return deleted, nil
}
func (s *DesignService) UpdateProjectBrandKit(ctx context.Context, projectID string, brandKitID string) (design.Project, error) {
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
if err != nil {
return design.Project{}, err
}
project.BrandKitID = strings.TrimSpace(brandKitID)
project.BrandContext = ""
project.BrandImages = nil
if project.BrandKitID != "" {
if s.brandKits == nil {
return design.Project{}, brandkit.ErrNotFound
}
resolved, err := s.brandKits.Resolve(ctx, project.BrandKitID)
if err != nil {
return design.Project{}, err
}
project.BrandContext = resolved.Context
project.BrandImages = append([]string(nil), resolved.ReferenceImages...)
}
project.UpdatedAt = s.now()
if err := s.repo.Save(ctx, project); err != nil {
return design.Project{}, err
}
return project, nil
}
func (s *DesignService) ClearBrandKitBindings(ctx context.Context, brandKitID string) error {
brandKitID = strings.TrimSpace(brandKitID)
if brandKitID == "" {
return nil
}
items, err := s.repo.List(ctx)
if err != nil {
return err
}
for _, item := range items {
project, err := s.repo.Get(ctx, item.ID)
if err != nil {
return err
}
if project.BrandKitID != brandKitID {
continue
}
project.BrandKitID = ""
project.BrandContext = ""
project.BrandImages = nil
project.UpdatedAt = s.now()
if err := s.repo.Save(ctx, project); err != nil {
return err
}
}
return nil
}
func (s *DesignService) applyInitialBrandKit(ctx context.Context, project design.Project, requestedID *string) (design.Project, error) {
if s.brandKits == nil {
return project, nil
}
if requestedID != nil {
project.BrandKitID = strings.TrimSpace(*requestedID)
if project.BrandKitID == "" {
return project, nil
}
resolved, err := s.brandKits.Resolve(ctx, project.BrandKitID)
if err != nil {
return design.Project{}, err
}
project.BrandContext = resolved.Context
project.BrandImages = append([]string(nil), resolved.ReferenceImages...)
return project, nil
}
resolved, err := s.brandKits.ResolveDefault(ctx)
if errors.Is(err, brandkit.ErrNotFound) {
return project, nil
}
if err != nil {
return design.Project{}, err
}
project.BrandKitID = resolved.ID
project.BrandContext = resolved.Context
project.BrandImages = append([]string(nil), resolved.ReferenceImages...)
return project, nil
}
func (s *DesignService) hydrateBrandKitContext(ctx context.Context, project design.Project) (design.Project, error) {
project.BrandContext = ""
project.BrandImages = nil
if strings.TrimSpace(project.BrandKitID) == "" || s.brandKits == nil {
return project, nil
}
resolved, err := s.brandKits.Resolve(ctx, project.BrandKitID)
if errors.Is(err, brandkit.ErrNotFound) {
project.BrandKitID = ""
return project, nil
}
if err != nil {
return design.Project{}, err
}
project.BrandContext = resolved.Context
project.BrandImages = append([]string(nil), resolved.ReferenceImages...)
return project, nil
}
func (s *DesignService) getProjectWithBrandKit(ctx context.Context, projectID string) (design.Project, error) {
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
if err != nil {
return design.Project{}, err
}
return s.hydrateBrandKitContext(ctx, project)
}
func (s *DesignService) CreateProject(ctx context.Context, title string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool) (design.Project, error) {
return s.CreateProjectWithBrandKit(ctx, title, prompt, imageModel, imageSize, imageQuality, imageCount, enableWebSearch, nil)
}
func (s *DesignService) CreateProjectWithBrandKit(ctx context.Context, title string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool, brandKitID *string) (design.Project, error) {
prompt = design.NormalizePrompt(prompt)
if prompt == "" {
project, _ := s.newProjectShell(ctx, title, prompt)
var err error
project, err = s.applyInitialBrandKit(ctx, project, brandKitID)
if err != nil {
return design.Project{}, err
}
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
if err := s.repo.Save(ctx, project); err != nil {
return design.Project{}, err
@@ -345,6 +471,11 @@ func (s *DesignService) CreateProject(ctx context.Context, title string, prompt
},
},
}
var err error
project, err = s.applyInitialBrandKit(ctx, project, brandKitID)
if err != nil {
return design.Project{}, err
}
agentReq := design.AgentRequest{
Project: project,
@@ -370,7 +501,7 @@ func (s *DesignService) CreateProject(ctx context.Context, title string, prompt
if err := s.maybeAddResearchStep(ctx, project.ID, "", project, prompt, "design", agentReq.Messages, true); err != nil {
return design.Project{}, err
}
if nextProject, err := s.repo.Get(ctx, project.ID); err == nil {
if nextProject, err := s.getProjectWithBrandKit(ctx, project.ID); err == nil {
project = nextProject
agentReq.Project = project
}
@@ -404,7 +535,16 @@ func (s *DesignService) CreateProject(ctx context.Context, title string, prompt
}
func (s *DesignService) CreateProjectAsync(ctx context.Context, title string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool) (design.Project, error) {
return s.CreateProjectAsyncWithBrandKit(ctx, title, prompt, imageModel, imageSize, imageQuality, imageCount, enableWebSearch, nil)
}
func (s *DesignService) CreateProjectAsyncWithBrandKit(ctx context.Context, title string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool, brandKitID *string) (design.Project, error) {
project, prompt := s.newProjectShell(ctx, title, prompt)
var err error
project, err = s.applyInitialBrandKit(ctx, project, brandKitID)
if err != nil {
return design.Project{}, err
}
if prompt == "" {
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
if err := s.repo.Save(ctx, project); err != nil {
@@ -437,7 +577,7 @@ func (s *DesignService) Generate(ctx context.Context, projectID string, prompt s
return GenerateResult{}, fmt.Errorf("%w: prompt is required", design.ErrInvalidInput)
}
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
project, err := s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return GenerateResult{}, err
}
@@ -543,7 +683,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
threadIDType = 9
}
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
project, err := s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return design.AgentThread{}, err
}
@@ -590,7 +730,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
Memory: buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages),
WebSearchEnabled: enableWebSearch,
})
project, err = s.repo.Get(ctx, project.ID)
project, err = s.getProjectWithBrandKit(ctx, project.ID)
if err != nil {
return design.AgentThread{}, err
}
@@ -627,7 +767,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
if err := s.maybeAddPlannedResearchStep(ctx, project.ID, threadID, projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages, enableWebSearch, taskPlan); err != nil {
return design.AgentThread{}, err
}
project, err = s.repo.Get(ctx, project.ID)
project, err = s.getProjectWithBrandKit(ctx, project.ID)
if err != nil {
return design.AgentThread{}, err
}
@@ -1529,8 +1669,15 @@ func (s *DesignService) completeAgentConversation(ctx context.Context, projectID
} else if cancelled {
return nil
}
currentProject, err := s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
req.Project.BrandKitID = currentProject.BrandKitID
req.Project.BrandContext = currentProject.BrandContext
req.Project.BrandImages = append([]string(nil), currentProject.BrandImages...)
req.Memory = buildAgentMemory(req.Project, req.Prompt, req.Mode, req.Messages)
var content string
var err error
if streamer, ok := s.creativeAgent.(design.StreamingCreativeAgent); ok {
content, err = s.streamAgentChatResponse(ctx, projectID, messageID, req, streamer)
} else {
@@ -1590,7 +1737,7 @@ func (s *DesignService) createAgentChatResponse(ctx context.Context, req design.
}
func (s *DesignService) updateAgentMessage(ctx context.Context, projectID string, messageID string, content string, status string, projectStatus design.ProjectStatus) error {
project, err := s.repo.Get(ctx, projectID)
project, err := s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
@@ -1905,7 +2052,7 @@ func (s *DesignService) completeNodeActionGeneration(ctx context.Context, projec
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, fmt.Errorf("image action returned no image"))
}
project, err = s.repo.Get(ctx, projectID)
project, err = s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
@@ -2499,7 +2646,7 @@ func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, pro
}
return err
}
project, err := s.repo.Get(ctx, projectID)
project, err := s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
@@ -2522,7 +2669,7 @@ func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, pro
Memory: buildAgentMemory(project, prompt, "design", messages),
WebSearchEnabled: enableWebSearch,
})
project, err = s.repo.Get(ctx, projectID)
project, err = s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
@@ -2540,7 +2687,7 @@ func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, pro
if err := s.maybeAddPlannedResearchStep(ctx, projectID, threadID, project, prompt, "design", messages, enableWebSearch, taskPlan); err != nil {
return err
}
project, err = s.repo.Get(ctx, projectID)
project, err = s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
@@ -2549,7 +2696,7 @@ func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, pro
if err != nil {
return err
}
reqProject, err := s.repo.Get(ctx, projectID)
reqProject, err := s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
@@ -2565,14 +2712,14 @@ func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, pro
if err := s.applyCreateProjectTaskPlan(ctx, projectID, threadID, prompt, imageSize, taskPlan); err != nil {
return err
}
project, err = s.repo.Get(ctx, projectID)
project, err = s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "GPT Image 2", generationStepContent(taskPlan)); err != nil {
return err
}
project, err = s.repo.Get(ctx, projectID)
project, err = s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
@@ -2905,7 +3052,7 @@ func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectI
}
return err
}
project, err := s.repo.Get(ctx, projectID)
project, err := s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}
@@ -2928,7 +3075,7 @@ func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectI
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "GPT Image 2", generationStepContent(taskPlan)); err != nil {
return err
}
project, err = s.repo.Get(ctx, projectID)
project, err = s.getProjectWithBrandKit(ctx, projectID)
if err != nil {
return err
}