Files
moteva/server/internal/application/brand_kit_service.go
T
root 789f51b29e 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>
2026-07-10 20:58:55 +08:00

511 lines
17 KiB
Go

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])
}