From c94b163540d7040ee1c714406deb7dff9a344395 Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 10 Jul 2026 22:19:14 +0800 Subject: [PATCH] feat(server): add localized agent starter config endpoint Add a public GET /api/ui/agent-starters endpoint returning localized empty-state content (title, description, preview images, and starter prompts) for the canvas Agent panel. Content is server-configurable via AgentStarter config with built-in zh-CN/en-US fallbacks, and the route is exempt from authentication so shared canvases render the same content. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/API.md | 47 ++++++ server/README.md | 1 + server/img_infinite_canvas.api | 20 +++ server/internal/config/config.go | 19 +++ .../get_agent_starter_config_handler.go | 31 ++++ server/internal/handler/routes.go | 5 + .../handler/user_context_middleware.go | 3 +- .../handler/user_context_middleware_test.go | 18 +++ .../logic/get_agent_starter_config_logic.go | 143 ++++++++++++++++++ .../get_agent_starter_config_logic_test.go | 70 +++++++++ server/internal/types/types.go | 17 +++ 11 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 server/internal/handler/get_agent_starter_config_handler.go create mode 100644 server/internal/logic/get_agent_starter_config_logic.go create mode 100644 server/internal/logic/get_agent_starter_config_logic_test.go diff --git a/server/API.md b/server/API.md index 5633b0f..0ebab29 100644 --- a/server/API.md +++ b/server/API.md @@ -20,6 +20,53 @@ Base path: `/api` Returns credits, quick actions, recent projects, and inspiration items for the Moteva-style home screen. +## Agent Starters + +`GET /api/ui/agent-starters?locale=zh-CN` + +Returns the localized empty-state content used by the canvas Agent panel. The endpoint is public so shared canvases can render the same content. Supported locales are `zh-CN` and `en-US`; unsupported values fall back to `zh-CN`. + +```json +{ + "title": "今天想创作什么?", + "description": "输入提示词开始——或使用下方的起点", + "previewImages": [ + "https://static.codia.ai/public/images/f788bdcc/lg.webp", + "https://static.codia.ai/public/images/ec25067a/lg.webp", + "https://static.codia.ai/public/images/d4ccbe39/lg.webp" + ], + "starters": [ + { + "id": "ecommerce", + "label": "电商商品图", + "prompt": "Create a premium ecommerce product image for a portable espresso machine, with realistic lighting, clean composition, benefit callouts, and marketplace-ready visual hierarchy" + } + ] +} +``` + +The full block can be overridden through `AgentStarter` service configuration. `PreviewImages` accepts up to three URLs; each locale owns its ordered `Starters` list. Invalid or empty configured values fall back to the built-in content, preserving the frontend contract until an operations content store replaces the configuration source. + +```yaml +AgentStarter: + PreviewImages: + - https://cdn.example.com/agent-entry-1.webp + ZhCN: + Title: 今天想创作什么? + Description: 输入提示词开始——或使用下方的起点 + Starters: + - ID: campaign + Label: 运营活动 + Prompt: Create a campaign visual for the current promotion + EnUS: + Title: What would you like to create today? + Description: Enter a prompt to begin, or use one of the starting points below. + Starters: + - ID: campaign + Label: Campaign + Prompt: Create a campaign visual for the current promotion +``` + ## Auth Global mode is enabled in `etc/config.yaml`. Email verification codes are stored through the cache abstraction for 10 minutes; with `Cache.Driver: redis`, the code only lives in Redis until TTL or successful consumption. diff --git a/server/README.md b/server/README.md index 263c9a6..cebd76c 100644 --- a/server/README.md +++ b/server/README.md @@ -5,6 +5,7 @@ go-zero backend for the Moteva-style infinite canvas MVP. ## Features - Dashboard data for the Moteva home page +- Localized, server-configurable Agent starter content for the empty canvas chat - Project creation from prompt - User-owned Brand Kits with optional per-canvas binding and server-authoritative creative context - Project title rename diff --git a/server/img_infinite_canvas.api b/server/img_infinite_canvas.api index 121958b..bb84bc8 100644 --- a/server/img_infinite_canvas.api +++ b/server/img_infinite_canvas.api @@ -307,6 +307,23 @@ type DashboardResponse { Inspiration []InspirationItem `json:"inspiration"` } +type GetAgentStarterConfigRequest { + Locale string `form:"locale,optional"` +} + +type AgentStarterItem { + Id string `json:"id"` + Label string `json:"label"` + Prompt string `json:"prompt"` +} + +type AgentStarterConfigResponse { + Title string `json:"title"` + Description string `json:"description"` + PreviewImages []string `json:"previewImages"` + Starters []AgentStarterItem `json:"starters"` +} + type ProjectListResponse { Projects []ProjectSummary `json:"projects"` NextOffset int64 `json:"nextOffset"` @@ -1007,6 +1024,9 @@ service img_infinite_canvas-api { @handler dashboard get /dashboard returns (DashboardResponse) + @handler getAgentStarterConfig + get /ui/agent-starters (GetAgentStarterConfigRequest) returns (AgentStarterConfigResponse) + @handler getAuthOptions get /auth/options returns (AuthOptionsResponse) diff --git a/server/internal/config/config.go b/server/internal/config/config.go index fb482a2..8fe49a0 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -15,9 +15,28 @@ type Config struct { Auth AuthConfig Sharing SharingConfig Agent AgentConfig + AgentStarter AgentStarterConfig ObjectStorage ObjectStorageConfig } +type AgentStarterConfig struct { + PreviewImages []string `json:",optional"` + ZhCN AgentStarterLocaleConfig + EnUS AgentStarterLocaleConfig +} + +type AgentStarterLocaleConfig struct { + Title string `json:",optional"` + Description string `json:",optional"` + Starters []AgentStarterItemConfig `json:",optional"` +} + +type AgentStarterItemConfig struct { + ID string `json:",optional"` + Label string `json:",optional"` + Prompt string `json:",optional"` +} + type SharingConfig struct { EncryptionSecret string `json:",optional"` EncryptionSecretEnv string `json:",default=SHARING_ENCRYPTION_SECRET"` diff --git a/server/internal/handler/get_agent_starter_config_handler.go b/server/internal/handler/get_agent_starter_config_handler.go new file mode 100644 index 0000000..d37d375 --- /dev/null +++ b/server/internal/handler/get_agent_starter_config_handler.go @@ -0,0 +1,31 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package handler + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "img_infinite_canvas/internal/logic" + "img_infinite_canvas/internal/svc" + "img_infinite_canvas/internal/types" +) + +func getAgentStarterConfigHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GetAgentStarterConfigRequest + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + + l := logic.NewGetAgentStarterConfigLogic(r.Context(), svcCtx) + resp, err := l.GetAgentStarterConfig(&req) + if err != nil { + httpx.ErrorCtx(r.Context(), w, err) + } else { + httpx.OkJsonCtx(r.Context(), w, resp) + } + } +} diff --git a/server/internal/handler/routes.go b/server/internal/handler/routes.go index 359ad15..482918d 100644 --- a/server/internal/handler/routes.go +++ b/server/internal/handler/routes.go @@ -329,6 +329,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/shares/:shareId", Handler: resolveShareHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/ui/agent-starters", + Handler: getAgentStarterConfigHandler(serverCtx), + }, }, rest.WithPrefix("/api"), ) diff --git a/server/internal/handler/user_context_middleware.go b/server/internal/handler/user_context_middleware.go index df62e93..c768432 100644 --- a/server/internal/handler/user_context_middleware.go +++ b/server/internal/handler/user_context_middleware.go @@ -107,7 +107,8 @@ func requiresAuthenticatedUser(r *http.Request) bool { strings.HasPrefix(path, "/api/shares/") || path == "/api/auth/options" || path == "/api/health" || - path == "/api/dashboard" { + path == "/api/dashboard" || + path == "/api/ui/agent-starters" { return false } return true diff --git a/server/internal/handler/user_context_middleware_test.go b/server/internal/handler/user_context_middleware_test.go index 6bdcf4a..ff50ea1 100644 --- a/server/internal/handler/user_context_middleware_test.go +++ b/server/internal/handler/user_context_middleware_test.go @@ -86,6 +86,24 @@ func TestUserContextMiddlewareIgnoresSpoofedUserIDOnPublicRoute(t *testing.T) { } } +func TestUserContextMiddlewareAllowsPublicAgentStarterConfig(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/ui/agent-starters?locale=en-US", nil) + rec := httptest.NewRecorder() + + called := false + UserContextMiddleware(fakeBearerAuthenticator{})(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })(rec, req) + + if !called { + t.Fatal("expected public agent starter config route to continue") + } + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code) + } +} + func TestUserContextMiddlewareAuthorizesAnonymousCanvasShare(t *testing.T) { ownerID := "d1d2fbbb-97bb-4406-8d0b-ec814cc65092" authorizer := fakeShareAuthorizer{access: sharingmodule.Access{ diff --git a/server/internal/logic/get_agent_starter_config_logic.go b/server/internal/logic/get_agent_starter_config_logic.go new file mode 100644 index 0000000..0448ea2 --- /dev/null +++ b/server/internal/logic/get_agent_starter_config_logic.go @@ -0,0 +1,143 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package logic + +import ( + "context" + "strings" + + "img_infinite_canvas/internal/config" + "img_infinite_canvas/internal/svc" + "img_infinite_canvas/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetAgentStarterConfigLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetAgentStarterConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAgentStarterConfigLogic { + return &GetAgentStarterConfigLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetAgentStarterConfigLogic) GetAgentStarterConfig(req *types.GetAgentStarterConfigRequest) (resp *types.AgentStarterConfigResponse, err error) { + locale := "zh-CN" + if req != nil && strings.EqualFold(strings.TrimSpace(req.Locale), "en-US") { + locale = "en-US" + } + content := resolveAgentStarterLocale(l.svcCtx.Config.AgentStarter, locale) + + return &types.AgentStarterConfigResponse{ + Title: content.Title, + Description: content.Description, + PreviewImages: resolveAgentStarterImages(l.svcCtx.Config.AgentStarter.PreviewImages), + Starters: toAgentStarterItems(content.Starters), + }, nil +} + +var defaultAgentStarterImages = []string{ + "https://static.codia.ai/public/images/f788bdcc/lg.webp", + "https://static.codia.ai/public/images/ec25067a/lg.webp", + "https://static.codia.ai/public/images/d4ccbe39/lg.webp", +} + +var defaultAgentStarterPrompts = []config.AgentStarterItemConfig{ + {ID: "ecommerce", Label: "电商商品图", Prompt: "Create a premium ecommerce product image for a portable espresso machine, with realistic lighting, clean composition, benefit callouts, and marketplace-ready visual hierarchy"}, + {ID: "poster", Label: "海报", Prompt: "Design a bold launch poster for a new skincare product line, with strong typography, product focus, campaign headline, date, and a polished premium layout"}, + {ID: "brand-visual", Label: "品牌视觉", Prompt: "Create a brand identity direction for a modern wellness drink brand, including color palette, typography mood, logo usage, packaging accents, and social-ready visual elements"}, + {ID: "social-media", Label: "社交媒体", Prompt: "Create a square Instagram promotion for a weekend fashion sale, with product cutouts, discount headline, brand colors, and a layout that works as a paid social ad"}, + {ID: "logo", Label: "Logo 设计", Prompt: "Design a clean vector logo for a direct-to-consumer home goods brand named Loom & Line, with a simple mark, balanced wordmark, and clear black-and-white usage"}, +} + +func resolveAgentStarterLocale(options config.AgentStarterConfig, locale string) config.AgentStarterLocaleConfig { + fallback := config.AgentStarterLocaleConfig{ + Title: "今天想创作什么?", + Description: "输入提示词开始——或使用下方的起点", + Starters: defaultAgentStarterPrompts, + } + configured := options.ZhCN + if locale == "en-US" { + fallback.Title = "What would you like to create today?" + fallback.Description = "Enter a prompt to begin, or use one of the starting points below." + fallback.Starters = englishAgentStarterPrompts(defaultAgentStarterPrompts) + configured = options.EnUS + } + + if title := strings.TrimSpace(configured.Title); title != "" { + fallback.Title = title + } + if description := strings.TrimSpace(configured.Description); description != "" { + fallback.Description = description + } + if starters := validAgentStarterConfigs(configured.Starters); len(starters) > 0 { + fallback.Starters = starters + } + return fallback +} + +func englishAgentStarterPrompts(items []config.AgentStarterItemConfig) []config.AgentStarterItemConfig { + labels := map[string]string{ + "ecommerce": "E-commerce product", + "poster": "Poster", + "brand-visual": "Brand visual", + "social-media": "Social media", + "logo": "Logo design", + } + result := make([]config.AgentStarterItemConfig, 0, len(items)) + for _, item := range items { + item.Label = labels[item.ID] + result = append(result, item) + } + return result +} + +func resolveAgentStarterImages(configured []string) []string { + images := make([]string, 0, len(configured)) + for _, image := range configured { + if image = strings.TrimSpace(image); image != "" { + images = append(images, image) + } + if len(images) == 3 { + break + } + } + if len(images) == 0 { + return append([]string(nil), defaultAgentStarterImages...) + } + return images +} + +func validAgentStarterConfigs(configured []config.AgentStarterItemConfig) []config.AgentStarterItemConfig { + starters := make([]config.AgentStarterItemConfig, 0, len(configured)) + seen := make(map[string]struct{}, len(configured)) + for _, item := range configured { + item.ID = strings.TrimSpace(item.ID) + item.Label = strings.TrimSpace(item.Label) + item.Prompt = strings.TrimSpace(item.Prompt) + if item.ID == "" || item.Label == "" || item.Prompt == "" { + continue + } + if _, exists := seen[item.ID]; exists { + continue + } + seen[item.ID] = struct{}{} + starters = append(starters, item) + } + return starters +} + +func toAgentStarterItems(configured []config.AgentStarterItemConfig) []types.AgentStarterItem { + items := make([]types.AgentStarterItem, 0, len(configured)) + for _, item := range configured { + items = append(items, types.AgentStarterItem{Id: item.ID, Label: item.Label, Prompt: item.Prompt}) + } + return items +} diff --git a/server/internal/logic/get_agent_starter_config_logic_test.go b/server/internal/logic/get_agent_starter_config_logic_test.go new file mode 100644 index 0000000..65da623 --- /dev/null +++ b/server/internal/logic/get_agent_starter_config_logic_test.go @@ -0,0 +1,70 @@ +package logic + +import ( + "context" + "testing" + + "img_infinite_canvas/internal/config" + "img_infinite_canvas/internal/svc" + "img_infinite_canvas/internal/types" +) + +func TestGetAgentStarterConfigReturnsLocalizedDefaults(t *testing.T) { + logic := NewGetAgentStarterConfigLogic(context.Background(), &svc.ServiceContext{}) + + response, err := logic.GetAgentStarterConfig(&types.GetAgentStarterConfigRequest{Locale: "en-US"}) + if err != nil { + t.Fatalf("get agent starter config: %v", err) + } + if response.Title != "What would you like to create today?" { + t.Fatalf("unexpected title: %q", response.Title) + } + if len(response.PreviewImages) != 3 { + t.Fatalf("expected three preview images, got %d", len(response.PreviewImages)) + } + if len(response.Starters) != 5 { + t.Fatalf("expected five starters, got %d", len(response.Starters)) + } + expected := []types.AgentStarterItem{ + {Id: "ecommerce", Label: "E-commerce product", Prompt: "Create a premium ecommerce product image for a portable espresso machine, with realistic lighting, clean composition, benefit callouts, and marketplace-ready visual hierarchy"}, + {Id: "poster", Label: "Poster", Prompt: "Design a bold launch poster for a new skincare product line, with strong typography, product focus, campaign headline, date, and a polished premium layout"}, + {Id: "brand-visual", Label: "Brand visual", Prompt: "Create a brand identity direction for a modern wellness drink brand, including color palette, typography mood, logo usage, packaging accents, and social-ready visual elements"}, + {Id: "social-media", Label: "Social media", Prompt: "Create a square Instagram promotion for a weekend fashion sale, with product cutouts, discount headline, brand colors, and a layout that works as a paid social ad"}, + {Id: "logo", Label: "Logo design", Prompt: "Design a clean vector logo for a direct-to-consumer home goods brand named Loom & Line, with a simple mark, balanced wordmark, and clear black-and-white usage"}, + } + for index, item := range expected { + if response.Starters[index] != item { + t.Fatalf("unexpected starter %d: got %#v, want %#v", index, response.Starters[index], item) + } + } +} + +func TestGetAgentStarterConfigUsesConfiguredContent(t *testing.T) { + serviceContext := &svc.ServiceContext{Config: config.Config{AgentStarter: config.AgentStarterConfig{ + PreviewImages: []string{" https://cdn.example.com/one.webp ", ""}, + ZhCN: config.AgentStarterLocaleConfig{ + Title: "新的入口标题", + Description: "新的入口说明", + Starters: []config.AgentStarterItemConfig{ + {ID: "campaign", Label: "运营活动", Prompt: "Create a campaign visual"}, + {ID: "campaign", Label: "重复项", Prompt: "Ignore duplicate"}, + {ID: "incomplete", Label: "不完整"}, + }, + }, + }}} + logic := NewGetAgentStarterConfigLogic(context.Background(), serviceContext) + + response, err := logic.GetAgentStarterConfig(&types.GetAgentStarterConfigRequest{Locale: "zh-CN"}) + if err != nil { + t.Fatalf("get configured agent starter content: %v", err) + } + if response.Title != "新的入口标题" || response.Description != "新的入口说明" { + t.Fatalf("unexpected configured copy: %+v", response) + } + if len(response.PreviewImages) != 1 || response.PreviewImages[0] != "https://cdn.example.com/one.webp" { + t.Fatalf("unexpected configured images: %#v", response.PreviewImages) + } + if len(response.Starters) != 1 || response.Starters[0].Id != "campaign" { + t.Fatalf("unexpected configured starters: %#v", response.Starters) + } +} diff --git a/server/internal/types/types.go b/server/internal/types/types.go index d7ce33c..0a9860d 100644 --- a/server/internal/types/types.go +++ b/server/internal/types/types.go @@ -77,6 +77,19 @@ type AgentPreferToolCategories struct { Image []string `json:"IMAGE,optional"` } +type AgentStarterConfigResponse struct { + Title string `json:"title"` + Description string `json:"description"` + PreviewImages []string `json:"previewImages"` + Starters []AgentStarterItem `json:"starters"` +} + +type AgentStarterItem struct { + Id string `json:"id"` + Label string `json:"label"` + Prompt string `json:"prompt"` +} + type AgentThreadListRequest struct { ProjectId string `json:"projectId"` Page int64 `json:"page,optional"` @@ -545,6 +558,10 @@ type GeneratorTaskSubmitResponse struct { Data GeneratorTaskSubmitData `json:"data"` } +type GetAgentStarterConfigRequest struct { + Locale string `form:"locale,optional"` +} + type GlobalEmailLoginRequest struct { Email string `json:"email"` Code string `json:"code"`