diff --git a/internal/agent/model.go b/internal/agent/model.go
index d8a06663..010d192e 100644
--- a/internal/agent/model.go
+++ b/internal/agent/model.go
@@ -21,6 +21,7 @@ type Agent struct {
RuntimeID string `json:"runtime_id,omitempty"`
RuntimeKind string `json:"runtime_kind,omitempty"`
Image string `json:"image,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
BoxID string `json:"box_id,omitempty"`
RuntimeOptions map[string]any `json:"runtime_options,omitempty"`
Role string `json:"role"`
@@ -37,6 +38,7 @@ type CreateAgentSpec struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
RuntimeKind string `json:"runtime_kind,omitempty"`
FromTemplate string `json:"from_template,omitempty"`
Role string `json:"role,omitempty"`
@@ -51,6 +53,7 @@ type UpdateRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Image *string `json:"image,omitempty"`
+ Avatar *string `json:"avatar,omitempty"`
RuntimeOptions *map[string]any `json:"runtime_options,omitempty"`
AgentProfile *AgentProfile `json:"agent_profile,omitempty"`
}
diff --git a/internal/agent/service.go b/internal/agent/service.go
index e7fb7929..e23d71da 100644
--- a/internal/agent/service.go
+++ b/internal/agent/service.go
@@ -3,6 +3,7 @@ package agent
import (
"bytes"
"context"
+ "crypto/sha256"
"errors"
"fmt"
"io"
@@ -431,6 +432,19 @@ func (s *Service) ensureManager(ctx context.Context, forceRecreate bool, imageOv
if s == nil {
return Agent{}, fmt.Errorf("agent service is required")
}
+ managerAvatar := ""
+ s.mu.RLock()
+ if existing, ok := s.agents[ManagerUserID]; ok {
+ managerAvatar = strings.TrimSpace(existing.Avatar)
+ } else {
+ for _, existing := range s.agents {
+ if isManagerAgent(existing) {
+ managerAvatar = strings.TrimSpace(existing.Avatar)
+ break
+ }
+ }
+ }
+ s.mu.RUnlock()
runtimeKind := runtimeKindForGatewayRuntime(runtimeOverride)
if strings.TrimSpace(runtimeOverride) != "" && runtimeKind == "" {
return Agent{}, fmt.Errorf("gateway runtime %q is not supported", runtimeOverride)
@@ -522,6 +536,7 @@ func (s *Service) ensureManager(ctx context.Context, forceRecreate bool, imageOv
RuntimeID: runtimeIDForAgentID(ManagerUserID),
RuntimeKind: runtimeKind,
Image: managerImage,
+ Avatar: managerAvatar,
Status: "profile_incomplete",
CreatedAt: now,
Role: RoleManager,
@@ -600,6 +615,7 @@ func (s *Service) ensureManager(ctx context.Context, forceRecreate bool, imageOv
RuntimeID: runtimeIDForAgentID(ManagerUserID),
RuntimeKind: s.gatewayRuntimeKind(),
Image: managerImage,
+ Avatar: managerAvatar,
BoxID: info.ID,
Status: string(info.State),
CreatedAt: info.CreatedAt.UTC(),
@@ -913,6 +929,9 @@ func (s *Service) replace(ctx context.Context, req CreateRequest) (Agent, error)
if strings.TrimSpace(spec.Image) == "" {
spec.Image = existing.Image
}
+ if strings.TrimSpace(spec.Avatar) == "" {
+ spec.Avatar = existing.Avatar
+ }
if strings.TrimSpace(spec.RuntimeKind) == "" {
spec.RuntimeKind = existing.RuntimeKind
}
@@ -956,6 +975,7 @@ func mergeReplaceSpec(existing Agent, next CreateAgentSpec, fieldMask []string)
Name: existing.Name,
Description: existing.Description,
Image: existing.Image,
+ Avatar: existing.Avatar,
RuntimeKind: existing.RuntimeKind,
Role: existing.Role,
Status: existing.Status,
@@ -977,6 +997,8 @@ func mergeReplaceSpec(existing Agent, next CreateAgentSpec, fieldMask []string)
merged.Description = next.Description
case "image":
merged.Image = next.Image
+ case "avatar":
+ merged.Avatar = next.Avatar
case "runtime_kind":
merged.RuntimeKind = next.RuntimeKind
case "role":
@@ -1045,7 +1067,7 @@ func (s *Service) agentSnapshot(id string) (Agent, bool) {
}
func (s *Service) resolveAgentBox(ctx context.Context, rt sandbox.Runtime, got Agent) (sandbox.Instance, string, error) {
- keys := make([]string, 0, 2)
+ keys := make([]string, 0, 3)
if boxID := strings.TrimSpace(got.BoxID); boxID != "" {
keys = append(keys, boxID)
}
@@ -1054,6 +1076,18 @@ func (s *Service) resolveAgentBox(ctx context.Context, rt sandbox.Runtime, got A
keys = append(keys, name)
}
}
+ if runtimeName := safeSandboxNameForAgent(got.ID, got.Name); runtimeName != "" {
+ duplicate := false
+ for _, key := range keys {
+ if key == runtimeName {
+ duplicate = true
+ break
+ }
+ }
+ if !duplicate {
+ keys = append(keys, runtimeName)
+ }
+ }
if len(keys) == 0 {
return nil, "", fmt.Errorf("agent box identifier is required")
}
@@ -1211,11 +1245,12 @@ func (s *Service) Delete(ctx context.Context, id string) error {
return fmt.Errorf("remove agent box: %w", err)
}
} else {
- rt, ensureErr := s.ensureRuntime(existing.Name)
+ runtimeName := sandboxNameForAgent(existing)
+ rt, ensureErr := s.ensureRuntime(runtimeName)
if ensureErr != nil {
return ensureErr
}
- runtimeHome, homeErr := s.sandboxRuntimeHome(existing.Name)
+ runtimeHome, homeErr := s.sandboxRuntimeHome(runtimeName)
if homeErr != nil {
return homeErr
}
@@ -1251,7 +1286,7 @@ func (s *Service) Delete(ctx context.Context, id string) error {
}
delete(s.agents, id)
s.deleteRuntimeRecordLocked(current.RuntimeID)
- runtimeHome, err := s.sandboxRuntimeHome(current.Name)
+ runtimeHome, err := s.sandboxRuntimeHome(sandboxNameForAgent(current))
if err != nil {
s.mu.Unlock()
return err
@@ -1297,6 +1332,48 @@ func isRetryableRemoveAllError(err error) bool {
return errors.Is(err, syscall.ENOTEMPTY) || strings.Contains(strings.ToLower(err.Error()), "directory not empty")
}
+func safeSandboxNameForAgent(id, name string) string {
+ name = strings.TrimSpace(name)
+ if isDockerSafeSandboxName(name) {
+ return name
+ }
+ id = strings.TrimSpace(id)
+ if isDockerSafeSandboxName(id) {
+ return id
+ }
+ seed := id
+ if seed == "" {
+ seed = name
+ }
+ if seed == "" {
+ seed = "agent"
+ }
+ sum := sha256.Sum256([]byte(seed))
+ return fmt.Sprintf("agent-%x", sum[:8])
+}
+
+func sandboxNameForAgent(got Agent) string {
+ return safeSandboxNameForAgent(got.ID, got.Name)
+}
+
+func isDockerSafeSandboxName(name string) bool {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return false
+ }
+ for idx := 0; idx < len(name); idx++ {
+ ch := name[idx]
+ valid := (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9')
+ if idx > 0 {
+ valid = valid || ch == '_' || ch == '.' || ch == '-'
+ }
+ if !valid {
+ return false
+ }
+ }
+ return true
+}
+
func (s *Service) List() []Agent {
s.mu.RLock()
agents := sortedAgentsFromMap(s.agents)
@@ -1371,6 +1448,7 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent
name := strings.TrimSpace(spec.Name)
description := strings.TrimSpace(spec.Description)
image := strings.TrimSpace(spec.Image)
+ avatar := strings.TrimSpace(spec.Avatar)
runtimeKind := strings.TrimSpace(spec.RuntimeKind)
switch {
case name == "":
@@ -1414,6 +1492,10 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent
if err := s.ensureCodexResponsesAPI(ctx, runtimeKind, resolvedProfile); err != nil {
return Agent{}, err
}
+ runtimeAgentName := name
+ if isGatewayRuntimeKind(runtimeKind) {
+ runtimeAgentName = safeSandboxNameForAgent(id, name)
+ }
runtimeProfile := s.runtimeProfileForKind(runtimeKind, id, name, description, resolvedProfile)
if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{
RuntimeID: runtimeIDForAgentID(id),
@@ -1425,32 +1507,32 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent
return Agent{}, fmt.Errorf("provision worker runtime: %w", err)
}
if testCreateGatewayBoxHook != nil && isGatewayRuntimeKind(runtimeKind) {
- rt, err := s.ensureRuntime(name)
+ rt, err := s.ensureRuntime(runtimeAgentName)
if err != nil {
return Agent{}, err
}
- runtimeHome, err := s.sandboxRuntimeHome(name)
+ runtimeHome, err := s.sandboxRuntimeHome(runtimeAgentName)
if err != nil {
return Agent{}, err
}
defer func() {
_ = s.closeRuntime(runtimeHome, rt)
}()
- box, info, err := s.createGatewayBox(ctx, rt, image, name, id, resolvedProfile)
+ box, info, err := s.createGatewayBox(ctx, rt, image, runtimeAgentName, id, resolvedProfile)
if err != nil {
return Agent{}, fmt.Errorf("create worker box: %w", err)
}
defer func() {
_ = s.closeBox(box)
}()
- return s.persistCreatedWorker(ctx, id, name, description, image, runtimeKind, resolvedProfile, spec.RuntimeOptions, agentruntime.Info{
+ return s.persistCreatedWorker(ctx, id, name, description, image, avatar, runtimeKind, resolvedProfile, spec.RuntimeOptions, agentruntime.Info{
HandleID: strings.TrimSpace(info.ID),
State: agentruntime.State(info.State),
CreatedAt: info.CreatedAt.UTC(),
})
}
if runtimeKind == RuntimeKindCodex {
- if err := s.persistStartingWorker(ctx, id, name, description, image, runtimeKind, resolvedProfile, spec.RuntimeOptions); err != nil {
+ if err := s.persistStartingWorker(ctx, id, name, description, image, avatar, runtimeKind, resolvedProfile, spec.RuntimeOptions); err != nil {
return Agent{}, err
}
defer func() {
@@ -1462,7 +1544,7 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent
handle, err := runtimeImpl.New(ctx, agentruntime.Spec{
RuntimeID: runtimeIDForAgentID(id),
AgentID: id,
- AgentName: name,
+ AgentName: runtimeAgentName,
Image: image,
Profile: runtimeProfile,
})
@@ -1475,10 +1557,10 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent
CreatedAt: time.Now().UTC(),
}
- return s.persistCreatedWorker(ctx, id, name, description, image, runtimeKind, resolvedProfile, spec.RuntimeOptions, info)
+ return s.persistCreatedWorker(ctx, id, name, description, image, avatar, runtimeKind, resolvedProfile, spec.RuntimeOptions, info)
}
-func (s *Service) persistStartingWorker(ctx context.Context, id, name, description, image, runtimeKind string, profile AgentProfile, runtimeOptions map[string]any) error {
+func (s *Service) persistStartingWorker(ctx context.Context, id, name, description, image, avatar, runtimeKind string, profile AgentProfile, runtimeOptions map[string]any) error {
s.mu.Lock()
if _, ok := s.agents[id]; ok {
@@ -1490,7 +1572,7 @@ func (s *Service) persistStartingWorker(ctx context.Context, id, name, descripti
return fmt.Errorf("agent name %q already exists", name)
}
- worker := newWorkerAgent(id, name, description, image, runtimeKind, profile, runtimeOptions, agentruntime.Info{
+ worker := newWorkerAgent(id, name, description, image, avatar, runtimeKind, profile, runtimeOptions, agentruntime.Info{
State: agentruntime.StateCreated,
CreatedAt: time.Now().UTC(),
})
@@ -1517,7 +1599,7 @@ func (s *Service) removeStartingWorker(ctx context.Context, id string) error {
return err
}
-func (s *Service) persistCreatedWorker(ctx context.Context, id, name, description, image, runtimeKind string, profile AgentProfile, createRuntimeExt map[string]any, info agentruntime.Info) (Agent, error) {
+func (s *Service) persistCreatedWorker(ctx context.Context, id, name, description, image, avatar, runtimeKind string, profile AgentProfile, createRuntimeExt map[string]any, info agentruntime.Info) (Agent, error) {
s.mu.Lock()
if existing, ok := s.agents[id]; ok && !isStartingWorker(existing) {
@@ -1529,7 +1611,7 @@ func (s *Service) persistCreatedWorker(ctx context.Context, id, name, descriptio
return Agent{}, fmt.Errorf("agent name %q already exists", name)
}
- worker := newWorkerAgent(id, name, description, image, runtimeKind, profile, createRuntimeExt, info)
+ worker := newWorkerAgent(id, name, description, image, avatar, runtimeKind, profile, createRuntimeExt, info)
s.agents[worker.ID] = worker
s.syncRuntimeRecordLocked(worker)
if worker.AgentProfile.ProfileComplete {
@@ -1549,7 +1631,7 @@ func (s *Service) persistCreatedWorker(ctx context.Context, id, name, descriptio
return created, nil
}
-func newWorkerAgent(id, name, description, image, runtimeKind string, profile AgentProfile, runtimeOptions map[string]any, info agentruntime.Info) Agent {
+func newWorkerAgent(id, name, description, image, avatar, runtimeKind string, profile AgentProfile, runtimeOptions map[string]any, info agentruntime.Info) Agent {
createdAt := info.CreatedAt.UTC()
if info.CreatedAt.IsZero() {
createdAt = time.Now().UTC()
@@ -1569,6 +1651,7 @@ func newWorkerAgent(id, name, description, image, runtimeKind string, profile Ag
RuntimeID: runtimeIDForAgentID(id),
RuntimeKind: runtimeKind,
Image: image,
+ Avatar: strings.TrimSpace(avatar),
BoxID: strings.TrimSpace(info.HandleID),
Description: description,
Status: string(state),
diff --git a/internal/agent/service_profiles.go b/internal/agent/service_profiles.go
index 7e782aa8..1cd8a239 100644
--- a/internal/agent/service_profiles.go
+++ b/internal/agent/service_profiles.go
@@ -157,6 +157,9 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Age
if req.Image != nil {
current.Image = strings.TrimSpace(*req.Image)
}
+ if req.Avatar != nil {
+ current.Avatar = strings.TrimSpace(*req.Avatar)
+ }
if req.AgentProfile != nil || req.RuntimeOptions != nil {
profileUpdated = true
profile := current.AgentProfile
@@ -323,11 +326,12 @@ func (s *Service) Recreate(ctx context.Context, id string) (Agent, error) {
}
if testCreateGatewayBoxHook != nil {
- rt, err := s.ensureRuntime(got.Name)
+ runtimeName := sandboxNameForAgent(got)
+ rt, err := s.ensureRuntime(runtimeName)
if err != nil {
return Agent{}, err
}
- runtimeHome, err := s.sandboxRuntimeHome(got.Name)
+ runtimeHome, err := s.sandboxRuntimeHome(runtimeName)
if err != nil {
return Agent{}, err
}
@@ -340,7 +344,7 @@ func (s *Service) Recreate(ctx context.Context, id string) (Agent, error) {
return Agent{}, fmt.Errorf("remove existing agent box: %w", deleteErr)
}
}
- box, sandboxInfo, err := s.createGatewayBox(ctx, rt, image, got.Name, got.ID, profile)
+ box, sandboxInfo, err := s.createGatewayBox(ctx, rt, image, runtimeName, got.ID, profile)
if err != nil {
return Agent{}, fmt.Errorf("create agent box: %w", err)
}
@@ -366,17 +370,21 @@ func (s *Service) Recreate(ctx context.Context, id string) (Agent, error) {
}
runtimeProfile := s.runtimeProfileForKind(runtimeKind, got.ID, got.Name, got.Description, profile)
+ runtimeAgentName := got.Name
+ if isGatewayRuntimeKind(runtimeKind) {
+ runtimeAgentName = sandboxNameForAgent(got)
+ }
createSpec := agentruntime.Spec{
RuntimeID: normalizeRuntimeID(got.RuntimeID, got.ID),
AgentID: got.ID,
- AgentName: got.Name,
+ AgentName: runtimeAgentName,
Image: image,
Profile: runtimeProfile,
}
if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{
RuntimeID: createSpec.RuntimeID,
AgentID: createSpec.AgentID,
- AgentName: createSpec.AgentName,
+ AgentName: got.Name,
Profile: runtimeProfile,
}); err != nil {
return Agent{}, fmt.Errorf("provision agent runtime: %w", err)
diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go
index a04405f6..b1977468 100644
--- a/internal/agent/service_test.go
+++ b/internal/agent/service_test.go
@@ -1171,6 +1171,7 @@ func TestRecreateTriggersLifecycleObserver(t *testing.T) {
svc.agents["u-alice"] = Agent{
ID: "u-alice",
Name: "alice",
+ Avatar: "avatar/cartoon-7.png",
Role: RoleWorker,
RuntimeID: "rt-u-alice",
RuntimeKind: RuntimeKindCodex,
@@ -1194,6 +1195,9 @@ func TestRecreateTriggersLifecycleObserver(t *testing.T) {
if got.BoxID != "codex-session-alice-new" {
t.Fatalf("Recreate().BoxID = %q, want %q", got.BoxID, "codex-session-alice-new")
}
+ if got.Avatar != "avatar/cartoon-7.png" {
+ t.Fatalf("Recreate().Avatar = %q, want %q", got.Avatar, "avatar/cartoon-7.png")
+ }
if len(observer.ensureCalls) != 1 || observer.ensureCalls[0].ID != "u-alice" {
t.Fatalf("EnsureAgent() calls = %+v, want one call for u-alice", observer.ensureCalls)
}
@@ -1683,7 +1687,6 @@ func TestCreateReplaceManagerUsesRequestedImage(t *testing.T) {
}); err != nil {
t.Fatalf("seed Create() error = %v", err)
}
-
replaced, err := svc.Create(context.Background(), CreateRequest{
Spec: CreateAgentSpec{
ID: ManagerUserID,
@@ -2024,6 +2027,10 @@ func TestCreateReplaceManagerSwitchesRuntimeKindUsesRequestedImage(t *testing.T)
}); err != nil {
t.Fatalf("seed Create() error = %v", err)
}
+ avatar := "avatar/cartoon-6.png"
+ if _, err := svc.Update(context.Background(), ManagerUserID, UpdateRequest{Avatar: &avatar}); err != nil {
+ t.Fatalf("Update() avatar error = %v", err)
+ }
const requestedImage = "openclaw-manager:requested"
replaced, err := svc.Create(context.Background(), CreateRequest{
@@ -2044,6 +2051,9 @@ func TestCreateReplaceManagerSwitchesRuntimeKindUsesRequestedImage(t *testing.T)
if got, want := replaced.Image, requestedImage; got != want {
t.Fatalf("Create() image = %q, want %q", got, want)
}
+ if got, want := replaced.Avatar, avatar; got != want {
+ t.Fatalf("Create() avatar = %q, want %q", got, want)
+ }
if got, want := svc.managerImage, requestedImage; got != want {
t.Fatalf("managerImage = %q, want %q", got, want)
}
@@ -2866,6 +2876,47 @@ func TestCreateWorkerUsesRequestedImageWhenGatewayRuntimeExplicit(t *testing.T)
}
}
+func TestCreateWorkerUsesSafeRuntimeNameForGatewayRuntime(t *testing.T) {
+ var gotRuntimeName string
+ svc, err := NewService(
+ testModelConfig(),
+ config.ServerConfig{},
+ "manager-image:1",
+ "",
+ WithRuntime(fakeAgentRuntime{
+ kind: RuntimeKindPicoClawSandbox,
+ new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) {
+ gotRuntimeName = spec.AgentName
+ return agentruntime.Handle{
+ RuntimeID: spec.RuntimeID,
+ HandleID: "box-" + spec.AgentName,
+ }, nil
+ },
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewService() error = %v", err)
+ }
+
+ got, err := svc.CreateWorker(context.Background(), CreateAgentSpec{
+ Name: "测试头像",
+ RuntimeKind: RuntimeKindPicoClawSandbox,
+ Image: "worker-image:1",
+ })
+ if err != nil {
+ t.Fatalf("CreateWorker() error = %v", err)
+ }
+ if got.Name != "测试头像" {
+ t.Fatalf("CreateWorker().Name = %q, want display name to be preserved", got.Name)
+ }
+ if !isDockerSafeSandboxName(gotRuntimeName) || gotRuntimeName == got.Name {
+ t.Fatalf("runtime AgentName = %q, want Docker-safe name distinct from display name", gotRuntimeName)
+ }
+ if got.BoxID != "box-"+gotRuntimeName {
+ t.Fatalf("CreateWorker().BoxID = %q, want handle from safe runtime name", got.BoxID)
+ }
+}
+
func TestCreateWorkerRejectsMissingImageWhenGatewayRuntimeExplicit(t *testing.T) {
var gotImage string
SetTestHooks(
diff --git a/internal/agent/store.go b/internal/agent/store.go
index 0a603255..729ac195 100644
--- a/internal/agent/store.go
+++ b/internal/agent/store.go
@@ -41,6 +41,7 @@ type persistedAgent struct {
RuntimeID string `json:"runtime_id,omitempty"`
RuntimeKind string `json:"runtime_kind,omitempty"`
Image string `json:"image,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
BoxID string `json:"box_id,omitempty"`
RuntimeOptions map[string]any `json:"runtime_options,omitempty"`
Role string `json:"role"`
@@ -76,6 +77,7 @@ func newPersistedAgent(a Agent) persistedAgent {
RuntimeID: a.RuntimeID,
RuntimeKind: a.RuntimeKind,
Image: a.Image,
+ Avatar: a.Avatar,
BoxID: a.BoxID,
RuntimeOptions: topRX,
Role: a.Role,
@@ -116,6 +118,7 @@ func (a persistedAgent) toAgent() Agent {
RuntimeID: a.RuntimeID,
RuntimeKind: a.RuntimeKind,
Image: a.Image,
+ Avatar: a.Avatar,
BoxID: a.BoxID,
RuntimeOptions: rx,
Role: a.Role,
diff --git a/internal/api/handler.go b/internal/api/handler.go
index c6d00dc5..a2eaf0f5 100644
--- a/internal/api/handler.go
+++ b/internal/api/handler.go
@@ -101,6 +101,7 @@ type agentResponse struct {
RuntimeID string `json:"runtime_id,omitempty"`
RuntimeKind string `json:"runtime_kind,omitempty"`
Image string `json:"image,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
BoxID string `json:"box_id,omitempty"`
Role string `json:"role"`
Status string `json:"status"`
@@ -508,12 +509,14 @@ func (h *Handler) handleBotByID(w http.ResponseWriter, r *http.Request) {
updated, err := h.botSvc.PatchNotificationBot(r.Context(), channelName, id, bot.CreateRequest{
Name: patch.Name,
Description: patch.Description,
+ Avatar: patch.Avatar,
RuntimeOptions: patch.RuntimeOptions,
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
+ h.publishUpdatedBotUser(updated)
writeJSON(w, http.StatusOK, updated)
case http.MethodDelete:
if err := h.botSvc.Delete(r.Context(), channelName, id); err != nil {
@@ -601,6 +604,7 @@ func (h *Handler) handleAgentByID(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), status)
return
}
+ h.publishUpdatedAgentUser(updated)
writeJSON(w, http.StatusOK, presentAgent(updated))
case http.MethodDelete:
if err := h.svc.Delete(r.Context(), id); err != nil {
@@ -617,6 +621,54 @@ func (h *Handler) handleAgentByID(w http.ResponseWriter, r *http.Request) {
}
}
+func (h *Handler) publishUpdatedAgentUser(updated agent.Agent) {
+ if h == nil || h.im == nil {
+ return
+ }
+ user, ok, err := h.im.UpdateAgentUser(im.UpdateAgentUserRequest{
+ ID: updated.ID,
+ Name: updated.Name,
+ Role: updated.Role,
+ Avatar: updated.Avatar,
+ })
+ if err != nil || !ok {
+ return
+ }
+ if h.imBus != nil {
+ userCopy := user
+ h.imBus.Publish(im.Event{
+ Type: im.EventTypeUserUpdated,
+ User: &userCopy,
+ })
+ }
+}
+
+func (h *Handler) publishUpdatedBotUser(updated bot.Bot) {
+ if h == nil || h.im == nil {
+ return
+ }
+ id := strings.TrimSpace(updated.UserID)
+ if id == "" {
+ id = strings.TrimSpace(updated.ID)
+ }
+ user, ok, err := h.im.UpdateAgentUser(im.UpdateAgentUserRequest{
+ ID: id,
+ Name: updated.Name,
+ Role: updated.Role,
+ Avatar: updated.Avatar,
+ })
+ if err != nil || !ok {
+ return
+ }
+ if h.imBus != nil {
+ userCopy := user
+ h.imBus.Publish(im.Event{
+ Type: im.EventTypeUserUpdated,
+ User: &userCopy,
+ })
+ }
+}
+
func (h *Handler) handleAgentProfileByID(w http.ResponseWriter, r *http.Request) {
id := pathValue(r, "id")
if id == "" {
@@ -891,6 +943,7 @@ func agentCreateRequestFromAPI(req apitypes.CreateAgentRequest) agent.CreateRequ
Name: req.Name,
Description: req.Description,
Image: req.Image,
+ Avatar: req.Avatar,
RuntimeKind: req.RuntimeKind,
FromTemplate: req.FromTemplate,
Role: req.Role,
@@ -1716,6 +1769,7 @@ func presentAgent(item agent.Agent) agentResponse {
RuntimeID: item.RuntimeID,
RuntimeKind: item.RuntimeKind,
Image: item.Image,
+ Avatar: item.Avatar,
BoxID: item.BoxID,
Role: item.Role,
Status: item.Status,
diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go
index fd9b8f0e..b680f245 100644
--- a/internal/api/handler_test.go
+++ b/internal/api/handler_test.go
@@ -536,7 +536,7 @@ func TestHandleBotsCreateCSGClawWorker(t *testing.T) {
imBus: bus,
}
- req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","description":"test lead","image":"agent-image:1","role":"worker","runtime_kind":"picoclaw_sandbox","agent_profile":{"provider":"csghub_lite","model_id":"glm-4.5","reasoning_effort":"high"}}`))
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","description":"test lead","image":"agent-image:1","avatar":"avatar/cartoon-3.png","role":"worker","runtime_kind":"picoclaw_sandbox","agent_profile":{"provider":"csghub_lite","model_id":"glm-4.5","reasoning_effort":"high"}}`))
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
@@ -554,6 +554,9 @@ func TestHandleBotsCreateCSGClawWorker(t *testing.T) {
if created.Description != "test lead" {
t.Fatalf("created bot description = %q, want test lead", created.Description)
}
+ if created.Avatar != "avatar/cartoon-3.png" {
+ t.Fatalf("created bot avatar = %q, want avatar/cartoon-3.png", created.Avatar)
+ }
rec = httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil))
@@ -570,6 +573,9 @@ func TestHandleBotsCreateCSGClawWorker(t *testing.T) {
if bots[0].Description != "test lead" {
t.Fatalf("bots[0].Description = %q, want test lead", bots[0].Description)
}
+ if bots[0].Avatar != "avatar/cartoon-3.png" {
+ t.Fatalf("bots[0].Avatar = %q, want avatar/cartoon-3.png", bots[0].Avatar)
+ }
rec = httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil))
@@ -586,6 +592,9 @@ func TestHandleBotsCreateCSGClawWorker(t *testing.T) {
if agents[0]["image"] != "agent-image:1" {
t.Fatalf("agents[0].image = %#v, want agent-image:1", agents[0]["image"])
}
+ if agents[0]["avatar"] != "avatar/cartoon-3.png" {
+ t.Fatalf("agents[0].avatar = %#v, want avatar/cartoon-3.png", agents[0]["avatar"])
+ }
profile, ok := agents[0]["agent_profile"].(map[string]any)
if !ok || profile["provider"] != agent.ProviderCSGHubLite || profile["model_id"] != "glm-4.5" {
t.Fatalf("agent_profile = %#v, want csghub_lite/glm-4.5", agents[0]["agent_profile"])
@@ -603,6 +612,11 @@ func TestHandleBotsCreateCSGClawWorker(t *testing.T) {
if !containsUser(users, "u-alice") {
t.Fatalf("users = %+v, want u-alice", users)
}
+ for _, user := range users {
+ if user.ID == "u-alice" && user.Avatar != "avatar/cartoon-3.png" {
+ t.Fatalf("user avatar = %q, want avatar/cartoon-3.png", user.Avatar)
+ }
+ }
rooms := imSvc.ListRooms()
if len(rooms) != 1 || !containsMember(rooms[0].Members, "u-admin") || !containsMember(rooms[0].Members, "u-alice") {
t.Fatalf("rooms = %+v, want bootstrap room with admin and u-alice", rooms)
@@ -1138,9 +1152,22 @@ func TestHandleAgentsPatchUpdatesMetadataAndProfile(t *testing.T) {
CreatedAt: time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC),
},
})
+ imSvc := im.NewService()
+ if _, _, err := imSvc.EnsureAgentUser(im.EnsureAgentUserRequest{
+ ID: "u-alice",
+ Name: "alice",
+ Handle: "alice",
+ Role: agent.RoleWorker,
+ Avatar: "avatar/3D-1.png",
+ }); err != nil {
+ t.Fatalf("EnsureAgentUser() error = %v", err)
+ }
+ bus := im.NewBus()
+ events, cancel := bus.Subscribe()
+ defer cancel()
- srv := &Handler{svc: svc}
- body := `{"description":"new role","agent_profile":{"name":"alice","provider":"csghub_lite","model_id":"new-model","env":{"A":"B"}}}`
+ srv := &Handler{svc: svc, im: imSvc, imBus: bus}
+ body := `{"description":"new role","avatar":"avatar/cartoon-4.png","agent_profile":{"name":"alice","provider":"csghub_lite","model_id":"new-model","env":{"A":"B"}}}`
req := httptest.NewRequest(http.MethodPatch, "/api/v1/agents/u-alice", strings.NewReader(body))
rec := httptest.NewRecorder()
@@ -1156,10 +1183,24 @@ func TestHandleAgentsPatchUpdatesMetadataAndProfile(t *testing.T) {
if got["description"] != "new role" {
t.Fatalf("agent = %#v, want updated description", got)
}
+ if got["avatar"] != "avatar/cartoon-4.png" {
+ t.Fatalf("agent avatar = %#v, want updated avatar", got["avatar"])
+ }
profile, ok := got["agent_profile"].(map[string]any)
if !ok || profile["env_restart_required"] != true || profile["model_id"] != "new-model" {
t.Fatalf("agent_profile = %#v, want env_restart_required true", got["agent_profile"])
}
+ user, ok := imSvc.User("u-alice")
+ if !ok {
+ t.Fatal("User(u-alice) ok = false, want true")
+ }
+ if user.Avatar != "avatar/cartoon-4.png" {
+ t.Fatalf("user avatar = %q, want avatar/cartoon-4.png", user.Avatar)
+ }
+ evt := mustReceiveIMEvent(t, events)
+ if evt.Type != im.EventTypeUserUpdated || evt.User == nil || evt.User.Avatar != "avatar/cartoon-4.png" {
+ t.Fatalf("event = %+v, want user.updated with updated avatar", evt)
+ }
}
func TestHandleAgentsGetByIDReloadsStateBeforeLookup(t *testing.T) {
@@ -1775,6 +1816,7 @@ func TestAgentCreateRequestFromAPIIncludesFromTemplate(t *testing.T) {
Name: "alice",
RuntimeKind: agent.RuntimeKindCodex,
FromTemplate: "builtin/frontend-alice",
+ Avatar: "avatar/3D-1.png",
Profile: "codex-fast",
})
@@ -1787,6 +1829,9 @@ func TestAgentCreateRequestFromAPIIncludesFromTemplate(t *testing.T) {
if got.Spec.FromTemplate != "builtin/frontend-alice" {
t.Fatalf("Spec.FromTemplate = %q, want %q", got.Spec.FromTemplate, "builtin/frontend-alice")
}
+ if got.Spec.Avatar != "avatar/3D-1.png" {
+ t.Fatalf("Spec.Avatar = %q, want %q", got.Spec.Avatar, "avatar/3D-1.png")
+ }
if got.Spec.Profile != "codex-fast" {
t.Fatalf("Spec.Profile = %q, want %q", got.Spec.Profile, "codex-fast")
}
diff --git a/internal/api/notification_bots_test.go b/internal/api/notification_bots_test.go
index 19cbb73e..3fa928f1 100644
--- a/internal/api/notification_bots_test.go
+++ b/internal/api/notification_bots_test.go
@@ -15,6 +15,7 @@ import (
func TestNotificationBotsCRUDAndListBotsFilter(t *testing.T) {
imSvc := im.NewService()
+ bus := im.NewBus()
botStore, err := bot.NewMemoryStore(nil)
if err != nil {
t.Fatalf("NewMemoryStore() error = %v", err)
@@ -25,7 +26,7 @@ func TestNotificationBotsCRUDAndListBotsFilter(t *testing.T) {
}
botSvc.SetDependencies(nil, imSvc)
- srv := &Handler{botSvc: botSvc, im: imSvc}
+ srv := &Handler{botSvc: botSvc, im: imSvc, imBus: bus}
router := srv.Routes()
createBody, _ := json.Marshal(apitypes.CreateBotRequest{
@@ -76,6 +77,28 @@ func TestNotificationBotsCRUDAndListBotsFilter(t *testing.T) {
t.Fatalf("GET /bots = %+v, want notification bot %q", listed, created.ID)
}
+ events, cancel := bus.Subscribe()
+ defer cancel()
+ patchBody, _ := json.Marshal(apitypes.PatchNotificationBotRequest{
+ Avatar: "avatar/cartoon-4.png",
+ })
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodPatch, "/api/v1/channels/csgclaw/bots/"+created.ID, bytes.NewReader(patchBody)))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH notification-bots status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ user, ok := imSvc.User(created.UserID)
+ if !ok {
+ t.Fatalf("User(%q) ok = false, want true", created.UserID)
+ }
+ if user.Avatar != "avatar/cartoon-4.png" {
+ t.Fatalf("user avatar = %q, want avatar/cartoon-4.png", user.Avatar)
+ }
+ evt := mustReceiveIMEvent(t, events)
+ if evt.Type != im.EventTypeUserUpdated || evt.User == nil || evt.User.Avatar != "avatar/cartoon-4.png" {
+ t.Fatalf("event = %+v, want user.updated with updated avatar", evt)
+ }
+
push := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots/"+created.ID+"/notifications", bytes.NewReader([]byte(`{"hello":"world"}`)))
req.Header.Set("Authorization", "Bearer secret-token")
diff --git a/internal/apitypes/agent.go b/internal/apitypes/agent.go
index 785378e3..3bc5c269 100644
--- a/internal/apitypes/agent.go
+++ b/internal/apitypes/agent.go
@@ -9,6 +9,7 @@ type Agent struct {
RuntimeID string `json:"runtime_id,omitempty"`
RuntimeKind string `json:"runtime_kind,omitempty"`
Image string `json:"image,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
BoxID string `json:"box_id,omitempty"`
Role string `json:"role"`
Status string `json:"status"`
@@ -21,6 +22,7 @@ type CreateAgentRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
RuntimeKind string `json:"runtime_kind,omitempty"`
FromTemplate string `json:"from_template,omitempty"`
Replace bool `json:"replace,omitempty"`
diff --git a/internal/apitypes/types.go b/internal/apitypes/types.go
index 7bddb42b..d508d7b2 100644
--- a/internal/apitypes/types.go
+++ b/internal/apitypes/types.go
@@ -18,6 +18,7 @@ type Bot struct {
RuntimeOptions map[string]any `json:"runtime_options,omitempty"`
RuntimeKind string `json:"runtime_kind,omitempty"`
Image string `json:"image,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
Status string `json:"status,omitempty"`
Provider string `json:"provider,omitempty"`
ModelID string `json:"model_id,omitempty"`
@@ -32,6 +33,7 @@ type CreateBotRequest struct {
Description string `json:"description,omitempty"`
Type string `json:"type,omitempty"`
Image string `json:"image,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
Role string `json:"role"`
Channel string `json:"channel,omitempty"`
RuntimeKind string `json:"runtime_kind,omitempty"`
@@ -43,6 +45,7 @@ type CreateBotRequest struct {
type PatchNotificationBotRequest struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
RuntimeOptions map[string]any `json:"runtime_options,omitempty"`
}
diff --git a/internal/bot/model.go b/internal/bot/model.go
index 8896e9fb..9d53e6f2 100644
--- a/internal/bot/model.go
+++ b/internal/bot/model.go
@@ -84,6 +84,7 @@ func NormalizeCreateRequest(req CreateRequest) (CreateRequest, error) {
req.Name = strings.TrimSpace(req.Name)
req.Description = strings.TrimSpace(req.Description)
req.Image = strings.TrimSpace(req.Image)
+ req.Avatar = strings.TrimSpace(req.Avatar)
req.RuntimeKind = strings.TrimSpace(req.RuntimeKind)
req.FromTemplate = strings.TrimSpace(req.FromTemplate)
req.Type = NormalizeBotType(req.Type)
@@ -113,6 +114,7 @@ func NormalizeBot(b Bot) (Bot, error) {
b.ID = strings.TrimSpace(b.ID)
b.Name = strings.TrimSpace(b.Name)
b.Description = strings.TrimSpace(b.Description)
+ b.Avatar = strings.TrimSpace(b.Avatar)
b.AgentID = strings.TrimSpace(b.AgentID)
b.UserID = strings.TrimSpace(b.UserID)
b.Type = NormalizeBotType(b.Type)
diff --git a/internal/bot/notification.go b/internal/bot/notification.go
index 8487f367..554a1ab0 100644
--- a/internal/bot/notification.go
+++ b/internal/bot/notification.go
@@ -78,6 +78,7 @@ func (s *Service) CreateNotificationBot(ctx context.Context, req CreateRequest)
ID: botID,
Name: normalized.Name,
Description: normalized.Description,
+ Avatar: normalized.Avatar,
Role: "Worker",
})
if err != nil {
@@ -93,6 +94,7 @@ func (s *Service) CreateNotificationBot(ctx context.Context, req CreateRequest)
ID: botID,
Name: normalized.Name,
Description: normalized.Description,
+ Avatar: normalized.Avatar,
Type: BotTypeNotification,
Role: string(RoleWorker),
Channel: normalized.Channel,
@@ -123,6 +125,9 @@ func (s *Service) PatchNotificationBot(ctx context.Context, channel, id string,
if desc := strings.TrimSpace(patch.Description); desc != "" {
existing.Description = desc
}
+ if avatar := strings.TrimSpace(patch.Avatar); avatar != "" {
+ existing.Avatar = avatar
+ }
if len(patch.RuntimeOptions) > 0 {
merged := notification_bot.MergeRuntimeOptionsPatch(
notification_bot.FlatFromRuntimeOptionsMap(existing.RuntimeOptions),
@@ -200,6 +205,7 @@ type channelBotIdentity struct {
Description string
Handle string
Role string
+ Avatar string
}
func (s *Service) ensureChannelUserForBot(ctx context.Context, channelName string, identity channelBotIdentity) (string, time.Time, error) {
@@ -224,6 +230,7 @@ func (s *Service) ensureChannelUserForBot(ctx context.Context, channelName strin
Description: identity.Description,
Handle: handle,
Role: identity.Role,
+ Avatar: identity.Avatar,
})
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to ensure im user: %w", err)
diff --git a/internal/bot/service.go b/internal/bot/service.go
index 16951fd8..70cb137e 100644
--- a/internal/bot/service.go
+++ b/internal/bot/service.go
@@ -125,6 +125,7 @@ func (s *Service) refreshBotAvailability(bots []Bot) []Bot {
b.Available = strings.EqualFold(strings.TrimSpace(a.Status), "running")
b.RuntimeKind = strings.TrimSpace(a.RuntimeKind)
b.Image = strings.TrimSpace(a.Image)
+ b.Avatar = strings.TrimSpace(a.Avatar)
b.Status = strings.TrimSpace(a.Status)
b.Provider = strings.TrimSpace(a.AgentProfile.Provider)
b.ModelID = strings.TrimSpace(a.AgentProfile.ModelID)
@@ -482,6 +483,7 @@ func (s *Service) createWorker(ctx context.Context, normalized CreateRequest) (B
Name: normalized.Name,
Description: normalized.Description,
Image: normalized.Image,
+ Avatar: normalized.Avatar,
Role: agent.RoleWorker,
RuntimeKind: normalized.RuntimeKind,
FromTemplate: normalized.FromTemplate,
@@ -512,6 +514,7 @@ func (s *Service) createWorker(ctx context.Context, normalized CreateRequest) (B
ID: created.ID,
Name: created.Name,
Description: normalized.Description,
+ Avatar: strings.TrimSpace(created.Avatar),
Role: normalized.Role,
Channel: normalized.Channel,
AgentID: created.ID,
@@ -637,6 +640,7 @@ func (s *Service) ensureChannelUser(ctx context.Context, channelName string, cre
Description: created.Description,
Handle: deriveAgentHandle(created),
Role: displayRole(created),
+ Avatar: created.Avatar,
})
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to ensure im user: %w", err)
@@ -651,6 +655,7 @@ func (s *Service) ensureChannelUser(ctx context.Context, channelName string, cre
Name: created.Name,
Handle: deriveAgentHandle(created),
Role: displayRole(created),
+ Avatar: created.Avatar,
})
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to ensure feishu user: %w", err)
diff --git a/internal/im/events.go b/internal/im/events.go
index daf3c3b3..05695ff0 100644
--- a/internal/im/events.go
+++ b/internal/im/events.go
@@ -15,6 +15,7 @@ const (
EventTypeConversationCreated = "conversation.created"
EventTypeConversationMembersAdded = "conversation.members_added"
EventTypeUserCreated = "user.created"
+ EventTypeUserUpdated = "user.updated"
EventTypeUserDeleted = "user.deleted"
EventTypeUpgradeStatusChanged = "upgrade.status_changed"
)
diff --git a/internal/im/provisioning.go b/internal/im/provisioning.go
index 9d895e5c..ecd08976 100644
--- a/internal/im/provisioning.go
+++ b/internal/im/provisioning.go
@@ -13,6 +13,7 @@ type AgentIdentity struct {
Description string
Handle string
Role string
+ Avatar string
}
type ProvisionResult struct {
@@ -44,6 +45,7 @@ func (p *Provisioner) EnsureAgentUser(_ context.Context, identity AgentIdentity)
Name: identity.Name,
Handle: identity.Handle,
Role: identity.Role,
+ Avatar: identity.Avatar,
})
if err != nil {
return ProvisionResult{}, err
diff --git a/internal/im/service.go b/internal/im/service.go
index 1801bfc8..020d8996 100644
--- a/internal/im/service.go
+++ b/internal/im/service.go
@@ -94,10 +94,18 @@ type EnsureAgentUserRequest struct {
Name string `json:"name"`
Handle string `json:"handle"`
Role string `json:"role"`
+ Avatar string `json:"avatar,omitempty"`
}
type EnsureWorkerUserRequest = EnsureAgentUserRequest
+type UpdateAgentUserRequest struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ Role string `json:"role,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
+}
+
type AddAgentToConversationRequest struct {
AgentID string `json:"agent_id"`
RoomID string `json:"room_id,omitempty"`
@@ -943,6 +951,7 @@ func (s *Service) EnsureAgentUser(req EnsureAgentUserRequest) (User, *Room, erro
name := strings.ToLower(strings.TrimSpace(req.Name))
handle := strings.ToLower(strings.TrimSpace(req.Handle))
role := strings.ToLower(strings.TrimSpace(req.Role))
+ avatar := strings.TrimSpace(req.Avatar)
switch {
case id == "":
return User{}, nil, fmt.Errorf("id is required")
@@ -954,6 +963,9 @@ func (s *Service) EnsureAgentUser(req EnsureAgentUserRequest) (User, *Room, erro
if role == "" {
role = "worker"
}
+ if avatar == "" {
+ avatar = initials(name)
+ }
s.mu.Lock()
defer s.mu.Unlock()
@@ -974,7 +986,7 @@ func (s *Service) EnsureAgentUser(req EnsureAgentUserRequest) (User, *Room, erro
Name: name,
Handle: handle,
Role: role,
- Avatar: initials(name),
+ Avatar: avatar,
IsOnline: true,
AccentHex: accentHexForID(id),
CreatedAt: time.Now().UTC(),
@@ -997,6 +1009,62 @@ func (s *Service) EnsureWorkerUser(req EnsureWorkerUserRequest) (User, *Room, er
return s.EnsureAgentUser(req)
}
+func (s *Service) UpdateAgentUser(req UpdateAgentUserRequest) (User, bool, error) {
+ id := strings.TrimSpace(req.ID)
+ if id == "" {
+ return User{}, false, fmt.Errorf("id is required")
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ user, ok := s.users[id]
+ if !ok {
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ name = strings.TrimSpace(strings.TrimPrefix(id, "u-"))
+ if name == "" {
+ name = id
+ }
+ }
+ handle := strings.ToLower(strings.TrimSpace(name))
+ handle = strings.ReplaceAll(handle, " ", "-")
+ role := strings.ToLower(strings.TrimSpace(req.Role))
+ if role == "" {
+ role = "worker"
+ }
+ user = User{
+ ID: id,
+ Name: name,
+ Handle: handle,
+ Role: role,
+ Avatar: strings.TrimSpace(req.Avatar),
+ IsOnline: true,
+ AccentHex: accentHexForID(id),
+ CreatedAt: time.Now().UTC(),
+ }
+ ok = true
+ }
+ if name := strings.TrimSpace(req.Name); name != "" {
+ user.Name = name
+ }
+ if role := strings.TrimSpace(req.Role); role != "" {
+ user.Role = role
+ }
+ if avatar := strings.TrimSpace(req.Avatar); avatar != "" {
+ user.Avatar = avatar
+ }
+ user = normalizeUser(user)
+ if strings.TrimSpace(user.Avatar) == "" {
+ user.Avatar = initials(user.Name)
+ }
+ s.users[id] = user
+ if err := s.saveLocked(); err != nil {
+ return User{}, false, err
+ }
+ return user, true, nil
+}
+
func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) {
content := strings.TrimSpace(req.Content)
roomID := strings.TrimSpace(req.RoomID)
diff --git a/web/app/public/avatar/3D-1.png b/web/app/public/avatar/3D-1.png
new file mode 100644
index 00000000..a04c5fab
Binary files /dev/null and b/web/app/public/avatar/3D-1.png differ
diff --git a/web/app/public/avatar/3D-2.png b/web/app/public/avatar/3D-2.png
new file mode 100644
index 00000000..0fe35f13
Binary files /dev/null and b/web/app/public/avatar/3D-2.png differ
diff --git a/web/app/public/avatar/3D-3.png b/web/app/public/avatar/3D-3.png
new file mode 100644
index 00000000..ef1780b2
Binary files /dev/null and b/web/app/public/avatar/3D-3.png differ
diff --git a/web/app/public/avatar/3D-4.png b/web/app/public/avatar/3D-4.png
new file mode 100644
index 00000000..d05380cf
Binary files /dev/null and b/web/app/public/avatar/3D-4.png differ
diff --git a/web/app/public/avatar/3D-5.png b/web/app/public/avatar/3D-5.png
new file mode 100644
index 00000000..7678a7d3
Binary files /dev/null and b/web/app/public/avatar/3D-5.png differ
diff --git a/web/app/public/avatar/3D-6.png b/web/app/public/avatar/3D-6.png
new file mode 100644
index 00000000..a4b94be3
Binary files /dev/null and b/web/app/public/avatar/3D-6.png differ
diff --git a/web/app/public/avatar/3D-7.png b/web/app/public/avatar/3D-7.png
new file mode 100644
index 00000000..dffa064a
Binary files /dev/null and b/web/app/public/avatar/3D-7.png differ
diff --git a/web/app/public/avatar/3D-8.png b/web/app/public/avatar/3D-8.png
new file mode 100644
index 00000000..62424636
Binary files /dev/null and b/web/app/public/avatar/3D-8.png differ
diff --git a/web/app/public/avatar/cartoon-1.png b/web/app/public/avatar/cartoon-1.png
new file mode 100644
index 00000000..920ec997
Binary files /dev/null and b/web/app/public/avatar/cartoon-1.png differ
diff --git a/web/app/public/avatar/cartoon-2.png b/web/app/public/avatar/cartoon-2.png
new file mode 100644
index 00000000..4a2eee74
Binary files /dev/null and b/web/app/public/avatar/cartoon-2.png differ
diff --git a/web/app/public/avatar/cartoon-3.png b/web/app/public/avatar/cartoon-3.png
new file mode 100644
index 00000000..2baf30f4
Binary files /dev/null and b/web/app/public/avatar/cartoon-3.png differ
diff --git a/web/app/public/avatar/cartoon-4.png b/web/app/public/avatar/cartoon-4.png
new file mode 100644
index 00000000..ab8f1941
Binary files /dev/null and b/web/app/public/avatar/cartoon-4.png differ
diff --git a/web/app/public/avatar/cartoon-5.png b/web/app/public/avatar/cartoon-5.png
new file mode 100644
index 00000000..1b7efa8d
Binary files /dev/null and b/web/app/public/avatar/cartoon-5.png differ
diff --git a/web/app/public/avatar/cartoon-6.png b/web/app/public/avatar/cartoon-6.png
new file mode 100644
index 00000000..01f2da68
Binary files /dev/null and b/web/app/public/avatar/cartoon-6.png differ
diff --git a/web/app/public/avatar/cartoon-7.png b/web/app/public/avatar/cartoon-7.png
new file mode 100644
index 00000000..472b0e80
Binary files /dev/null and b/web/app/public/avatar/cartoon-7.png differ
diff --git a/web/app/public/avatar/cartoon-8.png b/web/app/public/avatar/cartoon-8.png
new file mode 100644
index 00000000..5c18466e
Binary files /dev/null and b/web/app/public/avatar/cartoon-8.png differ
diff --git a/web/app/public/avatar/pic-1.png b/web/app/public/avatar/pic-1.png
new file mode 100644
index 00000000..4737ef95
Binary files /dev/null and b/web/app/public/avatar/pic-1.png differ
diff --git a/web/app/public/avatar/pic-2.png b/web/app/public/avatar/pic-2.png
new file mode 100644
index 00000000..0af8f9cb
Binary files /dev/null and b/web/app/public/avatar/pic-2.png differ
diff --git a/web/app/public/avatar/pic-3.png b/web/app/public/avatar/pic-3.png
new file mode 100644
index 00000000..1acb0818
Binary files /dev/null and b/web/app/public/avatar/pic-3.png differ
diff --git a/web/app/public/avatar/pic-4.png b/web/app/public/avatar/pic-4.png
new file mode 100644
index 00000000..4989b3ff
Binary files /dev/null and b/web/app/public/avatar/pic-4.png differ
diff --git a/web/app/public/avatar/pic-5.png b/web/app/public/avatar/pic-5.png
new file mode 100644
index 00000000..b2aceb4c
Binary files /dev/null and b/web/app/public/avatar/pic-5.png differ
diff --git a/web/app/public/avatar/pic-6.png b/web/app/public/avatar/pic-6.png
new file mode 100644
index 00000000..d26bbbc3
Binary files /dev/null and b/web/app/public/avatar/pic-6.png differ
diff --git a/web/app/public/avatar/pic-7.png b/web/app/public/avatar/pic-7.png
new file mode 100644
index 00000000..dd30cc16
Binary files /dev/null and b/web/app/public/avatar/pic-7.png differ
diff --git a/web/app/public/avatar/pic-8.png b/web/app/public/avatar/pic-8.png
new file mode 100644
index 00000000..3dde6b5b
Binary files /dev/null and b/web/app/public/avatar/pic-8.png differ
diff --git a/web/app/src/api/agents.ts b/web/app/src/api/agents.ts
index 53fb128a..85b58158 100644
--- a/web/app/src/api/agents.ts
+++ b/web/app/src/api/agents.ts
@@ -25,6 +25,7 @@ export type FetchAgentLogsOptions = {
export type AgentUpdatePayload = {
agent_profile?: JSONRecord;
+ avatar?: string;
description?: string;
image?: string;
name?: string;
diff --git a/web/app/src/components/business/AgentAvatar/AgentAvatar.tsx b/web/app/src/components/business/AgentAvatar/AgentAvatar.tsx
new file mode 100644
index 00000000..83a7356a
--- /dev/null
+++ b/web/app/src/components/business/AgentAvatar/AgentAvatar.tsx
@@ -0,0 +1,93 @@
+import { AgentIcon } from "@/components/ui/Icons";
+import { normalizeAvatarPath } from "@/shared/avatar";
+import type { ReactNode } from "react";
+
+const AVATAR_GROUPS = [
+ { key: "3D", labelKey: "agentAvatarStyle3D" },
+ { key: "cartoon", labelKey: "agentAvatarStyleCartoon" },
+ { key: "pic", labelKey: "agentAvatarStylePic" },
+] as const;
+
+export const AGENT_AVATAR_OPTIONS = AVATAR_GROUPS.flatMap((group) =>
+ Array.from({ length: 8 }, (_, index) => ({
+ group: group.key,
+ labelKey: group.labelKey,
+ index: index + 1,
+ value: `avatar/${group.key}-${index + 1}.png`,
+ })),
+);
+
+type TranslateFn = (key: string) => string;
+
+export function defaultAgentAvatar(): string {
+ return AGENT_AVATAR_OPTIONS[0]?.value || "";
+}
+
+export function normalizeAgentAvatarPath(value: unknown): string {
+ return normalizeAvatarPath(value);
+}
+
+export function AgentAvatarImage({ avatar, alt = "" }: { avatar?: string | null; alt?: string }) {
+ const src = normalizeAgentAvatarPath(avatar);
+ if (!src) {
+ return ;
+}
+
+export function AgentAvatarContent({
+ avatar,
+ fallback,
+ alt = "",
+}: {
+ avatar?: string | null;
+ fallback?: ReactNode;
+ alt?: string;
+}) {
+ const src = normalizeAgentAvatarPath(avatar);
+ if (!src) {
+ return {fallback ?? avatar};
+ }
+ return
;
+}
+
+export function AgentAvatarPicker({
+ value,
+ t,
+ onChange,
+}: {
+ value?: string | null;
+ t: TranslateFn;
+ onChange: (value: string) => void;
+}) {
+ const selected = normalizeAgentAvatarPath(value) || defaultAgentAvatar();
+ return (
+