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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user