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 ; + } + return {alt}; +} + +export function AgentAvatarContent({ + avatar, + fallback, + alt = "", +}: { + avatar?: string | null; + fallback?: ReactNode; + alt?: string; +}) { + const src = normalizeAgentAvatarPath(avatar); + if (!src) { + return {fallback ?? avatar}; + } + return {alt}; +} + +export function AgentAvatarPicker({ + value, + t, + onChange, +}: { + value?: string | null; + t: TranslateFn; + onChange: (value: string) => void; +}) { + const selected = normalizeAgentAvatarPath(value) || defaultAgentAvatar(); + return ( +
+ {AVATAR_GROUPS.map((group) => ( +
+
{t(group.labelKey)}
+
+ {AGENT_AVATAR_OPTIONS.filter((option) => option.group === group.key).map((option) => { + const checked = option.value === selected; + const label = `${t(option.labelKey)} ${option.index}`; + return ( + + ); + })} +
+
+ ))} +
+ ); +} diff --git a/web/app/src/components/business/AgentAvatar/index.ts b/web/app/src/components/business/AgentAvatar/index.ts new file mode 100644 index 00000000..209d5a90 --- /dev/null +++ b/web/app/src/components/business/AgentAvatar/index.ts @@ -0,0 +1 @@ +export * from "./AgentAvatar"; diff --git a/web/app/src/components/business/RoomAvatar/RoomAvatar.css b/web/app/src/components/business/RoomAvatar/RoomAvatar.css new file mode 100644 index 00000000..73abcbde --- /dev/null +++ b/web/app/src/components/business/RoomAvatar/RoomAvatar.css @@ -0,0 +1,124 @@ +.room-avatar { + position: relative; + display: block; + overflow: clip; + border: 1px solid var(--portal-white); + background: var(--portal-white); + flex: 0 0 auto; + isolation: isolate; +} + +.room-avatar--compact { + box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.56); +} + +.room-avatar--large { + box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.42); +} + +.room-avatar-tile { + position: absolute; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + border-color: var(--portal-white); + color: var(--avatar-text); +} + +.room-avatar-tile--full { + inset: 0; +} + +.room-avatar-tile--half { + top: 0; + bottom: 0; + width: 50%; +} + +.room-avatar-tile--left { + left: 0; + border-right: 1px solid var(--portal-white); +} + +.room-avatar-tile--right { + right: 0; + border-left: 1px solid var(--portal-white); +} + +.room-avatar-tile--quarter { + width: 50%; + height: 50%; +} + +.room-avatar-tile--top-left { + top: 0; + left: 0; + border-right: 1px solid var(--portal-white); + border-bottom: 1px solid var(--portal-white); +} + +.room-avatar-tile--top-right { + top: 0; + right: 0; + border-left: 1px solid var(--portal-white); + border-bottom: 1px solid var(--portal-white); +} + +.room-avatar-tile--bottom-left { + left: 0; + bottom: 0; + border-top: 1px solid var(--portal-white); + border-right: 1px solid var(--portal-white); +} + +.room-avatar-tile--bottom-right { + right: 0; + bottom: 0; + border-top: 1px solid var(--portal-white); + border-left: 1px solid var(--portal-white); +} + +.room-avatar-tile--tall { + right: 0; + top: 0; + bottom: 0; + width: 50%; + border-left: 1px solid var(--portal-white); +} + +.room-avatar-content { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font: inherit; + line-height: 1; + font-weight: 700; +} + +.room-avatar-content .agent-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.room-avatar-count-badge { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 16px; + height: 16px; + border-radius: 8px; + background: var(--portal-white); + color: var(--portal-gray-700); + font-family: var(--font-sans); + font-size: 10px; + font-weight: 500; + line-height: 16px; + text-align: center; + z-index: 1; + pointer-events: none; +} diff --git a/web/app/src/components/business/RoomAvatar/RoomAvatar.tsx b/web/app/src/components/business/RoomAvatar/RoomAvatar.tsx new file mode 100644 index 00000000..b67fcba8 --- /dev/null +++ b/web/app/src/components/business/RoomAvatar/RoomAvatar.tsx @@ -0,0 +1,120 @@ +import { AgentAvatarContent } from "@/components/business/AgentAvatar"; +import type { IMConversation, IMUser, UsersById } from "@/models/conversations"; +import "./RoomAvatar.css"; + +type RoomAvatarMember = Pick; +type RoomAvatarSlot = RoomAvatarMember & { placeholder?: boolean }; + +type RoomAvatarProps = { + ariaLabel?: string; + count?: number; + members: RoomAvatarMember[]; + size?: 32 | 48; + showCountBadge?: boolean; +}; + +const TILE_COLORS = ["#c4a4f3", "#fd94a1", "#99c6f8", "#fecd79", "#b68de3"] as const; + +function normalizeMemberFallback(member: RoomAvatarSlot): string { + if (member.placeholder) { + return "#"; + } + const text = (member.name || member.handle || member.id || "").trim(); + if (!text) { + return "#"; + } + return text.slice(0, 2).toUpperCase(); +} + +function pickTileColor(member: RoomAvatarMember, index: number): string { + return member.accent_hex || TILE_COLORS[index % TILE_COLORS.length]; +} + +function buildAvatarTiles(memberCount: number): Array<{ sizeClass: string; variant: string }> { + if (memberCount <= 1) { + return [{ sizeClass: "room-avatar-tile--full", variant: "single" }]; + } + if (memberCount === 2) { + return [ + { sizeClass: "room-avatar-tile--half room-avatar-tile--left", variant: "top-left" }, + { sizeClass: "room-avatar-tile--half room-avatar-tile--right", variant: "top-right" }, + ]; + } + if (memberCount === 3) { + return [ + { sizeClass: "room-avatar-tile--quarter room-avatar-tile--top-left", variant: "top-left" }, + { sizeClass: "room-avatar-tile--quarter room-avatar-tile--bottom-left", variant: "bottom-left" }, + { sizeClass: "room-avatar-tile--tall room-avatar-tile--right", variant: "right" }, + ]; + } + return [ + { sizeClass: "room-avatar-tile--quarter room-avatar-tile--top-left", variant: "top-left" }, + { sizeClass: "room-avatar-tile--quarter room-avatar-tile--top-right", variant: "top-right" }, + { sizeClass: "room-avatar-tile--quarter room-avatar-tile--bottom-left", variant: "bottom-left" }, + { sizeClass: "room-avatar-tile--quarter room-avatar-tile--bottom-right", variant: "bottom-right" }, + ]; +} + +export function resolveRoomAvatarMembers( + conversation: IMConversation | null | undefined, + usersById: UsersById, + currentUserID?: string | null, +): RoomAvatarMember[] { + return (conversation?.members || []) + .filter((memberID) => memberID !== currentUserID) + .map((memberID) => usersById.get(memberID)) + .filter((member): member is IMUser => Boolean(member)) + .map((member) => ({ + id: member.id, + name: member.name || member.handle || member.id, + handle: member.handle, + avatar: member.avatar, + accent_hex: member.accent_hex, + })); +} + +export function RoomAvatar({ ariaLabel, count, members, size = 32, showCountBadge = true }: RoomAvatarProps) { + const totalCount = count ?? members.length; + const visibleMembers = members.slice(0, 4); + const variantCount = Math.min(4, Math.max(1, visibleMembers.length)); + const tiles = buildAvatarTiles(variantCount); + const shouldShowBadge = showCountBadge && totalCount > 4; + const isCompact = size <= 32; + + return ( + + {tiles.map((tile, index) => { + const member = visibleMembers[index]; + if (!member) { + return null; + } + return ( + + ); + })} + {shouldShowBadge ? ( + + ) : null} + + ); +} diff --git a/web/app/src/components/business/RoomAvatar/index.ts b/web/app/src/components/business/RoomAvatar/index.ts new file mode 100644 index 00000000..e5ce9e82 --- /dev/null +++ b/web/app/src/components/business/RoomAvatar/index.ts @@ -0,0 +1 @@ +export * from "./RoomAvatar"; diff --git a/web/app/src/components/business/index.ts b/web/app/src/components/business/index.ts index e080e93f..60f4e9c9 100644 --- a/web/app/src/components/business/index.ts +++ b/web/app/src/components/business/index.ts @@ -1,2 +1,4 @@ +export * from "./AgentAvatar"; export * from "./MessageContent"; +export * from "./RoomAvatar"; export * from "./ProfileControls"; diff --git a/web/app/src/hooks/workspace/useAgentController.ts b/web/app/src/hooks/workspace/useAgentController.ts index 2d05914a..efb1a0b3 100644 --- a/web/app/src/hooks/workspace/useAgentController.ts +++ b/web/app/src/hooks/workspace/useAgentController.ts @@ -47,6 +47,7 @@ import { isNotifierRuntimeDraftOnAgentPage, normalizeAuthProviderName, partitionWorkspaceAgentItems, + resolveAgentAvatarSource, normalizeRuntimeKind, normalizeTemplateSelection, pickDefaultAgentTemplate, @@ -69,6 +70,7 @@ import { WorkspacePaneTypes } from "@/models/routing"; import { useCLIProxyAuthStatuses } from "./useCLIProxyAuthStatuses"; import { useProfileModelOptions } from "./useProfileModelOptions"; import type { MessageAction, MessageActionError, MessageLike } from "@/components/business/MessageContent/types"; +import { defaultAgentAvatar } from "@/components/business/AgentAvatar"; import type { IMConversation } from "@/models/conversations"; import type { UseAgentControllerArgs } from "./types"; @@ -129,13 +131,26 @@ export function useAgentController({ const [messageActionBusy] = useState(""); const [messageActionError, setMessageActionError] = useState({ key: "", message: "" }); const [agentPageDraft, setAgentPageDraft] = useState(null); + const [agentPageOriginalDraft, setAgentPageOriginalDraft] = useState(null); const [agentPageBusy, setAgentPageBusy] = useState(false); const [agentPagePublishBusy, setAgentPagePublishBusy] = useState(false); const [agentPageError, setAgentPageError] = useState(""); const managerProfileIncomplete = managerProfile && managerProfile.profile_complete === false; - const managerAgent = agents.find((item) => item.role === MANAGER_AGENT_ROLE || item.id === MANAGER_AGENT_ID); - const { workerAgentItems, notificationAgentItems } = partitionWorkspaceAgentItems(agents, MANAGER_AGENT_ID); - const agentItems = [...workerAgentItems, ...notificationAgentItems]; + const usersById = useMemo(() => { + const result = new Map(); + data?.users.forEach((user) => result.set(user.id, user)); + return result; + }, [data?.users]); + const agentItems = useMemo( + () => + agents.map((item) => ({ + ...item, + avatar: resolveAgentAvatarSource(item, usersById), + })), + [agents, usersById], + ); + const managerAgent = agentItems.find((item) => item.role === MANAGER_AGENT_ROLE || item.id === MANAGER_AGENT_ID); + const { workerAgentItems, notificationAgentItems } = partitionWorkspaceAgentItems(agentItems, MANAGER_AGENT_ID); const runningAgentCount = agentItems.filter(isAgentRunning).length; const notifierWebhookPublicOrigin = useMemo( () => resolvedNotifierWebhookOrigin(bootstrapConfig), @@ -145,8 +160,8 @@ export function useAgentController({ if (activePane.type !== WorkspacePaneTypes.agent) { return null; } - return agents.find((item) => item.id === activePane.id) ?? null; - }, [agents, activePane]); + return agentItems.find((item) => item.id === activePane.id) ?? null; + }, [agentItems, activePane]); const activeConversation = useMemo( () => data?.rooms.find((item) => item.id === activeConversationId) ?? null, [data, activeConversationId], @@ -255,6 +270,7 @@ export function useAgentController({ useEffect(() => { if (!selectedAgentForPage) { setAgentPageDraft(null); + setAgentPageOriginalDraft(null); setAgentPageError(""); setAgentPagePublishBusy(false); return; @@ -465,6 +481,7 @@ export function useAgentController({ agentToDraft({ name: "", description: "", + avatar: defaultAgentAvatar(), bot_type: BOT_TYPE_NOTIFICATION, }), ); @@ -492,6 +509,7 @@ export function useAgentController({ selectedTemplate?.runtime_kind || bootstrapConfig?.runtime_kind || managerAgent?.runtime_kind || "", ) || DEFAULT_RUNTIME_KIND; let draft = agentToDraft({ + avatar: defaultAgentAvatar(), image: runtimeImageForKind(runtimeKind, bootstrapConfig, managerAgent?.image || ""), runtime_kind: runtimeKind, bot_type: BOT_TYPE_NORMAL, @@ -506,6 +524,7 @@ export function useAgentController({ selectedTemplate?.runtime_kind || bootstrapConfig?.runtime_kind || managerAgent?.runtime_kind || "", ) || DEFAULT_RUNTIME_KIND; let draft = agentToDraft({ + avatar: defaultAgentAvatar(), image: runtimeImageForKind(runtimeKind, bootstrapConfig, managerAgent?.image || ""), runtime_kind: runtimeKind, bot_type: BOT_TYPE_NORMAL, @@ -542,11 +561,60 @@ export function useAgentController({ try { const draft = await agentDraftFromItem(item); setAgentPageDraft(draft); + setAgentPageOriginalDraft(draft); } catch (err) { setAgentPageError(err.message || t("agentActionFailed")); const draft = ensureNotifierPullSubscriptionDraft(agentToDraft(item)); setAgentPageDraft(draft); + setAgentPageOriginalDraft(draft); + } + } + + function normalizeDraftForCompare(draft: AgentDraft | null | undefined): AgentDraft | null { + if (!draft) { + return null; } + return ensureNotifierPullSubscriptionDraft(draft); + } + + function profilePayloadForCompare(draft: AgentDraft | null | undefined): string { + const normalized = normalizeDraftForCompare(draft); + if (!normalized) { + return ""; + } + return JSON.stringify( + draftToProfile(normalized, { + name: normalized.name, + description: normalized.description, + }), + ); + } + + function runtimeOptionsPayloadForCompare(draft: AgentDraft | null | undefined): string { + const normalized = normalizeDraftForCompare(draft); + if (!normalized) { + return ""; + } + const runtimeOptions = draftNotifierRuntimeOptionsForSave(normalized, { + mergeNotifier: false, + }); + return JSON.stringify(runtimeOptions || {}); + } + + function hasObjectValues(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0); + } + + function debugAgentPageSavePayload(mode: "meta-only" | "full", payload: AgentUpdatePayload): void { + if (!import.meta.env.DEV) { + return; + } + // Dev-only trace to verify whether avatar-only saves include profile/runtime payloads. + console.info("[agent-page-save]", { + agent_id: selectedAgentForPage?.id || "", + mode, + payload, + }); } async function saveAgentPage(): Promise { @@ -561,6 +629,7 @@ export function useAgentController({ const runtimeOptions = draftNotifierRuntimeOptionsForSave(draft, { mergeNotifier: true }); const payload: AgentUpdatePayload = { name: agentPageDraft.name, + avatar: agentPageDraft.avatar, description: agentPageDraft.description, }; if (runtimeOptions) { @@ -568,7 +637,10 @@ export function useAgentController({ } const saved = await patchNotificationBotRequest(selectedAgentForPage.id, payload); await refreshAgents(); - setAgentPageDraft(agentToDraft(saved)); + await refreshWorkspaceBootstrap(); + const nextDraft = agentToDraft(saved); + setAgentPageDraft(nextDraft); + setAgentPageOriginalDraft(nextDraft); return; } const profile = draftToProfile(draft, { @@ -578,20 +650,45 @@ export function useAgentController({ const runtimeOptions = draftNotifierRuntimeOptionsForSave(draft, { mergeNotifier: false, }); + const profileChanged = profilePayloadForCompare(agentPageDraft) !== profilePayloadForCompare(agentPageOriginalDraft); + const runtimeOptionsChanged = + runtimeOptionsPayloadForCompare(agentPageDraft) !== runtimeOptionsPayloadForCompare(agentPageOriginalDraft); + const hasProfileOrRuntimeChange = profileChanged || (runtimeOptionsChanged && hasObjectValues(runtimeOptions)); + const payload: AgentUpdatePayload = { name: agentPageDraft.name, + avatar: agentPageDraft.avatar, description: agentPageDraft.description, - agent_profile: profile, }; - if (runtimeOptions) { + if (profileChanged) { + payload.agent_profile = profile; + } + if (runtimeOptionsChanged && hasObjectValues(runtimeOptions)) { payload.runtime_options = runtimeOptions; } + if (!hasProfileOrRuntimeChange) { + debugAgentPageSavePayload("meta-only", payload); + const savedMetaOnly = await updateAgentRequest(selectedAgentForPage.id, payload); + await refreshAgents(); + await refreshWorkspaceBootstrap(); + if (savedMetaOnly.id === MANAGER_AGENT_ID) { + await refreshManagerProfile(); + } + const nextDraft = await agentDraftFromItem(savedMetaOnly); + setAgentPageDraft(nextDraft); + setAgentPageOriginalDraft(nextDraft); + return; + } + debugAgentPageSavePayload("full", payload); const saved = await updateAgentRequest(selectedAgentForPage.id, payload); await refreshAgents(); + await refreshWorkspaceBootstrap(); if (saved.id === MANAGER_AGENT_ID) { await refreshManagerProfile(); } - setAgentPageDraft(await agentDraftFromItem(saved)); + const nextDraft = await agentDraftFromItem(saved); + setAgentPageDraft(nextDraft); + setAgentPageOriginalDraft(nextDraft); } catch (err) { setAgentPageError(err.message || t("agentActionFailed")); } finally { @@ -639,6 +736,7 @@ export function useAgentController({ const runtimeOptions = draftNotifierRuntimeOptionsForSave(draft, { mergeNotifier: true }); const payload: AgentUpdatePayload = { name: agentDraft.name, + avatar: agentDraft.avatar, description: agentDraft.description, }; if (runtimeOptions) { @@ -648,6 +746,7 @@ export function useAgentController({ ? await createNotificationBotRequest(payload) : await patchNotificationBotRequest(editingAgent!.id, payload); await refreshAgents(); + await refreshWorkspaceBootstrap(); if (isCreate) { setAgentProgress((current) => current @@ -669,6 +768,7 @@ export function useAgentController({ }); const payload: AgentUpdatePayload = { name: agentDraft.name, + avatar: agentDraft.avatar, role: WORKER_AGENT_ROLE, description: agentDraft.description, image: agentDraft.image, @@ -683,14 +783,13 @@ export function useAgentController({ ? await createBotRequest(payload) : await updateAgentRequest(editingAgent!.id, { name: payload.name, + avatar: payload.avatar, description: payload.description, agent_profile: payload.agent_profile, ...(payload.runtime_options ? { runtime_options: payload.runtime_options } : {}), }); await refreshAgents(); - if (isCreate) { - await refreshWorkspaceBootstrap(); - } + await refreshWorkspaceBootstrap(); if (saved.id === MANAGER_AGENT_ID) { await refreshManagerProfile(); } diff --git a/web/app/src/models/agents.ts b/web/app/src/models/agents.ts index c5fede80..72d8cc2d 100644 --- a/web/app/src/models/agents.ts +++ b/web/app/src/models/agents.ts @@ -15,6 +15,7 @@ import { RUNTIME_KIND_OPTIONS, WORKER_AGENT_ROLE, } from "@/shared/constants/agents"; +import { avatarFallbackText } from "@/shared/avatar"; export type RuntimeKind = "picoclaw_sandbox" | "openclaw_sandbox" | "codex" | string; export type BotType = typeof BOT_TYPE_NORMAL | typeof BOT_TYPE_NOTIFICATION | string; @@ -67,6 +68,7 @@ export type AgentLike = AgentProfileLike & { id?: string | null; type?: BotType | null; available?: boolean | null; + avatar?: string | null; image?: string | null; name?: string | null; role?: string | null; @@ -75,11 +77,19 @@ export type AgentLike = AgentProfileLike & { template_name?: string | null; }; +export type AvatarLikeUser = { + avatar?: string | null; + handle?: string | null; + id: string; + name?: string | null; +}; + export type AgentDraft = { agent_id?: string; api_key: string; api_key_preview: string; api_key_set: boolean; + avatar?: string; base_url: string; default_image?: string; description?: string; @@ -214,7 +224,42 @@ export function normalizeBotType(value: unknown): BotType { } export function isNotificationBotAgent(item: AgentLike | null | undefined): boolean { - return normalizeBotType(item?.type) === BOT_TYPE_NOTIFICATION; + return normalizeBotType(item?.bot_type ?? item?.type) === BOT_TYPE_NOTIFICATION; +} + +export function resolveAgentAvatarSource( + agent: AgentLike | null | undefined, + usersById?: Map | null, +): string { + const agentAvatar = String(agent?.avatar ?? "").trim(); + const candidateUserIDs = [agent?.user_id, agent?.id] + .map((value) => String(value ?? "").trim()) + .filter(Boolean); + + for (const userID of candidateUserIDs) { + const userAvatar = String(usersById?.get(userID)?.avatar ?? "").trim(); + if (userAvatar) { + return userAvatar; + } + } + + return agentAvatar; +} + +export function resolveAgentAvatarFallback( + agent: AgentLike | null | undefined, + usersById?: Map | null, +): string { + const candidateUserIDs = [agent?.user_id, agent?.id] + .map((value) => String(value ?? "").trim()) + .filter(Boolean); + for (const userID of candidateUserIDs) { + const user = usersById?.get(userID); + if (user) { + return avatarFallbackText(user.avatar, user.name, user.handle, user.id); + } + } + return avatarFallbackText(agent?.avatar, agent?.name, agent?.handle, agent?.id); } export function partitionWorkspaceAgentItems( @@ -619,6 +664,7 @@ export function agentToDraft(agent: AgentDraftSource | null | undefined): AgentD role: agent?.role || WORKER_AGENT_ROLE, bot_type: botType, description: agent?.description || profile.description || "", + avatar: agent?.avatar || "", default_image: agent?.image || "", image: agent?.image || "", from_template: agent?.from_template || "", diff --git a/web/app/src/models/conversations.ts b/web/app/src/models/conversations.ts index c393a679..5a5e7280 100644 --- a/web/app/src/models/conversations.ts +++ b/web/app/src/models/conversations.ts @@ -373,7 +373,7 @@ export function applyIMEvent( return current; } - if (event.type === "user.created" && event.user) { + if ((event.type === "user.created" || event.type === "user.updated") && event.user) { return upsertUserInData(current, event.user); } if (event.type === "user.deleted" && event.user) { @@ -401,7 +401,7 @@ export function isAgentRosterEvent(event: IMServerEvent | null | undefined): boo if (!event?.type) { return false; } - if (event.type === "user.created" || event.type === "user.deleted") { + if (event.type === "user.created" || event.type === "user.updated" || event.type === "user.deleted") { return true; } if (event.type === "conversation.created" || event.type === "room.created") { diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css index d99cb1fb..a1da2847 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css @@ -62,7 +62,9 @@ .agent-detail-pane .entity-avatar { width: 48px; height: 48px; - border-radius: 12px; + border-radius: 50%; + background: transparent; + box-shadow: none; } .agent-detail-pane .entity-avatar svg, @@ -71,6 +73,13 @@ height: 24px; } +.agent-detail-pane .entity-avatar .agent-avatar-image { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; +} + .agent-detail-pane .entity-title-row h1 { font-size: 23px; line-height: 30px; @@ -185,6 +194,18 @@ min-height: 34px; } +.agent-detail-pane .agent-avatar-option { + width: 54px; + min-width: 54px; + height: 54px; + min-height: 54px; +} + +.agent-detail-pane .agent-avatar-option img { + width: 48px; + height: 48px; +} + :root[data-theme="dark"] .agent-detail-pane { background: linear-gradient(180deg, var(--panel) 0, var(--panel) 138px, transparent 138px), var(--bg); } diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx index 87dda217..7565a1c3 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx @@ -22,7 +22,8 @@ import { normalizeRuntimeKind, notifierFormIsComplete, } from "@/models/agents"; -import { AgentIcon } from "@/components/ui/Icons"; +import { AgentAvatarContent, AgentAvatarPicker } from "@/components/business/AgentAvatar"; +import { avatarFallbackText } from "@/shared/avatar"; import { Button, Select } from "@/components/ui"; export function AgentDetailPane({ @@ -65,7 +66,7 @@ export function AgentDetailPane({
- +
@@ -212,6 +213,10 @@ export function AgentDetailPane({ /> ) : null} +
+ {t("agentAvatar")} + updateDraft({ avatar })} /> +