فهرست منبع

Add custom room avatar support

LSamien 2 ماه پیش
والد
کامیت
7beb97ffc1

+ 3 - 1
hermes-plugins/yaoyao/dashboard/group_plugin_api.py

@@ -1071,6 +1071,8 @@ def _create_room_command(request: CreateRoomRequest) -> dict[str, object]:
         command.pop("orchestrationMode", None)
         command.pop("orchestrationMode", None)
     if "instructions" not in request.model_fields_set:
     if "instructions" not in request.model_fields_set:
         command.pop("instructions", None)
         command.pop("instructions", None)
+    if "avatar" not in request.model_fields_set:
+        command.pop("avatar", None)
     agents = command.get("agents")
     agents = command.get("agents")
     if isinstance(agents, list):
     if isinstance(agents, list):
         for seed, item in zip(request.agents, agents):
         for seed, item in zip(request.agents, agents):
@@ -1135,7 +1137,7 @@ async def capabilities() -> dict[str, object]:
         "limits": limits_payload(),
         "limits": limits_payload(),
         "eventTypes": sorted(EVENT_TYPES),
         "eventTypes": sorted(EVENT_TYPES),
         "features": [
         "features": [
-            "hostFlow", "roomInstructions", "nodeWorker", "nodeRegistry"
+            "hostFlow", "roomInstructions", "roomAvatar", "nodeWorker", "nodeRegistry"
         ],
         ],
     }
     }
 
 

+ 46 - 4
hermes-plugins/yaoyao/dashboard/group_protocol.py

@@ -2,6 +2,9 @@
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
+import base64
+import binascii
+import re
 import unicodedata
 import unicodedata
 from pathlib import Path
 from pathlib import Path
 from typing import Any, Literal
 from typing import Any, Literal
@@ -18,7 +21,7 @@ from pydantic import (
 )
 )
 
 
 
 
-PROTOCOL_VERSION = 11
+PROTOCOL_VERSION = 12
 MAX_AGENTS_PER_ROOM = 8
 MAX_AGENTS_PER_ROOM = 8
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
@@ -32,6 +35,7 @@ UNLIMITED_REPLY_ROUNDS = -1
 MAX_FINITE_REPLY_ROUNDS = 100
 MAX_FINITE_REPLY_ROUNDS = 100
 MAX_AGENT_DISPLAY_NAME_LENGTH = 100
 MAX_AGENT_DISPLAY_NAME_LENGTH = 100
 MAX_ROOM_INSTRUCTIONS_LENGTH = 4_000
 MAX_ROOM_INSTRUCTIONS_LENGTH = 4_000
+MAX_ROOM_AVATAR_LENGTH = 512 * 1024
 MAX_ROOM_CONCURRENCY = 3
 MAX_ROOM_CONCURRENCY = 3
 MAX_PLUGIN_CONCURRENCY = 4
 MAX_PLUGIN_CONCURRENCY = 4
 INITIAL_CONTEXT_MESSAGE_LIMIT = 50
 INITIAL_CONTEXT_MESSAGE_LIMIT = 50
@@ -40,6 +44,10 @@ REASONING_EFFORTS = frozenset({
     "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"
     "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"
 })
 })
 ORCHESTRATION_MODES = frozenset({"free", "host"})
 ORCHESTRATION_MODES = frozenset({"free", "host"})
+_ROOM_AVATAR_PATTERN = re.compile(
+    r"^data:image/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$",
+    re.IGNORECASE,
+)
 
 
 # These literals address the room as a whole and must never become a member
 # These literals address the room as a whole and must never become a member
 # display-name or alias target.  Keep the stable wire spelling for existing
 # display-name or alias target.  Keep the stable wire spelling for existing
@@ -84,11 +92,33 @@ def limits_payload() -> dict[str, int]:
         "unlimitedReplyRoundsValue": UNLIMITED_REPLY_ROUNDS,
         "unlimitedReplyRoundsValue": UNLIMITED_REPLY_ROUNDS,
         "maxAgentDisplayNameLength": MAX_AGENT_DISPLAY_NAME_LENGTH,
         "maxAgentDisplayNameLength": MAX_AGENT_DISPLAY_NAME_LENGTH,
         "maxRoomInstructionsLength": MAX_ROOM_INSTRUCTIONS_LENGTH,
         "maxRoomInstructionsLength": MAX_ROOM_INSTRUCTIONS_LENGTH,
+        "maxRoomAvatarLength": MAX_ROOM_AVATAR_LENGTH,
         "maxRoomConcurrency": MAX_ROOM_CONCURRENCY,
         "maxRoomConcurrency": MAX_ROOM_CONCURRENCY,
         "maxPluginConcurrency": MAX_PLUGIN_CONCURRENCY,
         "maxPluginConcurrency": MAX_PLUGIN_CONCURRENCY,
     }
     }
 
 
 
 
+def normalize_room_avatar(value: object) -> str:
+    """Validate a persisted custom avatar; an empty string selects auto mode."""
+    if not isinstance(value, str):
+        raise ValueError("avatar must be a data image URL or an empty string")
+    normalized = value.strip()
+    if not normalized:
+        return ""
+    if len(normalized) > MAX_ROOM_AVATAR_LENGTH:
+        raise ValueError("avatar is too large")
+    match = _ROOM_AVATAR_PATTERN.fullmatch(normalized)
+    if match is None:
+        raise ValueError("avatar must be a PNG, JPEG, or WebP data image URL")
+    try:
+        decoded = base64.b64decode(match.group(2), validate=True)
+    except (binascii.Error, ValueError) as error:
+        raise ValueError("avatar contains invalid base64 data") from error
+    if not decoded:
+        raise ValueError("avatar image is empty")
+    return normalized
+
+
 class GroupModel(BaseModel):
 class GroupModel(BaseModel):
     """Public request base with lower-camel aliases and forbidden extras."""
     """Public request base with lower-camel aliases and forbidden extras."""
 
 
@@ -150,6 +180,7 @@ class CreateRoomRequest(GroupModel):
     name: str = Field(min_length=1, max_length=100)
     name: str = Field(min_length=1, max_length=100)
     cwd: str = Field(default="", max_length=4096)
     cwd: str = Field(default="", max_length=4096)
     instructions: str = Field(default="", max_length=MAX_ROOM_INSTRUCTIONS_LENGTH)
     instructions: str = Field(default="", max_length=MAX_ROOM_INSTRUCTIONS_LENGTH)
+    avatar: str = Field(default="", max_length=MAX_ROOM_AVATAR_LENGTH)
     max_reply_rounds: StrictInt = Field(
     max_reply_rounds: StrictInt = Field(
         default=DEFAULT_MAX_REPLY_ROUNDS, alias="maxReplyRounds"
         default=DEFAULT_MAX_REPLY_ROUNDS, alias="maxReplyRounds"
     )
     )
@@ -168,6 +199,11 @@ class CreateRoomRequest(GroupModel):
     def normalize_instructions(cls, value: str) -> str:
     def normalize_instructions(cls, value: str) -> str:
         return normalize_room_instructions(value)
         return normalize_room_instructions(value)
 
 
+    @field_validator("avatar")
+    @classmethod
+    def normalize_avatar(cls, value: str) -> str:
+        return normalize_room_avatar(value)
+
     @model_validator(mode="after")
     @model_validator(mode="after")
     def validate_unique_host(self) -> "CreateRoomRequest":
     def validate_unique_host(self) -> "CreateRoomRequest":
         if sum(agent.is_host for agent in self.agents) > 1:
         if sum(agent.is_host for agent in self.agents) > 1:
@@ -182,13 +218,14 @@ class UpdateRoomRequest(GroupModel):
     instructions: str | None = Field(
     instructions: str | None = Field(
         default=None, max_length=MAX_ROOM_INSTRUCTIONS_LENGTH
         default=None, max_length=MAX_ROOM_INSTRUCTIONS_LENGTH
     )
     )
+    avatar: str | None = Field(default=None, max_length=MAX_ROOM_AVATAR_LENGTH)
     max_reply_rounds: StrictInt | None = Field(default=None, alias="maxReplyRounds")
     max_reply_rounds: StrictInt | None = Field(default=None, alias="maxReplyRounds")
     orchestration_mode: Literal["free", "host"] | None = Field(
     orchestration_mode: Literal["free", "host"] | None = Field(
         default=None, alias="orchestrationMode"
         default=None, alias="orchestrationMode"
     )
     )
 
 
     @field_validator(
     @field_validator(
-        "name", "cwd", "instructions", "max_reply_rounds", "orchestration_mode",
+        "name", "cwd", "instructions", "avatar", "max_reply_rounds", "orchestration_mode",
         mode="before"
         mode="before"
     )
     )
     @classmethod
     @classmethod
@@ -200,11 +237,11 @@ class UpdateRoomRequest(GroupModel):
     @model_validator(mode="after")
     @model_validator(mode="after")
     def require_change(self) -> "UpdateRoomRequest":
     def require_change(self) -> "UpdateRoomRequest":
         if not (
         if not (
-            {"name", "cwd", "instructions", "max_reply_rounds", "orchestration_mode"}
+            {"name", "cwd", "instructions", "avatar", "max_reply_rounds", "orchestration_mode"}
             & self.model_fields_set
             & self.model_fields_set
         ):
         ):
             raise ValueError(
             raise ValueError(
-                "Room update requires name, cwd, instructions, maxReplyRounds, or orchestrationMode"
+                "Room update requires name, cwd, instructions, avatar, maxReplyRounds, or orchestrationMode"
             )
             )
         return self
         return self
 
 
@@ -213,6 +250,11 @@ class UpdateRoomRequest(GroupModel):
     def normalize_instructions(cls, value: str | None) -> str | None:
     def normalize_instructions(cls, value: str | None) -> str | None:
         return None if value is None else normalize_room_instructions(value)
         return None if value is None else normalize_room_instructions(value)
 
 
+    @field_validator("avatar")
+    @classmethod
+    def normalize_avatar(cls, value: str | None) -> str | None:
+        return None if value is None else normalize_room_avatar(value)
+
     @field_validator("max_reply_rounds")
     @field_validator("max_reply_rounds")
     @classmethod
     @classmethod
     def validate_max_reply_rounds(cls, value: int | None) -> int | None:
     def validate_max_reply_rounds(cls, value: int | None) -> int | None:

+ 70 - 13
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -40,11 +40,12 @@ from .group_protocol import (
     normalize_max_reply_rounds,
     normalize_max_reply_rounds,
     normalize_room_cwd,
     normalize_room_cwd,
     normalize_room_instructions,
     normalize_room_instructions,
+    normalize_room_avatar,
     normalize_room_name,
     normalize_room_name,
 )
 )
 
 
 
 
-SCHEMA_VERSION = 13
+SCHEMA_VERSION = 14
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
 MIN_RETAINED_EVENTS = 100_000
@@ -116,6 +117,7 @@ _SCHEMA_STATEMENTS = (
         name TEXT NOT NULL,
         name TEXT NOT NULL,
         cwd TEXT NOT NULL DEFAULT '',
         cwd TEXT NOT NULL DEFAULT '',
         instructions TEXT NOT NULL DEFAULT '',
         instructions TEXT NOT NULL DEFAULT '',
+        avatar_data_url TEXT NOT NULL DEFAULT '',
         max_reply_rounds INTEGER NOT NULL DEFAULT 3,
         max_reply_rounds INTEGER NOT NULL DEFAULT 3,
         orchestration_mode TEXT NOT NULL DEFAULT 'free',
         orchestration_mode TEXT NOT NULL DEFAULT 'free',
         created_at REAL NOT NULL,
         created_at REAL NOT NULL,
@@ -505,6 +507,9 @@ class GroupStore:
             raw_version = "12"
             raw_version = "12"
         if raw_version == "12":
         if raw_version == "12":
             self._migrate_v12_to_v13(connection)
             self._migrate_v12_to_v13(connection)
+            raw_version = "13"
+        if raw_version == "13":
+            self._migrate_v13_to_v14(connection)
         self._validated_schema_version(
         self._validated_schema_version(
             self._metadata_value_from_connection(connection, "schema_version")
             self._metadata_value_from_connection(connection, "schema_version")
         )
         )
@@ -1274,11 +1279,26 @@ class GroupStore:
             "UPDATE group_meta SET value = '13' WHERE key = 'schema_version'"
             "UPDATE group_meta SET value = '13' WHERE key = 'schema_version'"
         )
         )
 
 
+    @staticmethod
+    def _migrate_v13_to_v14(connection: sqlite3.Connection) -> None:
+        columns = {
+            str(row["name"])
+            for row in connection.execute("PRAGMA table_info(group_rooms)")
+        }
+        if "avatar_data_url" not in columns:
+            connection.execute(
+                "ALTER TABLE group_rooms ADD COLUMN avatar_data_url "
+                "TEXT NOT NULL DEFAULT ''"
+            )
+        connection.execute(
+            "UPDATE group_meta SET value = '14' WHERE key = 'schema_version'"
+        )
+
     @staticmethod
     @staticmethod
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
         expected_columns = {
             "group_meta": {"key", "value"},
             "group_meta": {"key", "value"},
-            "group_rooms": {"id", "name", "cwd", "instructions", "max_reply_rounds", "orchestration_mode", "created_at", "updated_at", "archived"},
+            "group_rooms": {"id", "name", "cwd", "instructions", "avatar_data_url", "max_reply_rounds", "orchestration_mode", "created_at", "updated_at", "archived"},
             "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at", "archived", "pinned"},
             "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at", "archived", "pinned"},
             "group_agents": {"id", "room_id", "profile", "node_id", "node_label", "execution_profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "is_host", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
             "group_agents": {"id", "room_id", "profile", "node_id", "node_label", "execution_profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "is_host", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
             "group_agent_topic_state": {"agent_id", "topic_id", "last_context_message_seq", "created_at", "updated_at"},
             "group_agent_topic_state": {"agent_id", "topic_id", "last_context_message_seq", "created_at", "updated_at"},
@@ -1292,6 +1312,7 @@ class GroupStore:
             ("group_rooms", "max_reply_rounds"): ("INTEGER", 1, "3"),
             ("group_rooms", "max_reply_rounds"): ("INTEGER", 1, "3"),
             ("group_rooms", "orchestration_mode"): ("TEXT", 1, "'free'"),
             ("group_rooms", "orchestration_mode"): ("TEXT", 1, "'free'"),
             ("group_rooms", "instructions"): ("TEXT", 1, "''"),
             ("group_rooms", "instructions"): ("TEXT", 1, "''"),
+            ("group_rooms", "avatar_data_url"): ("TEXT", 1, "''"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
             ("group_agents", "is_host"): ("INTEGER", 1, "0"),
             ("group_agents", "is_host"): ("INTEGER", 1, "0"),
             ("group_agents", "node_id"): ("TEXT", 1, "'local'"),
             ("group_agents", "node_id"): ("TEXT", 1, "'local'"),
@@ -1749,6 +1770,7 @@ class GroupStore:
                 "name",
                 "name",
                 "cwd",
                 "cwd",
                 "instructions",
                 "instructions",
+                "avatar",
                 "maxReplyRounds",
                 "maxReplyRounds",
                 "orchestrationMode",
                 "orchestrationMode",
                 "agents",
                 "agents",
@@ -1762,6 +1784,7 @@ class GroupStore:
             instructions = normalize_room_instructions(
             instructions = normalize_room_instructions(
                 command.get("instructions", "")
                 command.get("instructions", "")
             )
             )
+            avatar = normalize_room_avatar(command.get("avatar", ""))
             max_reply_rounds = normalize_max_reply_rounds(
             max_reply_rounds = normalize_max_reply_rounds(
                 command.get("maxReplyRounds", DEFAULT_MAX_REPLY_ROUNDS)
                 command.get("maxReplyRounds", DEFAULT_MAX_REPLY_ROUNDS)
             )
             )
@@ -1773,11 +1796,11 @@ class GroupStore:
             room_id = str(uuid.uuid4())
             room_id = str(uuid.uuid4())
             connection.execute(
             connection.execute(
                 """INSERT INTO group_rooms
                 """INSERT INTO group_rooms
-                (id, name, cwd, instructions, max_reply_rounds, orchestration_mode,
+                (id, name, cwd, instructions, avatar_data_url, max_reply_rounds, orchestration_mode,
                  created_at, updated_at, archived)
                  created_at, updated_at, archived)
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)""",
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)""",
                 (
                 (
-                    room_id, name, cwd, instructions, max_reply_rounds,
+                    room_id, name, cwd, instructions, avatar, max_reply_rounds,
                     orchestration_mode, now, now,
                     orchestration_mode, now, now,
                 ),
                 ),
             )
             )
@@ -1853,7 +1876,7 @@ class GroupStore:
         ):
         ):
             raise ValueError("limit must be an integer from 1 to 100")
             raise ValueError("limit must be an integer from 1 to 100")
         cursor_values = self._decode_cursor(cursor) if cursor is not None else None
         cursor_values = self._decode_cursor(cursor) if cursor is not None else None
-        query = """SELECT id, name, cwd, instructions, max_reply_rounds, orchestration_mode,
+        query = """SELECT id, name, cwd, instructions, avatar_data_url, max_reply_rounds, orchestration_mode,
             created_at, updated_at, archived,
             created_at, updated_at, archived,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id) AS agent_count,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id) AS agent_count,
             (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id) AS topic_count
             (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id) AS topic_count
@@ -1868,7 +1891,9 @@ class GroupStore:
             rows = connection.execute(query, params).fetchall()
             rows = connection.execute(query, params).fetchall()
             items = []
             items = []
             for row in rows[:limit]:
             for row in rows[:limit]:
-                summary = self._room_summary(row)
+                summary = self._room_summary(
+                    row, self._room_avatar_members(connection, str(row["id"]))
+                )
                 activity = self._room_activity_from_connection(
                 activity = self._room_activity_from_connection(
                     connection, str(row["id"])
                     connection, str(row["id"])
                 )
                 )
@@ -2005,7 +2030,7 @@ class GroupStore:
         command = self._command(
         command = self._command(
             command,
             command,
             {
             {
-                "requestId", "name", "cwd", "instructions",
+                "requestId", "name", "cwd", "instructions", "avatar",
                 "maxReplyRounds", "orchestrationMode",
                 "maxReplyRounds", "orchestrationMode",
             },
             },
         )
         )
@@ -2013,13 +2038,13 @@ class GroupStore:
         changes = {
         changes = {
             key
             key
             for key in (
             for key in (
-                "name", "cwd", "instructions", "maxReplyRounds", "orchestrationMode"
+                "name", "cwd", "instructions", "avatar", "maxReplyRounds", "orchestrationMode"
             )
             )
             if key in command
             if key in command
         }
         }
         if not changes:
         if not changes:
             raise ValueError(
             raise ValueError(
-                "Room update requires name, cwd, instructions, maxReplyRounds, or orchestrationMode"
+                "Room update requires name, cwd, instructions, avatar, maxReplyRounds, or orchestrationMode"
             )
             )
 
 
         def action(connection: sqlite3.Connection) -> dict[str, object]:
         def action(connection: sqlite3.Connection) -> dict[str, object]:
@@ -2035,6 +2060,9 @@ class GroupStore:
             if "instructions" in changes:
             if "instructions" in changes:
                 assignments.append("instructions = ?")
                 assignments.append("instructions = ?")
                 values.append(normalize_room_instructions(command["instructions"]))
                 values.append(normalize_room_instructions(command["instructions"]))
+            if "avatar" in changes:
+                assignments.append("avatar_data_url = ?")
+                values.append(normalize_room_avatar(command["avatar"]))
             if "maxReplyRounds" in changes:
             if "maxReplyRounds" in changes:
                 assignments.append("max_reply_rounds = ?")
                 assignments.append("max_reply_rounds = ?")
                 values.append(normalize_max_reply_rounds(command["maxReplyRounds"]))
                 values.append(normalize_max_reply_rounds(command["maxReplyRounds"]))
@@ -5872,7 +5900,7 @@ class GroupStore:
         created_at: float,
         created_at: float,
     ) -> None:
     ) -> None:
         room = connection.execute(
         room = connection.execute(
-            """SELECT id, name, cwd, instructions, max_reply_rounds, orchestration_mode,
+            """SELECT id, name, cwd, instructions, avatar_data_url, max_reply_rounds, orchestration_mode,
             created_at, updated_at, archived,
             created_at, updated_at, archived,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id)
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id)
                 AS agent_count,
                 AS agent_count,
@@ -5887,7 +5915,9 @@ class GroupStore:
             connection,
             connection,
             room_id,
             room_id,
             "room.updated",
             "room.updated",
-            self._room_summary(room),
+            self._room_summary(
+                room, self._room_avatar_members(connection, room_id)
+            ),
             created_at=created_at,
             created_at=created_at,
         )
         )
 
 
@@ -5905,6 +5935,9 @@ class GroupStore:
         ).fetchall()
         ).fetchall()
         result = self._room_wire(room)
         result = self._room_wire(room)
         result["agents"] = [self._agent_wire(agent) for agent in agents]
         result["agents"] = [self._agent_wire(agent) for agent in agents]
+        result["avatarMembers"] = [
+            self._avatar_member_wire(agent) for agent in agents[:4]
+        ]
         return result
         return result
 
 
     def prepare_run_session_configuration(
     def prepare_run_session_configuration(
@@ -6905,6 +6938,7 @@ class GroupStore:
             "name": row["name"],
             "name": row["name"],
             "cwd": row["cwd"],
             "cwd": row["cwd"],
             "instructions": row["instructions"],
             "instructions": row["instructions"],
+            "avatar": row["avatar_data_url"],
             "maxReplyRounds": row["max_reply_rounds"],
             "maxReplyRounds": row["max_reply_rounds"],
             "orchestrationMode": row["orchestration_mode"],
             "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],
             "createdAt": row["created_at"],
@@ -6913,12 +6947,17 @@ class GroupStore:
         }
         }
 
 
     @staticmethod
     @staticmethod
-    def _room_summary(row: sqlite3.Row) -> dict[str, object]:
+    def _room_summary(
+        row: sqlite3.Row,
+        avatar_members: list[dict[str, object]] | None = None,
+    ) -> dict[str, object]:
         return {
         return {
             "id": row["id"],
             "id": row["id"],
             "name": row["name"],
             "name": row["name"],
             "cwd": row["cwd"],
             "cwd": row["cwd"],
             "instructions": row["instructions"],
             "instructions": row["instructions"],
+            "avatar": row["avatar_data_url"],
+            "avatarMembers": avatar_members or [],
             "maxReplyRounds": row["max_reply_rounds"],
             "maxReplyRounds": row["max_reply_rounds"],
             "orchestrationMode": row["orchestration_mode"],
             "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],
             "createdAt": row["created_at"],
@@ -6928,6 +6967,24 @@ class GroupStore:
             "topicCount": row["topic_count"],
             "topicCount": row["topic_count"],
         }
         }
 
 
+    @staticmethod
+    def _avatar_member_wire(row: sqlite3.Row) -> dict[str, object]:
+        return {
+            "profile": row["execution_profile"] or row["profile"],
+            "nodeId": row["node_id"],
+            "displayName": row["display_name"],
+        }
+
+    def _room_avatar_members(
+        self, connection: sqlite3.Connection, room_id: str
+    ) -> list[dict[str, object]]:
+        rows = connection.execute(
+            """SELECT profile, execution_profile, node_id, display_name FROM group_agents
+            WHERE room_id = ? ORDER BY created_at ASC, id ASC LIMIT 4""",
+            (room_id,),
+        ).fetchall()
+        return [self._avatar_member_wire(row) for row in rows]
+
     @staticmethod
     @staticmethod
     def _topic_summary(row: sqlite3.Row) -> dict[str, object]:
     def _topic_summary(row: sqlite3.Row) -> dict[str, object]:
         preview = str(row["preview"] or "")
         preview = str(row["preview"] or "")

+ 3 - 3
hermes-plugins/yaoyao/dashboard/tests/test_group_host_contract.py

@@ -34,7 +34,7 @@ def new_id() -> str:
 
 
 class GroupHostContractTests(unittest.TestCase):
 class GroupHostContractTests(unittest.TestCase):
     def test_protocol_v5_host_fields_are_independent_and_compatible(self) -> None:
     def test_protocol_v5_host_fields_are_independent_and_compatible(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 11)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 12)
         legacy = PROTOCOL.CreateRoomRequest.model_validate({
         legacy = PROTOCOL.CreateRoomRequest.model_validate({
             "requestId": new_id(),
             "requestId": new_id(),
             "name": "兼容群",
             "name": "兼容群",
@@ -496,7 +496,7 @@ class GroupHostContractTests(unittest.TestCase):
 
 
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 13)
+            self.assertEqual(migrated.schema_version(), 14)
             self.assertTrue(
             self.assertTrue(
                 all(
                 all(
                     room["orchestrationMode"] == "free"
                     room["orchestrationMode"] == "free"
@@ -881,7 +881,7 @@ class GroupHostContractTests(unittest.TestCase):
 
 
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 13)
+            self.assertEqual(migrated.schema_version(), 14)
             self.assertEqual(
             self.assertEqual(
                 migrated.get_room(room["id"])["orchestrationMode"], "free"
                 migrated.get_room(room["id"])["orchestrationMode"], "free"
             )
             )

+ 62 - 3
hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py

@@ -75,6 +75,15 @@ class GroupSettingsContractTests(unittest.TestCase):
             self.assertEqual(agents_by_node["local"]["profile"], "default")
             self.assertEqual(agents_by_node["local"]["profile"], "default")
             self.assertEqual(agents_by_node[node_id]["profile"], "default")
             self.assertEqual(agents_by_node[node_id]["profile"], "default")
             self.assertEqual(agents_by_node[node_id]["nodeLabel"], "MacBook Pro")
             self.assertEqual(agents_by_node[node_id]["nodeLabel"], "MacBook Pro")
+            self.assertEqual(
+                {(item["nodeId"], item["profile"]) for item in room["avatarMembers"]},
+                {("local", "default"), (node_id, "default")},
+            )
+            [summary] = store.list_rooms(limit=50, cursor=None).items
+            self.assertEqual(
+                {(item["nodeId"], item["profile"]) for item in summary["avatarMembers"]},
+                {("local", "default"), (node_id, "default")},
+            )
             with store.connection() as connection:
             with store.connection() as connection:
                 stored = connection.execute(
                 stored = connection.execute(
                     "SELECT profile, execution_profile FROM group_agents "
                     "SELECT profile, execution_profile FROM group_agents "
@@ -391,7 +400,7 @@ class GroupSettingsContractTests(unittest.TestCase):
 
 
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 13)
+            self.assertEqual(migrated.schema_version(), 14)
             self.assertEqual(migrated.get_room(room["id"])["instructions"], "")
             self.assertEqual(migrated.get_room(room["id"])["instructions"], "")
 
 
     def test_terminal_session_info_captures_effective_model_for_settlement(self) -> None:
     def test_terminal_session_info_captures_effective_model_for_settlement(self) -> None:
@@ -696,7 +705,7 @@ class GroupSettingsContractTests(unittest.TestCase):
                 )
                 )
 
 
     def test_protocol_v5_advertises_reply_round_contract(self) -> None:
     def test_protocol_v5_advertises_reply_round_contract(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 11)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 12)
         self.assertEqual(
         self.assertEqual(
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
         )
         )
@@ -729,6 +738,56 @@ class GroupSettingsContractTests(unittest.TestCase):
                     }
                     }
                 )
                 )
 
 
+    def test_room_avatar_persists_and_auto_mode_exposes_members(self) -> None:
+        avatar = "data:image/png;base64,AA=="
+        request = PROTOCOL.CreateRoomRequest.model_validate({
+            "requestId": request_id(),
+            "name": "头像团队",
+            "avatar": avatar,
+            "agents": [
+                {"profile": "planner", "displayName": "策划"},
+                {"profile": "reviewer", "displayName": "评审"},
+            ],
+        })
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                group_plugin_api._create_room_command(request)
+            )
+            self.assertEqual(room["avatar"], avatar)
+            self.assertEqual(
+                {item["displayName"] for item in room["avatarMembers"]},
+                {"策划", "评审"},
+            )
+
+            [summary] = store.list_rooms(limit=50, cursor=None).items
+            self.assertEqual(summary["avatar"], avatar)
+            self.assertEqual(
+                {item["profile"] for item in summary["avatarMembers"]},
+                {"planner", "reviewer"},
+            )
+
+            updated = store.update_room(room["id"], {
+                "requestId": request_id(),
+                "avatar": "",
+            })
+            self.assertEqual(updated["avatar"], "")
+            self.assertEqual(len(updated["avatarMembers"]), 2)
+
+        for invalid in (
+            "https://example.com/avatar.png",
+            "data:image/svg+xml;base64,AA==",
+            "data:image/png;base64,***",
+        ):
+            with self.subTest(invalid=invalid), self.assertRaises(ValueError):
+                PROTOCOL.CreateRoomRequest.model_validate({
+                    "requestId": request_id(),
+                    "name": "头像团队",
+                    "avatar": invalid,
+                    "agents": [{"profile": "planner"}],
+                })
+
     def test_reserved_all_aliases_cannot_be_agent_display_names(self) -> None:
     def test_reserved_all_aliases_cannot_be_agent_display_names(self) -> None:
         model_payloads = (
         model_payloads = (
             (
             (
@@ -1070,7 +1129,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             self._create_v1_database(path)
             self._create_v1_database(path)
             store = GroupStore(path)
             store = GroupStore(path)
             store.initialize()
             store.initialize()
-            self.assertEqual(store.schema_version(), 13)
+            self.assertEqual(store.schema_version(), 14)
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             with store.connection() as connection:
             with store.connection() as connection:
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()

+ 4 - 3
hermes-plugins/yaoyao/dashboard/tests/test_group_topics_contract.py

@@ -37,7 +37,7 @@ def new_id() -> str:
 
 
 class GroupTopicsContractTests(unittest.TestCase):
 class GroupTopicsContractTests(unittest.TestCase):
     def test_protocol_and_router_advertise_topics(self) -> None:
     def test_protocol_and_router_advertise_topics(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 11)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 12)
         self.assertIn("topic.updated", PROTOCOL.EVENT_TYPES)
         self.assertIn("topic.updated", PROTOCOL.EVENT_TYPES)
         self.assertIn("room.activity", PROTOCOL.EVENT_TYPES)
         self.assertIn("room.activity", PROTOCOL.EVENT_TYPES)
         request = PROTOCOL.SendMessageRequest.model_validate({
         request = PROTOCOL.SendMessageRequest.model_validate({
@@ -268,7 +268,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
 
 
-            self.assertEqual(migrated.schema_version(), 13)
+            self.assertEqual(migrated.schema_version(), 14)
             with migrated.connection() as connection:
             with migrated.connection() as connection:
                 topic = connection.execute(
                 topic = connection.execute(
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
@@ -1504,7 +1504,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
 
             store = GroupStore(path)
             store = GroupStore(path)
             store.initialize()
             store.initialize()
-            self.assertEqual(store.schema_version(), 13)
+            self.assertEqual(store.schema_version(), 14)
             with store.connection() as migrated:
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
@@ -1534,6 +1534,7 @@ class GroupTopicsContractTests(unittest.TestCase):
                     **expected_room,
                     **expected_room,
                     "orchestration_mode": "free",
                     "orchestration_mode": "free",
                     "instructions": "",
                     "instructions": "",
+                    "avatar_data_url": "",
                 }
                 }
             )
             )
             self.assertEqual(
             self.assertEqual(

BIN
hermes-yaoyao-0.1.1-linux-amd64.tar


+ 2 - 2
src/client/api/groups.ts

@@ -110,13 +110,13 @@ export interface AgentSeed {
   fastMode?: boolean | null
   fastMode?: boolean | null
 }
 }
 
 
-export async function createGroupRoom(input: { name: string; cwd?: string; instructions?: string; agents: AgentSeed[]; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
+export async function createGroupRoom(input: { name: string; cwd?: string; instructions?: string; avatar?: string; agents: AgentSeed[]; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms`, {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms`, {
     method: 'POST', body: { requestId: requestId(), cwd: '', maxReplyRounds: 3, ...input } as unknown as JsonValue,
     method: 'POST', body: { requestId: requestId(), cwd: '', maxReplyRounds: 3, ...input } as unknown as JsonValue,
   })))
   })))
 }
 }
 
 
-export async function updateGroupRoom(roomId: string, input: { name?: string; cwd?: string; instructions?: string; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
+export async function updateGroupRoom(roomId: string, input: { name?: string; cwd?: string; instructions?: string; avatar?: string; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`, {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`, {
     method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
     method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
   })))
   })))

+ 3 - 1
src/client/components/app/ResourceSidebar.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import TeamAvatar from '@/components/common/TeamAvatar.vue'
 import type { SidebarItem } from './types'
 import type { SidebarItem } from './types'
 
 
 const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
 const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
@@ -148,7 +149,8 @@ defineExpose({ focusSearch })
               <AppIcon name="chevron-down" :size="13" />
               <AppIcon name="chevron-down" :size="13" />
             </button>
             </button>
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
-              <AppIcon v-if="row.item.icon" :name="row.item.icon" :size="15" />
+              <TeamAvatar v-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :size="23" />
+              <AppIcon v-else-if="row.item.icon" :name="row.item.icon" :size="15" />
               <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
               <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
               <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />
               <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />
             </span>
             </span>

+ 2 - 0
src/client/components/app/types.ts

@@ -14,4 +14,6 @@ export type SidebarItem = {
   expanded?: boolean
   expanded?: boolean
   status?: 'online' | 'working' | 'offline'
   status?: 'online' | 'working' | 'offline'
   icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts' | 'branch' | 'topic' | 'chevron-left' | 'plus'
   icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts' | 'branch' | 'topic' | 'chevron-left' | 'plus'
+  avatar?: string
+  avatarMembers?: Array<{ name: string; avatar?: string }>
 }
 }

+ 68 - 0
src/client/components/common/TeamAvatar.vue

@@ -0,0 +1,68 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import AgentAvatar from '@/components/common/AgentAvatar.vue'
+
+export type TeamAvatarMember = {
+  name: string
+  avatar?: string
+}
+
+const props = withDefaults(defineProps<{
+  name: string
+  avatar?: string
+  members?: TeamAvatarMember[]
+  size?: number
+}>(), {
+  avatar: '',
+  members: () => [],
+  size: 32,
+})
+
+const hasCustomAvatar = computed(() => /^data:image\/(png|jpeg|webp);base64,/i.test(props.avatar))
+const visibleMembers = computed(() => props.members.slice(0, 4))
+const memberSize = computed(() => Math.max(12, Math.round(props.size * ({ 1: .72, 2: .62, 3: .54, 4: .48 }[visibleMembers.value.length] || .48))))
+</script>
+
+<template>
+  <span
+    class="team-avatar"
+    :class="`team-avatar--${Math.max(1, visibleMembers.length)}`"
+    :style="{ width: `${size}px`, height: `${size}px` }"
+    role="img"
+    :aria-label="`${name}团队头像`"
+  >
+    <img v-if="hasCustomAvatar" class="team-avatar__custom" :src="avatar" alt="" />
+    <template v-else-if="visibleMembers.length">
+      <span
+        v-for="(member, index) in visibleMembers"
+        :key="`${member.name}:${index}`"
+        class="team-avatar__member"
+        :style="{ width: `${memberSize}px`, height: `${memberSize}px` }"
+      >
+        <AgentAvatar :name="member.name" :avatar="member.avatar" :size="memberSize" />
+      </span>
+    </template>
+    <span v-else class="team-avatar__fallback">{{ name.trim().slice(0, 1).toUpperCase() || '团' }}</span>
+  </span>
+</template>
+
+<style scoped>
+.team-avatar { position: relative; display: block; flex: 0 0 auto; overflow: hidden; border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); }
+.team-avatar__custom { width: 100%; height: 100%; object-fit: cover; }
+.team-avatar__member { position: absolute; display: grid; place-items: center; overflow: hidden; border-radius: 50%; background: color-mix(in srgb, var(--surface-raised) 82%, var(--accent)); box-shadow: 0 0 0 max(1px, calc(v-bind(size) * .035)) var(--surface-raised); }
+.team-avatar__member :deep(.agent-avatar) { border-radius: 50%; }
+.team-avatar__member:nth-of-type(2) { background: color-mix(in srgb, var(--surface-raised) 84%, var(--danger)); }
+.team-avatar__member:nth-of-type(3) { background: color-mix(in srgb, var(--surface-raised) 84%, var(--success)); }
+.team-avatar__member:nth-of-type(4) { background: color-mix(in srgb, var(--surface-raised) 84%, var(--warning)); }
+.team-avatar--1 .team-avatar__member { top: 50%; left: 50%; transform: translate(-50%, -50%); }
+.team-avatar--2 .team-avatar__member { top: 50%; left: 8%; transform: translateY(-50%); }
+.team-avatar--2 .team-avatar__member:nth-of-type(2) { right: 8%; left: auto; }
+.team-avatar--3 .team-avatar__member:first-of-type { top: 2%; left: 50%; transform: translateX(-50%); }
+.team-avatar--3 .team-avatar__member:nth-of-type(2) { bottom: 3%; left: 4%; }
+.team-avatar--3 .team-avatar__member:nth-of-type(3) { right: 4%; bottom: 3%; }
+.team-avatar--4 .team-avatar__member:first-of-type { top: 4%; left: 4%; }
+.team-avatar--4 .team-avatar__member:nth-of-type(2) { top: 4%; right: 4%; }
+.team-avatar--4 .team-avatar__member:nth-of-type(3) { bottom: 4%; left: 4%; }
+.team-avatar--4 .team-avatar__member:nth-of-type(4) { right: 4%; bottom: 4%; }
+.team-avatar__fallback { display: grid; width: 100%; height: 100%; place-items: center; background: var(--surface-hover); font-size: calc(v-bind(size) * .36); font-weight: 700; }
+</style>

+ 36 - 2
src/client/components/groups/CreateGroupDialog.vue

@@ -2,10 +2,13 @@
 import { computed, ref, watch } from 'vue'
 import { computed, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
+import TeamAvatar from '@/components/common/TeamAvatar.vue'
+import { processTeamAvatarFile } from '@/utils/teamAvatar'
 import type { GroupProfileOption } from './types'
 import type { GroupProfileOption } from './types'
 
 
 interface CreateGroupPayload {
 interface CreateGroupPayload {
   name: string
   name: string
+  avatar?: string
   members: GroupProfileOption[]
   members: GroupProfileOption[]
   autoReply: boolean
   autoReply: boolean
   replyRounds: number
   replyRounds: number
@@ -14,7 +17,8 @@ interface CreateGroupPayload {
   orchestrationMode?: 'free' | 'host'
   orchestrationMode?: 'free' | 'host'
 }
 }
 
 
-const props = withDefaults(defineProps<{ open: boolean; profiles: GroupProfileOption[]; hostEnabled?: boolean; hostFlowEnabled?: boolean; roomInstructionsEnabled?: boolean; busy?: boolean }>(), {
+const props = withDefaults(defineProps<{ open: boolean; profiles: GroupProfileOption[]; avatarEnabled?: boolean; hostEnabled?: boolean; hostFlowEnabled?: boolean; roomInstructionsEnabled?: boolean; busy?: boolean }>(), {
+  avatarEnabled: false,
   hostEnabled: false,
   hostEnabled: false,
   hostFlowEnabled: false,
   hostFlowEnabled: false,
   roomInstructionsEnabled: false,
   roomInstructionsEnabled: false,
@@ -29,6 +33,11 @@ const hostProfile = ref('')
 const autoReply = ref(true)
 const autoReply = ref(true)
 const replyRounds = ref(3)
 const replyRounds = ref(3)
 const orchestrationMode = ref<'free' | 'host'>('free')
 const orchestrationMode = ref<'free' | 'host'>('free')
+const avatarMode = ref<'auto' | 'upload'>('auto')
+const avatarDataURL = ref('')
+const avatarError = ref('')
+const avatarInput = ref<HTMLInputElement>()
+const selectedMembers = computed(() => selected.value.flatMap(id => props.profiles.find(profile => profile.id === id) ?? []))
 const valid = computed(() => name.value.trim().length > 0
 const valid = computed(() => name.value.trim().length > 0
   && selected.value.length >= 1
   && selected.value.length >= 1
   && selected.value.length <= 8
   && selected.value.length <= 8
@@ -43,6 +52,9 @@ watch(() => props.open, open => {
   autoReply.value = true
   autoReply.value = true
   replyRounds.value = 3
   replyRounds.value = 3
   orchestrationMode.value = 'free'
   orchestrationMode.value = 'free'
+  avatarMode.value = 'auto'
+  avatarDataURL.value = ''
+  avatarError.value = ''
 }, { immediate: true })
 }, { immediate: true })
 
 
 function toggle(profile: GroupProfileOption) {
 function toggle(profile: GroupProfileOption) {
@@ -51,14 +63,29 @@ function toggle(profile: GroupProfileOption) {
   if (!selected.value.includes(hostProfile.value)) hostProfile.value = selected.value[0] ?? ''
   if (!selected.value.includes(hostProfile.value)) hostProfile.value = selected.value[0] ?? ''
 }
 }
 
 
+async function chooseAvatar(event: Event) {
+  const file = (event.target as HTMLInputElement).files?.[0]
+  if (!file) return
+  try {
+    avatarDataURL.value = await processTeamAvatarFile(file)
+    avatarMode.value = 'upload'
+    avatarError.value = ''
+  } catch (cause) {
+    avatarError.value = cause instanceof Error ? cause.message : '处理头像失败'
+  } finally {
+    if (avatarInput.value) avatarInput.value.value = ''
+  }
+}
+
 function create() {
 function create() {
   if (!valid.value) return
   if (!valid.value) return
   const payload: CreateGroupPayload = {
   const payload: CreateGroupPayload = {
     name: name.value.trim(),
     name: name.value.trim(),
-    members: selected.value.flatMap(id => props.profiles.find(profile => profile.id === id) ?? []),
+    members: selectedMembers.value,
     autoReply: autoReply.value,
     autoReply: autoReply.value,
     replyRounds: replyRounds.value,
     replyRounds: replyRounds.value,
   }
   }
+  if (props.avatarEnabled) payload.avatar = avatarMode.value === 'upload' ? avatarDataURL.value : ''
   if (props.hostEnabled) payload.hostProfile = hostProfile.value
   if (props.hostEnabled) payload.hostProfile = hostProfile.value
   if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
   if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
   if (props.roomInstructionsEnabled) payload.instructions = instructions.value.trim()
   if (props.roomInstructionsEnabled) payload.instructions = instructions.value.trim()
@@ -73,6 +100,12 @@ function create() {
         <section class="create-dialog" role="dialog" aria-modal="true" aria-labelledby="create-group-title">
         <section class="create-dialog" role="dialog" aria-modal="true" aria-labelledby="create-group-title">
           <header><div><small>9119 团队</small><h2 id="create-group-title">新建团队</h2></div><button class="icon-button" type="button" aria-label="关闭" @click="emit('close')"><AppIcon name="close" /></button></header>
           <header><div><small>9119 团队</small><h2 id="create-group-title">新建团队</h2></div><button class="icon-button" type="button" aria-label="关闭" @click="emit('close')"><AppIcon name="close" /></button></header>
           <label class="field"><span>团队名称</span><input v-model="name" maxlength="80" autofocus placeholder="例如:产品评审" /></label>
           <label class="field"><span>团队名称</span><input v-model="name" maxlength="80" autofocus placeholder="例如:产品评审" /></label>
+          <section v-if="avatarEnabled" class="avatar-picker" aria-labelledby="team-avatar-label">
+            <TeamAvatar :name="name || '团队'" :avatar="avatarMode === 'upload' ? avatarDataURL : ''" :members="selectedMembers.map(member => ({ name: member.displayName, avatar: member.avatar }))" :size="58" />
+            <div><strong id="team-avatar-label">团队头像</strong><small>{{ avatarMode === 'auto' ? '自动组合已选 Agent 的头像' : '使用上传的图片' }}</small><p><button class="quiet-button" :class="{ active: avatarMode === 'auto' }" type="button" @click="avatarMode = 'auto'">自动生成</button><button class="quiet-button" type="button" @click="avatarInput?.click()"><AppIcon name="image" :size="14" />上传图片</button></p></div>
+            <input ref="avatarInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
+          </section>
+          <p v-if="avatarError" class="avatar-error" role="alert">{{ avatarError }}</p>
           <label v-if="roomInstructionsEnabled" class="field room-instructions"><span>说明 <small>供所有 Agent 查阅的规则和形式准则</small></span><textarea v-model="instructions" maxlength="4000" rows="4" placeholder="例如:先确认事实;结论使用中文;涉及发布必须等待确认。" /></label>
           <label v-if="roomInstructionsEnabled" class="field room-instructions"><span>说明 <small>供所有 Agent 查阅的规则和形式准则</small></span><textarea v-model="instructions" maxlength="4000" rows="4" placeholder="例如:先确认事实;结论使用中文;涉及发布必须等待确认。" /></label>
           <div class="agent-picker">
           <div class="agent-picker">
             <div class="agent-picker__heading"><span>选择 Agent</span><small>{{ selected.length }}/8</small></div>
             <div class="agent-picker__heading"><span>选择 Agent</span><small>{{ selected.length }}/8</small></div>
@@ -110,6 +143,7 @@ function create() {
 .create-dialog { width: min(470px, 100%); max-height: min(680px, calc(100vh - 36px)); padding: 18px; overflow-y: auto; border: 1px solid var(--line); border-radius: 17px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
 .create-dialog { width: min(470px, 100%); max-height: min(680px, calc(100vh - 36px)); padding: 18px; overflow-y: auto; border: 1px solid var(--line); border-radius: 17px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
 header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; } header small { color: var(--text-muted); font-size: 9px; letter-spacing: .06em; } h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
 header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; } header small { color: var(--text-muted); font-size: 9px; letter-spacing: .06em; } h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
 .field { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: 10px; }.field span { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }.field small { color: var(--text-muted); font-size: 9px; font-weight: 400; }.field input, .field textarea { padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: none; resize: vertical; background: var(--surface-soft); color: var(--text-primary); }.field input { height: 38px; }.field textarea { min-height: 86px; padding-block: 9px; line-height: 1.5; }.field input:focus, .field textarea:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.room-instructions { margin-top: 13px; }
 .field { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: 10px; }.field span { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }.field small { color: var(--text-muted); font-size: 9px; font-weight: 400; }.field input, .field textarea { padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: none; resize: vertical; background: var(--surface-soft); color: var(--text-primary); }.field input { height: 38px; }.field textarea { min-height: 86px; padding-block: 9px; line-height: 1.5; }.field input:focus, .field textarea:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.room-instructions { margin-top: 13px; }
+.avatar-picker { display: flex; align-items: center; gap: 12px; margin-top: 13px; padding: 11px; border-radius: 11px; background: var(--surface-soft); }.avatar-picker > div { display: grid; min-width: 0; gap: 3px; }.avatar-picker strong { font-size: 10px; }.avatar-picker small { color: var(--text-muted); font-size: 9px; }.avatar-picker p { display: flex; gap: 5px; margin: 3px 0 0; }.avatar-picker .quiet-button { display: inline-flex; min-height: 27px; align-items: center; gap: 5px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; background: var(--surface-hover); color: var(--text-secondary); cursor: pointer; font-size: 9px; }.avatar-picker .quiet-button.active { border-color: color-mix(in srgb, var(--accent) 42%, var(--line)); color: var(--accent); }.avatar-error { margin: 6px 0 0; color: var(--danger); font-size: 9px; }.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
 .agent-picker { margin-top: 17px; }.agent-picker__heading { display: flex; justify-content: space-between; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.agent-picker__heading small { color: var(--text-muted); }.agent-picker > button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.agent-picker > button:hover, .agent-picker > button.selected { background: var(--surface-soft); }.agent-picker > button:disabled { opacity: .35; }.agent-picker > button strong { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; font-size: 11px; }.agent-picker > button strong small { overflow: hidden; color: var(--text-muted); font-size: 9px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }.agent-picker p { color: var(--text-muted); font-size: 10px; }
 .agent-picker { margin-top: 17px; }.agent-picker__heading { display: flex; justify-content: space-between; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.agent-picker__heading small { color: var(--text-muted); }.agent-picker > button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.agent-picker > button:hover, .agent-picker > button.selected { background: var(--surface-soft); }.agent-picker > button:disabled { opacity: .35; }.agent-picker > button strong { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; font-size: 11px; }.agent-picker > button strong small { overflow: hidden; color: var(--text-muted); font-size: 9px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }.agent-picker p { color: var(--text-muted); font-size: 10px; }
 .host-picker { display: grid; grid-template-columns: minmax(0, 1fr) 148px; align-items: center; gap: 12px; margin-top: 13px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; color: var(--text-secondary); font-size: 10px; }.host-picker > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; font-weight: 650; }.host-picker small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.45; }.host-picker select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 10px; }.host-picker select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .host-picker { display: grid; grid-template-columns: minmax(0, 1fr) 148px; align-items: center; gap: 12px; margin-top: 13px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; color: var(--text-secondary); font-size: 10px; }.host-picker > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; font-weight: 650; }.host-picker small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.45; }.host-picker select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 10px; }.host-picker select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .dialog-settings { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; padding: 11px; border-radius: 10px; background: var(--surface-soft); }.dialog-settings label { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 10px; }.dialog-settings input[type="checkbox"] { accent-color: var(--accent); }.dialog-settings input[type="number"] { width: 51px; padding: 4px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-raised); color: var(--text-primary); text-align: center; }
 .dialog-settings { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; padding: 11px; border-radius: 10px; background: var(--surface-soft); }.dialog-settings label { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 10px; }.dialog-settings input[type="checkbox"] { accent-color: var(--accent); }.dialog-settings input[type="number"] { width: 51px; padding: 4px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-raised); color: var(--text-primary); text-align: center; }

+ 49 - 2
src/client/components/groups/GroupManager.vue

@@ -3,6 +3,8 @@ import { computed, reactive, ref, watch } from 'vue'
 import type { GroupAgent, ModelOption } from '@shared/types'
 import type { GroupAgent, ModelOption } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
+import TeamAvatar from '@/components/common/TeamAvatar.vue'
+import { processTeamAvatarFile } from '@/utils/teamAvatar'
 import type { UiRoom } from './types'
 import type { UiRoom } from './types'
 import type { GroupProfileOption } from './types'
 import type { GroupProfileOption } from './types'
 
 
@@ -39,6 +41,7 @@ const props = withDefaults(defineProps<{
   hostEnabled?: boolean
   hostEnabled?: boolean
   hostFlowEnabled?: boolean
   hostFlowEnabled?: boolean
   roomInstructionsEnabled?: boolean
   roomInstructionsEnabled?: boolean
+  avatarEnabled?: boolean
   availableProfiles?: GroupProfileOption[]
   availableProfiles?: GroupProfileOption[]
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
   modelOptionsLoading?: Record<string, boolean>
@@ -48,7 +51,7 @@ const props = withDefaults(defineProps<{
   agentAvatars?: Record<string, string>
   agentAvatars?: Record<string, string>
   managerError?: string
   managerError?: string
   busy?: boolean
   busy?: boolean
-}>(), { hostEnabled: false, hostFlowEnabled: false, roomInstructionsEnabled: false, busy: false, agentAvatars: () => ({}) })
+}>(), { hostEnabled: false, hostFlowEnabled: false, roomInstructionsEnabled: false, avatarEnabled: false, busy: false, agentAvatars: () => ({}) })
 
 
 const emit = defineEmits<{
 const emit = defineEmits<{
   updateRoom: [patch: Partial<UiRoom>]
   updateRoom: [patch: Partial<UiRoom>]
@@ -64,15 +67,22 @@ const emit = defineEmits<{
 const form = reactive({
 const form = reactive({
   name: props.room.name,
   name: props.room.name,
   instructions: props.room.instructions ?? '',
   instructions: props.room.instructions ?? '',
+  avatar: props.room.avatar ?? '',
   replyRounds: props.room.replyRounds ?? 3,
   replyRounds: props.room.replyRounds ?? 3,
   orchestrationMode: props.room.orchestrationMode ?? 'free' as 'free' | 'host',
   orchestrationMode: props.room.orchestrationMode ?? 'free' as 'free' | 'host',
 })
 })
 const expandedAgentId = ref('')
 const expandedAgentId = ref('')
+const avatarInput = ref<HTMLInputElement>()
+const avatarError = ref('')
 const agentDrafts = reactive<Record<string, AgentDraft>>({})
 const agentDrafts = reactive<Record<string, AgentDraft>>({})
 const dirtyAgents = reactive(new Set<string>())
 const dirtyAgents = reactive(new Set<string>())
 const selectedAgent = computed(() => props.agents.find(agent => agent.id === expandedAgentId.value))
 const selectedAgent = computed(() => props.agents.find(agent => agent.id === expandedAgentId.value))
 const hostAgent = computed(() => props.agents.find(agent => agent.isHost))
 const hostAgent = computed(() => props.agents.find(agent => agent.isHost))
 const hasActiveAgents = computed(() => props.agents.some(agent => ['queued', 'running', 'awaiting_input'].includes(agent.status)))
 const hasActiveAgents = computed(() => props.agents.some(agent => ['queued', 'running', 'awaiting_input'].includes(agent.status)))
+const avatarMembers = computed(() => props.agents.slice(0, 4).map(agent => ({
+  name: agent.displayName || agent.profile,
+  avatar: agent.nodeId === 'local' ? props.agentAvatars[agent.profile] : undefined,
+})))
 
 
 function modelKey(provider?: string | null, model?: string | null): string {
 function modelKey(provider?: string | null, model?: string | null): string {
   return provider && model ? JSON.stringify([provider, model]) : ''
   return provider && model ? JSON.stringify([provider, model]) : ''
@@ -104,6 +114,7 @@ function draftFrom(agent: GroupAgent): AgentDraft {
 watch(() => props.room, room => {
 watch(() => props.room, room => {
   form.name = room.name
   form.name = room.name
   form.instructions = room.instructions ?? ''
   form.instructions = room.instructions ?? ''
+  form.avatar = room.avatar ?? ''
   form.replyRounds = room.replyRounds ?? 3
   form.replyRounds = room.replyRounds ?? 3
   form.orchestrationMode = room.orchestrationMode ?? 'free'
   form.orchestrationMode = room.orchestrationMode ?? 'free'
 }, { deep: true })
 }, { deep: true })
@@ -127,11 +138,33 @@ function saveRoom() {
   emit('updateRoom', {
   emit('updateRoom', {
     name: form.name.trim() || props.room.name,
     name: form.name.trim() || props.room.name,
     ...(props.roomInstructionsEnabled ? { instructions: form.instructions.trim() } : {}),
     ...(props.roomInstructionsEnabled ? { instructions: form.instructions.trim() } : {}),
+    ...(props.avatarEnabled ? { avatar: form.avatar } : {}),
     replyRounds: replyRounds === -1 ? -1 : Math.min(100, Math.max(1, replyRounds || 1)),
     replyRounds: replyRounds === -1 ? -1 : Math.min(100, Math.max(1, replyRounds || 1)),
     ...(props.hostFlowEnabled ? { orchestrationMode: form.orchestrationMode } : {}),
     ...(props.hostFlowEnabled ? { orchestrationMode: form.orchestrationMode } : {}),
   })
   })
 }
 }
 
 
+async function chooseAvatar(event: Event) {
+  const input = event.target as HTMLInputElement
+  const file = input.files?.[0]
+  if (!file) return
+  try {
+    form.avatar = await processTeamAvatarFile(file)
+    avatarError.value = ''
+    saveRoom()
+  } catch (cause) {
+    avatarError.value = cause instanceof Error ? cause.message : '处理头像失败'
+  } finally {
+    input.value = ''
+  }
+}
+
+function useAutomaticAvatar() {
+  form.avatar = ''
+  avatarError.value = ''
+  saveRoom()
+}
+
 function selectHost(event: Event) {
 function selectHost(event: Event) {
   const id = (event.target as HTMLSelectElement).value
   const id = (event.target as HTMLSelectElement).value
   if (!id || id === hostAgent.value?.id) return
   if (!id || id === hostAgent.value?.id) return
@@ -224,6 +257,19 @@ function statusLabel(status: GroupAgent['status']): string {
     <section>
     <section>
       <h3>团队设置</h3>
       <h3>团队设置</h3>
       <label><span>名称</span><input v-model="form.name" maxlength="80" @change="saveRoom" /></label>
       <label><span>名称</span><input v-model="form.name" maxlength="80" @change="saveRoom" /></label>
+      <div v-if="avatarEnabled" class="team-avatar-setting">
+        <TeamAvatar :name="room.name" :avatar="form.avatar" :members="avatarMembers" :size="52" />
+        <div>
+          <strong>团队头像</strong>
+          <small>{{ form.avatar ? '使用上传的图片' : '自动组合当前 Agent 头像' }}</small>
+          <span>
+            <button class="quiet-button" type="button" :disabled="busy || !form.avatar" @click="useAutomaticAvatar">自动生成</button>
+            <button class="quiet-button" type="button" :disabled="busy" @click="avatarInput?.click()"><AppIcon name="image" :size="14" />上传图片</button>
+          </span>
+        </div>
+        <input ref="avatarInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
+      </div>
+      <p v-if="avatarError" class="avatar-error" role="alert">{{ avatarError }}</p>
       <label v-if="roomInstructionsEnabled"><span>说明<small>所有 Agent 都会在回复前查阅,可填写协作规则和形式准则。</small></span><textarea v-model="form.instructions" maxlength="4000" rows="5" aria-label="团队说明" placeholder="例如:先核对事实;结论使用中文;发布前等待确认。" @change="saveRoom" /></label>
       <label v-if="roomInstructionsEnabled"><span>说明<small>所有 Agent 都会在回复前查阅,可填写协作规则和形式准则。</small></span><textarea v-model="form.instructions" maxlength="4000" rows="5" aria-label="团队说明" placeholder="例如:先核对事实;结论使用中文;发布前等待确认。" @change="saveRoom" /></label>
       <label class="rounds"><span>最多回复轮数<small>-1 表示无限</small></span><input v-model.number="form.replyRounds" type="number" min="-1" max="100" aria-label="最多回复轮数" @change="saveRoom" /></label>
       <label class="rounds"><span>最多回复轮数<small>-1 表示无限</small></span><input v-model.number="form.replyRounds" type="number" min="-1" max="100" aria-label="最多回复轮数" @change="saveRoom" /></label>
       <label v-if="hostFlowEnabled" class="flow-mode">
       <label v-if="hostFlowEnabled" class="flow-mode">
@@ -332,6 +378,7 @@ section { padding: 17px 0; border-top: 1px solid var(--line); } section:first-of
 h3 { margin: 0 0 13px; color: var(--text-secondary); font-size: 10px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; } h3 em { color: var(--text-muted); font-style: normal; font-weight: 500; }
 h3 { margin: 0 0 13px; color: var(--text-secondary); font-size: 10px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; } h3 em { color: var(--text-muted); font-style: normal; font-weight: 500; }
 label { display: flex; flex-direction: column; gap: 5px; margin: 0 0 11px; color: var(--text-muted); font-size: 10px; }
 label { display: flex; flex-direction: column; gap: 5px; margin: 0 0 11px; color: var(--text-muted); font-size: 10px; }
 input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; resize: vertical; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; } input:focus, textarea:focus, select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; resize: vertical; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; } input:focus, textarea:focus, select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
+.team-avatar-setting { display: flex; align-items: center; gap: 12px; margin: 0 0 13px; padding: 10px; border-radius: 11px; background: var(--surface-soft); }.team-avatar-setting > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }.team-avatar-setting strong { color: var(--text-secondary); font-size: 10px; }.team-avatar-setting small { color: var(--text-muted); font-size: 9px; }.team-avatar-setting span { display: flex; gap: 6px; margin-top: 4px; }.team-avatar-setting .quiet-button { display: inline-flex; min-height: 28px; align-items: center; gap: 5px; padding: 0 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 9px; }.team-avatar-setting .quiet-button:disabled { cursor: not-allowed; opacity: .4; }.avatar-error { margin: -7px 0 11px; color: var(--danger); font-size: 9px; }.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
 .rounds { flex-direction: row; align-items: center; justify-content: space-between; }.rounds > span { display: flex; flex-direction: column; gap: 2px; }.rounds small { color: var(--text-muted); font-size: 9px; }.rounds input { width: 62px; text-align: center; }
 .rounds { flex-direction: row; align-items: center; justify-content: space-between; }.rounds > span { display: flex; flex-direction: column; gap: 2px; }.rounds small { color: var(--text-muted); font-size: 9px; }.rounds input { width: 62px; text-align: center; }
 .flow-mode { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; }.flow-mode > span { display: flex; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.flow-mode small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.flow-mode select { margin: 0; padding: 7px 8px; background: var(--surface); }
 .flow-mode { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; }.flow-mode > span { display: flex; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.flow-mode small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.flow-mode select { margin: 0; padding: 7px 8px; background: var(--surface); }
 .host-selector { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; margin-bottom: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); }.host-selector > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.host-selector small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.host-selector select { padding: 7px 8px; background: var(--surface); }
 .host-selector { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; margin-bottom: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); }.host-selector > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.host-selector small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.host-selector select { padding: 7px 8px; background: var(--surface); }
@@ -339,7 +386,7 @@ input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(-
 .agent-avatar { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: transparent; color: var(--text-secondary); font-size: 10px; font-weight: 700; }.agent-avatar i { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface-soft); border-radius: 50%; background: var(--text-muted); }.agent-avatar .status-running, .agent-avatar .status-queued { background: var(--warning); }.agent-avatar .status-idle { background: var(--success); }.agent-avatar .status-awaiting_input { background: var(--warning); }
 .agent-avatar { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: transparent; color: var(--text-secondary); font-size: 10px; font-weight: 700; }.agent-avatar i { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface-soft); border-radius: 50%; background: var(--text-muted); }.agent-avatar .status-running, .agent-avatar .status-queued { background: var(--warning); }.agent-avatar .status-idle { background: var(--success); }.agent-avatar .status-awaiting_input { background: var(--warning); }
 .agent-copy { display: flex; min-width: 0; flex-direction: column; }.agent-name-line, .settings-agent-name { display: flex; min-width: 0; align-items: center; gap: 5px; }.agent-copy strong, .agent-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.agent-copy strong { min-width: 0; font-size: 11px; }.agent-copy small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }.host-badge { flex: 0 0 auto; padding: 2px 5px; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 9%, transparent); color: var(--accent); font-size: 8px; font-style: normal; font-weight: 650; line-height: 1.2; }
 .agent-copy { display: flex; min-width: 0; flex-direction: column; }.agent-name-line, .settings-agent-name { display: flex; min-width: 0; align-items: center; gap: 5px; }.agent-copy strong, .agent-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.agent-copy strong { min-width: 0; font-size: 11px; }.agent-copy small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }.host-badge { flex: 0 0 auto; padding: 2px 5px; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 9%, transparent); color: var(--accent); font-size: 8px; font-style: normal; font-weight: 650; line-height: 1.2; }
 .agent-action { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.agent-action:hover { background: var(--surface-hover); color: var(--text-primary); }.agent-action.danger:hover { color: var(--danger); }.agent-action:disabled { cursor: not-allowed; opacity: .25; }
 .agent-action { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.agent-action:hover { background: var(--surface-hover); color: var(--text-primary); }.agent-action.danger:hover { color: var(--danger); }.agent-action:disabled { cursor: not-allowed; opacity: .25; }
-.agent-settings-backdrop { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 24px; background: color-mix(in srgb, var(--text-primary) 24%, transparent); }.agent-settings-dialog { width: min(100%, 520px); max-height: min(720px, calc(100vh - 48px)); overflow: auto; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: 0 24px 72px color-mix(in srgb, var(--text-primary) 24%, transparent); }.agent-settings-dialog header { min-height: 68px; padding: 0 20px; border-bottom: 1px solid var(--line); border-radius: 16px 16px 0 0; }.agent-settings-close { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; }.agent-settings-close:hover { background: var(--surface-hover); color: var(--text-primary); }.agent-editor { display: block; min-width: 0; margin: 0; padding: 20px; border: 0; }.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }.editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }.editor-grid > label:nth-child(1), .editor-grid > label:nth-child(2) { grid-column: 1 / -1; }.editor-grid label:last-child { margin-bottom: 7px; }.editor-note { margin: -1px 2px 0; color: var(--text-muted); font-size: 9px; }.editor-note.error { color: var(--danger); }
+.agent-settings-backdrop { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 24px; background: color-mix(in srgb, var(--text-primary) 24%, transparent); }.agent-settings-dialog { width: min(100%, 520px); max-height: min(720px, calc(100vh - 48px)); overflow: auto; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: 0 24px 72px color-mix(in srgb, var(--text-primary) 24%, transparent); }.agent-settings-dialog header { min-height: 68px; padding: 0 20px; border-bottom: 1px solid var(--line); border-radius: 16px 16px 0 0; }.agent-settings-close { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; }.agent-settings-close:hover { background: var(--surface-hover); color: var(--text-primary); }.agent-editor { display: block; min-width: 0; margin: 0; padding: 20px; border: 0; }.editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }.editor-grid > label:nth-child(1), .editor-grid > label:nth-child(2) { grid-column: 1 / -1; }.editor-grid label:last-child { margin-bottom: 7px; }.editor-note { margin: -1px 2px 0; color: var(--text-muted); font-size: 9px; }.editor-note.error { color: var(--danger); }
 .editor-toggles { display: flex; align-items: center; gap: 16px; padding: 3px 0 12px; }.editor-toggles label { display: flex; flex-direction: row; align-items: center; gap: 5px; margin: 0; color: var(--text-secondary); }.editor-toggles input { width: auto; margin: 0; accent-color: var(--accent); }
 .editor-toggles { display: flex; align-items: center; gap: 16px; padding: 3px 0 12px; }.editor-toggles label { display: flex; flex-direction: row; align-items: center; gap: 5px; margin: 0; color: var(--text-secondary); }.editor-toggles input { width: auto; margin: 0; accent-color: var(--accent); }
 .remote-agent-address { margin: 0 0 10px; overflow-wrap: anywhere; color: var(--text-muted); font-size: 9px; line-height: 1.5; }
 .remote-agent-address { margin: 0 0 10px; overflow-wrap: anywhere; color: var(--text-muted); font-size: 9px; line-height: 1.5; }
 .host-explanation { margin: -4px 0 12px; color: var(--text-muted); font-size: 9px; line-height: 1.5; }
 .host-explanation { margin: -4px 0 12px; color: var(--text-muted); font-size: 9px; line-height: 1.5; }

+ 1 - 0
src/client/components/groups/types.ts

@@ -24,6 +24,7 @@ export type UiRoom = {
   name: string
   name: string
   description?: string
   description?: string
   instructions?: string
   instructions?: string
+  avatar?: string
   archived?: boolean
   archived?: boolean
   memberIds?: string[]
   memberIds?: string[]
   autoReply?: boolean
   autoReply?: boolean

+ 11 - 2
src/client/components/workspace/viewModels.ts

@@ -203,7 +203,11 @@ export function chatInteraction(approval?: ApprovalRequest, clarification?: Clar
   return null
   return null
 }
 }
 
 
-export function roomSidebarItem(room: GroupRoomSummary): SidebarItem {
+export function roomSidebarItem(
+  room: GroupRoomSummary,
+  avatars: Record<string, string> = {},
+  avatarsByName: Record<string, string> = {},
+): SidebarItem {
   return {
   return {
     id: room.id,
     id: room.id,
     title: room.name || '未命名团队',
     title: room.name || '未命名团队',
@@ -211,7 +215,11 @@ export function roomSidebarItem(room: GroupRoomSummary): SidebarItem {
     meta: formatRelative(room.updatedAt),
     meta: formatRelative(room.updatedAt),
     section: historySection(room.updatedAt),
     section: historySection(room.updatedAt),
     unread: room.unreadCount,
     unread: room.unreadCount,
-    icon: 'groups',
+    avatar: room.avatar || '',
+    avatarMembers: (room.avatarMembers || []).map(member => ({
+      name: member.displayName || member.profile,
+      avatar: member.nodeId === 'local' ? avatars[member.profile] || avatarsByName[member.displayName] : undefined,
+    })),
   }
   }
 }
 }
 
 
@@ -282,6 +290,7 @@ export function roomToUi(room: GroupRoomDetail): UiRoom {
     name: room.name,
     name: room.name,
     archived: room.archived,
     archived: room.archived,
     instructions: room.instructions,
     instructions: room.instructions,
+    avatar: room.avatar,
     memberIds: room.agents.map(agent => agent.id),
     memberIds: room.agents.map(agent => agent.id),
     replyRounds: room.maxReplyRounds,
     replyRounds: room.maxReplyRounds,
     orchestrationMode: room.orchestrationMode,
     orchestrationMode: room.orchestrationMode,

+ 4 - 3
src/client/stores/groups.ts

@@ -65,6 +65,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const hostProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 5)
   const hostProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 5)
   const hostFlowProtocol = computed(() => capabilities.value?.features?.includes('hostFlow') ?? false)
   const hostFlowProtocol = computed(() => capabilities.value?.features?.includes('hostFlow') ?? false)
   const roomInstructionsProtocol = computed(() => capabilities.value?.features?.includes('roomInstructions') ?? false)
   const roomInstructionsProtocol = computed(() => capabilities.value?.features?.includes('roomInstructions') ?? false)
+  const roomAvatarProtocol = computed(() => capabilities.value?.features?.includes('roomAvatar') ?? false)
   const activityProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 8)
   const activityProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 8)
   const topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
   const topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
   const selectedTopic = computed(() => selectedTopicId.value ? topics.value.find(topic => topic.id === selectedTopicId.value) : undefined)
   const selectedTopic = computed(() => selectedTopicId.value ? topics.value.find(topic => topic.id === selectedTopicId.value) : undefined)
@@ -449,7 +450,7 @@ export const useGroupsStore = defineStore('groups', () => {
 
 
   async function refresh(): Promise<void> { await rebuildSnapshot() }
   async function refresh(): Promise<void> { await rebuildSnapshot() }
 
 
-  async function createRoom(input: { name: string; cwd?: string; instructions?: string; agents: AgentSeed[]; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
+  async function createRoom(input: { name: string; cwd?: string; instructions?: string; avatar?: string; agents: AgentSeed[]; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
     const detail = await createApi(input)
     const detail = await createApi(input)
     protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
     protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
     selectedRoomId.value = detail.id
     selectedRoomId.value = detail.id
@@ -459,7 +460,7 @@ export const useGroupsStore = defineStore('groups', () => {
     return detail
     return detail
   }
   }
 
 
-  async function updateRoom(roomId: string, input: { name?: string; cwd?: string; instructions?: string; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
+  async function updateRoom(roomId: string, input: { name?: string; cwd?: string; instructions?: string; avatar?: string; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
     const detail = await updateRoomApi(roomId, input)
     const detail = await updateRoomApi(roomId, input)
     const topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
     const topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
     const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
     const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
@@ -754,7 +755,7 @@ export const useGroupsStore = defineStore('groups', () => {
   }
   }
 
 
   return {
   return {
-    availability, capabilities, topicProtocol, hostProtocol, hostFlowProtocol, roomInstructionsProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
+    availability, capabilities, topicProtocol, hostProtocol, hostFlowProtocol, roomInstructionsProtocol, roomAvatarProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
     messages, agents, pendingInteractions,
     messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     pinnedTopics, nodes, start, stop, refresh, refreshNodes, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, setTopicPinned, renameTopic, loadPinnedTopics, archivedRooms, archivedTopics, addAgent, updateAgent,
     pinnedTopics, nodes, start, stop, refresh, refreshNodes, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, setTopicPinned, renameTopic, loadPinnedTopics, archivedRooms, archivedTopics, addAgent, updateAgent,

+ 8 - 2
src/client/utils/normalize.ts

@@ -443,7 +443,13 @@ export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
   const rawAgents = source.agents
   const rawAgents = source.agents
   const orchestrationMode = string(pick(source, 'orchestrationMode', 'orchestration_mode'))
   const orchestrationMode = string(pick(source, 'orchestrationMode', 'orchestration_mode'))
   return {
   return {
-    id: string(source.id), name: string(source.name, '未命名团队'), cwd: string(source.cwd), instructions: string(source.instructions),
+    id: string(source.id), name: string(source.name, '未命名团队'), cwd: string(source.cwd), instructions: string(source.instructions), avatar: string(source.avatar),
+    avatarMembers: values(pick(source, 'avatarMembers', 'avatar_members')).flatMap(value => {
+      const member = record(value)
+      const profile = string(member.profile)
+      if (!profile) return []
+      return [{ profile, nodeId: string(pick(member, 'nodeId', 'node_id'), 'local'), displayName: string(pick(member, 'displayName', 'display_name'), profile) }]
+    }),
     createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
     createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
     archived: bool(source.archived), agentCount: Array.isArray(rawAgents) ? rawAgents.length : number(pick(source, 'agentCount', 'agent_count')),
     archived: bool(source.archived), agentCount: Array.isArray(rawAgents) ? rawAgents.length : number(pick(source, 'agentCount', 'agent_count')),
     lastMessage: source.lastMessage || source.last_message ? normalizeGroupMessage(pick(source, 'lastMessage', 'last_message')) : null,
     lastMessage: source.lastMessage || source.last_message ? normalizeGroupMessage(pick(source, 'lastMessage', 'last_message')) : null,
@@ -469,7 +475,7 @@ export function normalizeGroupRoomDetail(value: unknown): GroupRoomDetail {
   const source = record(value)
   const source = record(value)
   const summary = normalizeGroupRoom(source)
   const summary = normalizeGroupRoom(source)
   return {
   return {
-    id: summary.id, name: summary.name, cwd: summary.cwd, instructions: summary.instructions, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
+    id: summary.id, name: summary.name, cwd: summary.cwd, instructions: summary.instructions, avatar: summary.avatar, avatarMembers: summary.avatarMembers, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
     archived: summary.archived, maxReplyRounds: summary.maxReplyRounds, orchestrationMode: summary.orchestrationMode,
     archived: summary.archived, maxReplyRounds: summary.maxReplyRounds, orchestrationMode: summary.orchestrationMode,
     agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(normalizeGroupRun),
     agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(normalizeGroupRun),
     pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),
     pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),

+ 42 - 0
src/client/utils/teamAvatar.ts

@@ -0,0 +1,42 @@
+const SUPPORTED_TEAM_AVATAR_TYPES = new Set([
+  'image/png',
+  'image/jpeg',
+  'image/webp',
+])
+
+function readImage(file: File): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => typeof reader.result === 'string'
+      ? resolve(reader.result)
+      : reject(new Error('无法读取图片'))
+    reader.onerror = () => reject(new Error('无法读取图片'))
+    reader.readAsDataURL(file)
+  })
+}
+
+function resizeAvatar(source: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const image = new Image()
+    image.onload = () => {
+      const canvas = document.createElement('canvas')
+      canvas.width = 256
+      canvas.height = 256
+      const context = canvas.getContext('2d')
+      if (!context) return reject(new Error('当前浏览器不支持图片处理'))
+      const edge = Math.min(image.width, image.height)
+      context.drawImage(image, (image.width - edge) / 2, (image.height - edge) / 2, edge, edge, 0, 0, 256, 256)
+      resolve(canvas.toDataURL('image/png'))
+    }
+    image.onerror = () => reject(new Error('请选择有效的 PNG、JPEG 或 WebP 图片'))
+    image.src = source
+  })
+}
+
+export async function processTeamAvatarFile(file: File): Promise<string> {
+  if (!SUPPORTED_TEAM_AVATAR_TYPES.has(file.type)) {
+    throw new Error('请选择 PNG、JPEG 或 WebP 图片')
+  }
+  if (file.size > 10 * 1024 * 1024) throw new Error('图片不能超过 10 MB')
+  return resizeAvatar(await readImage(file))
+}

+ 20 - 5
src/client/views/GroupsView.vue

@@ -56,7 +56,16 @@ const archivedRoomList = ref<GroupRoomSummary[]>([])
 const archivedTopicList = ref<GroupTopicSummary[]>([])
 const archivedTopicList = ref<GroupTopicSummary[]>([])
 
 
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
-const roomSidebarItems = computed(() => activeRooms.value.map(roomSidebarItem))
+const roomSidebarItems = computed(() => activeRooms.value.map(room => {
+  const currentMembers = room.id === groups.selectedRoomId && displayAgents.value.length
+    ? displayAgents.value.slice(0, 4).map(agent => ({
+      profile: agent.profile,
+      nodeId: agent.nodeId,
+      displayName: agent.displayName,
+    }))
+    : room.avatarMembers
+  return roomSidebarItem({ ...room, avatarMembers: currentMembers }, agentAvatars.value, agentAvatarsByName.value)
+}))
 const pinnedTopics = computed(() => groups.pinnedTopics.filter(topic => !topic.archived).sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id)))
 const pinnedTopics = computed(() => groups.pinnedTopics.filter(topic => !topic.archived).sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id)))
 const topicListRoom = computed(() => topicListRoomId.value ? activeRooms.value.find(room => room.id === topicListRoomId.value) : undefined)
 const topicListRoom = computed(() => topicListRoomId.value ? activeRooms.value.find(room => room.id === topicListRoomId.value) : undefined)
 const sidebarSubtitle = computed(() => topicListRoom.value
 const sidebarSubtitle = computed(() => topicListRoom.value
@@ -81,7 +90,7 @@ function topicTimeSection(updatedAt: number): string {
 }
 }
 const sidebarItems = computed<SidebarItem[]>(() => {
 const sidebarItems = computed<SidebarItem[]>(() => {
   const room = topicListRoom.value
   const room = topicListRoom.value
-  if (!room) return activeRooms.value.map(item => ({ ...roomSidebarItem(item), section: undefined }))
+  if (!room) return roomSidebarItems.value.map(item => ({ ...item, section: undefined }))
   const items: SidebarItem[] = [{
   const items: SidebarItem[] = [{
     id: GROUP_LIST_BACK_ID,
     id: GROUP_LIST_BACK_ID,
     title: '返回团队列表',
     title: '返回团队列表',
@@ -147,6 +156,9 @@ const agents = computed(() => displayAgents.value.map(agentToUi))
 const agentAvatars = computed(() => Object.fromEntries(auth.profiles.flatMap(profile =>
 const agentAvatars = computed(() => Object.fromEntries(auth.profiles.flatMap(profile =>
   profile.agentAvatar ? [[profile.name, profile.agentAvatar] as const] : []
   profile.agentAvatar ? [[profile.name, profile.agentAvatar] as const] : []
 )))
 )))
+const agentAvatarsByName = computed(() => Object.fromEntries(auth.profiles.flatMap(profile =>
+  profile.agentAvatar ? [[profile.agentName || profile.displayName || profile.name, profile.agentAvatar] as const] : []
+)))
 function serverAddress(value: string): string {
 function serverAddress(value: string): string {
   try { return new URL(value).host }
   try { return new URL(value).host }
   catch { return value.replace(/^https?:\/\//i, '').replace(/\/.*$/, '') }
   catch { return value.replace(/^https?:\/\//i, '').replace(/\/.*$/, '') }
@@ -378,10 +390,11 @@ async function startTopic(roomId: string) {
   } catch { /* store publishes the error */ }
   } catch { /* store publishes the error */ }
 }
 }
 
 
-async function createRoom(payload: { name: string; members: GroupProfileOption[]; autoReply: boolean; replyRounds: number; instructions?: string; hostProfile?: string; orchestrationMode?: 'free' | 'host' }) {
+async function createRoom(payload: { name: string; avatar?: string; members: GroupProfileOption[]; autoReply: boolean; replyRounds: number; instructions?: string; hostProfile?: string; orchestrationMode?: 'free' | 'host' }) {
   const hostProfile = payload.hostProfile || payload.members[0]?.id
   const hostProfile = payload.hostProfile || payload.members[0]?.id
   const detail = await groups.createRoom({
   const detail = await groups.createRoom({
     name: payload.name,
     name: payload.name,
+    ...(groups.roomAvatarProtocol ? { avatar: payload.avatar ?? '' } : {}),
     ...(groups.roomInstructionsProtocol ? { instructions: payload.instructions ?? '' } : {}),
     ...(groups.roomInstructionsProtocol ? { instructions: payload.instructions ?? '' } : {}),
     agents: payload.members.map(member => ({
     agents: payload.members.map(member => ({
       profile: member.profile,
       profile: member.profile,
@@ -407,11 +420,12 @@ async function send(payload: ComposerSubmit) {
   } catch { /* store publishes the error */ }
   } catch { /* store publishes the error */ }
 }
 }
 
 
-async function updateRoom(patch: { name?: string; instructions?: string; replyRounds?: number; orchestrationMode?: 'free' | 'host' }) {
+async function updateRoom(patch: { name?: string; instructions?: string; avatar?: string; replyRounds?: number; orchestrationMode?: 'free' | 'host' }) {
   if (!groups.selectedRoom) return
   if (!groups.selectedRoom) return
   await groups.updateRoom(groups.selectedRoom.id, {
   await groups.updateRoom(groups.selectedRoom.id, {
     name: patch.name,
     name: patch.name,
     ...(groups.roomInstructionsProtocol ? { instructions: patch.instructions } : {}),
     ...(groups.roomInstructionsProtocol ? { instructions: patch.instructions } : {}),
+    ...(groups.roomAvatarProtocol ? { avatar: patch.avatar } : {}),
     maxReplyRounds: patch.replyRounds,
     maxReplyRounds: patch.replyRounds,
     ...(groups.hostFlowProtocol ? { orchestrationMode: patch.orchestrationMode } : {}),
     ...(groups.hostFlowProtocol ? { orchestrationMode: patch.orchestrationMode } : {}),
   })
   })
@@ -710,6 +724,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :host-enabled="groups.hostProtocol"
         :host-enabled="groups.hostProtocol"
         :host-flow-enabled="groups.hostFlowProtocol"
         :host-flow-enabled="groups.hostFlowProtocol"
         :room-instructions-enabled="groups.roomInstructionsProtocol"
         :room-instructions-enabled="groups.roomInstructionsProtocol"
+        :avatar-enabled="groups.roomAvatarProtocol"
         :available-profiles="availableProfiles"
         :available-profiles="availableProfiles"
         :busy="managerBusy"
         :busy="managerBusy"
         :model-options-by-profile="modelOptionsByProfile"
         :model-options-by-profile="modelOptionsByProfile"
@@ -732,7 +747,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
   </WorkspaceView>
   </WorkspaceView>
 
 
   <FloatingResourceSearch section="groups" label="搜索团队" :items="roomSidebarItems" @select="selectRoom" />
   <FloatingResourceSearch section="groups" label="搜索团队" :items="roomSidebarItems" @select="selectRoom" />
-  <CreateGroupDialog :open="createOpen" :profiles="profiles" :host-enabled="groups.hostProtocol" :host-flow-enabled="groups.hostFlowProtocol" :room-instructions-enabled="groups.roomInstructionsProtocol" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
+  <CreateGroupDialog :open="createOpen" :profiles="profiles" :avatar-enabled="groups.roomAvatarProtocol" :host-enabled="groups.hostProtocol" :host-flow-enabled="groups.hostFlowProtocol" :room-instructions-enabled="groups.roomInstructionsProtocol" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
 
 

+ 9 - 1
src/shared/types.ts

@@ -242,7 +242,7 @@ export interface GroupLimits {
 }
 }
 
 
 export const MIN_SUPPORTED_GROUP_PROTOCOL_VERSION = 2
 export const MIN_SUPPORTED_GROUP_PROTOCOL_VERSION = 2
-export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 11
+export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 12
 export const SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL = `v${MIN_SUPPORTED_GROUP_PROTOCOL_VERSION}–v${MAX_SUPPORTED_GROUP_PROTOCOL_VERSION}`
 export const SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL = `v${MIN_SUPPORTED_GROUP_PROTOCOL_VERSION}–v${MAX_SUPPORTED_GROUP_PROTOCOL_VERSION}`
 
 
 export function isSupportedGroupProtocolVersion(value: number): boolean {
 export function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -364,6 +364,8 @@ export interface GroupRoomSummary {
   name: string
   name: string
   cwd: string
   cwd: string
   instructions?: string
   instructions?: string
+  avatar?: string
+  avatarMembers?: GroupRoomAvatarMember[]
   createdAt: number
   createdAt: number
   updatedAt: number
   updatedAt: number
   archived: boolean
   archived: boolean
@@ -375,6 +377,12 @@ export interface GroupRoomSummary {
   orchestrationMode?: 'free' | 'host'
   orchestrationMode?: 'free' | 'host'
 }
 }
 
 
+export interface GroupRoomAvatarMember {
+  profile: string
+  nodeId: string
+  displayName: string
+}
+
 export interface GroupRoomDetail extends Omit<GroupRoomSummary, 'agentCount' | 'lastMessage' | 'unreadCount'> {
 export interface GroupRoomDetail extends Omit<GroupRoomSummary, 'agentCount' | 'lastMessage' | 'unreadCount'> {
   agents: GroupAgent[]
   agents: GroupAgent[]
   runs: GroupRun[]
   runs: GroupRun[]

+ 41 - 2
tests/client/groupHost.test.ts

@@ -1,10 +1,10 @@
 import { mount } from '@vue/test-utils'
 import { mount } from '@vue/test-utils'
 import { nextTick } from 'vue'
 import { nextTick } from 'vue'
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
-import type { GroupAgent, GroupMessage } from '@shared/types'
+import type { GroupAgent, GroupMessage, GroupRoomSummary } from '@shared/types'
 import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
-import { groupMessageToUi } from '@/components/workspace/viewModels'
+import { groupMessageToUi, roomSidebarItem } from '@/components/workspace/viewModels'
 
 
 const profiles = (...names: string[]) => names.map(name => ({
 const profiles = (...names: string[]) => names.map(name => ({
   id: `local|${name}`, profile: name, displayName: name, nodeId: 'local', nodeLabel: '当前 Hermes',
   id: `local|${name}`, profile: name, displayName: name, nodeId: 'local', nodeLabel: '当前 Hermes',
@@ -20,6 +20,22 @@ function agent(id: string, profile: string, displayName: string, isHost: boolean
 }
 }
 
 
 describe('group host controls', () => {
 describe('group host controls', () => {
+  it('resolves a local team avatar by display name when an old room stored an internal Profile alias', () => {
+    const room = {
+      id: 'room-1', name: '头像团队', cwd: '', instructions: '', avatar: '', createdAt: 1, updatedAt: 1,
+      archived: false, agentCount: 2, lastMessage: null, unreadCount: 0, activeRunCount: 0, maxReplyRounds: 3, orchestrationMode: 'free',
+      avatarMembers: [
+        { profile: 'legacy:default', nodeId: 'local', displayName: '丫头' },
+        { profile: 'default', nodeId: 'remote-node', displayName: '远端丫头' },
+      ],
+    } satisfies GroupRoomSummary
+    const item = roomSidebarItem(room, { default: 'data:image/png;base64,LOCAL==' }, { 丫头: 'data:image/png;base64,LOCAL==' })
+    expect(item.avatarMembers).toEqual([
+      { name: '丫头', avatar: 'data:image/png;base64,LOCAL==' },
+      { name: '远端丫头', avatar: undefined },
+    ])
+  })
+
   it('keeps same-name Profiles distinct across paired Hermes nodes', async () => {
   it('keeps same-name Profiles distinct across paired Hermes nodes', async () => {
     const local = profiles('default')[0]!
     const local = profiles('default')[0]!
     const remote = {
     const remote = {
@@ -132,6 +148,29 @@ describe('group host controls', () => {
     wrapper.unmount()
     wrapper.unmount()
   })
   })
 
 
+  it('shows and submits an automatic team avatar from selected Agents', async () => {
+    const choices = profiles('yaoyao', 'yaoer').map((profile, index) => ({
+      ...profile,
+      ...(index === 0 ? { avatar: 'data:image/png;base64,AA==' } : {}),
+    }))
+    const wrapper = mount(CreateGroupDialog, {
+      attachTo: document.body,
+      props: { open: true, profiles: choices, avatarEnabled: true },
+      global: { stubs: { teleport: true } },
+    })
+    await wrapper.get('input[placeholder="例如:产品评审"]').setValue('头像团队')
+    await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
+    expect(wrapper.findAll('.avatar-picker .team-avatar__member')).toHaveLength(2)
+    expect(wrapper.get('.avatar-picker').text()).toContain('自动组合已选 Agent 的头像')
+    await wrapper.get('.solid-button').trigger('click')
+    expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
+      name: '头像团队',
+      avatar: '',
+      members: choices,
+    })
+    wrapper.unmount()
+  })
+
   it('emits one host promotion and explains the independent auto-reply setting', async () => {
   it('emits one host promotion and explains the independent auto-reply setting', async () => {
     const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
     const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
     const second = agent('agent-2', 'yaoer', '瑶儿', false, false)
     const second = agent('agent-2', 'yaoer', '瑶儿', false, false)

+ 3 - 2
tests/client/groupProtocol.test.ts

@@ -168,7 +168,8 @@ describe('group protocol continuity', () => {
     expect(isSupportedGroupProtocolVersion(8)).toBe(true)
     expect(isSupportedGroupProtocolVersion(8)).toBe(true)
     expect(isSupportedGroupProtocolVersion(10)).toBe(true)
     expect(isSupportedGroupProtocolVersion(10)).toBe(true)
     expect(isSupportedGroupProtocolVersion(11)).toBe(true)
     expect(isSupportedGroupProtocolVersion(11)).toBe(true)
-    expect(isSupportedGroupProtocolVersion(12)).toBe(false)
-    expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v11')
+    expect(isSupportedGroupProtocolVersion(12)).toBe(true)
+    expect(isSupportedGroupProtocolVersion(13)).toBe(false)
+    expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v12')
   })
   })
 })
 })

+ 52 - 0
tests/e2e/workspace.spec.ts

@@ -118,6 +118,58 @@ test('uses a floating search dialog for group rooms', async ({ page }) => {
   await expect(searchDialog).toBeHidden()
   await expect(searchDialog).toBeHidden()
 })
 })
 
 
+test('shows the team avatar in the list and automatic avatar controls during creation', async ({ page }) => {
+  await page.getByRole('button', { name: '团队' }).click()
+
+  const roomRow = page.locator('[data-sidebar-id="22222222-2222-4222-8222-222222222222"]')
+  const roomAvatar = roomRow.getByRole('img', { name: '设计与工程协作团队头像' })
+  await expect(roomAvatar).toBeVisible()
+  await expect(roomAvatar.locator('.team-avatar__member')).toHaveCount(2)
+  await expect(roomAvatar).toContainText('夭')
+  await expect(roomAvatar).toContainText('瑶')
+  const avatarGeometry = await roomAvatar.evaluate(element => {
+    const outer = element.getBoundingClientRect()
+    return [...element.querySelectorAll<HTMLElement>('.team-avatar__member')].map(member => {
+      const rect = member.getBoundingClientRect()
+      return { width: rect.width, height: rect.height, outerWidth: outer.width, radius: getComputedStyle(member).borderRadius }
+    })
+  })
+  expect(avatarGeometry).toHaveLength(2)
+  for (const member of avatarGeometry) {
+    expect(member.width).toBeLessThan(member.outerWidth * .75)
+    expect(member.height).toBe(member.width)
+    expect(member.radius).toBe('50%')
+  }
+
+  await page.getByRole('button', { name: '新建团队' }).click()
+  const dialog = page.getByRole('dialog', { name: '新建团队' })
+  await expect(dialog.getByText('自动组合已选 Agent 的头像')).toBeVisible()
+  await expect(dialog.getByRole('button', { name: '上传图片' })).toBeVisible()
+  await expect(dialog.getByRole('button', { name: '自动生成' })).toBeVisible()
+  await dialog.locator('input[type="file"]').setInputFiles('public/brand/AppIcon-1024.png')
+  await expect(dialog.getByText('使用上传的图片')).toBeVisible()
+  await expect(dialog.getByRole('img', { name: '团队团队头像' }).locator('img')).toBeVisible()
+})
+
+test('updates an existing team avatar from the Web manager', async ({ page }) => {
+  await page.getByRole('button', { name: '团队' }).click()
+  await page.getByRole('button', { name: '管理团队' }).click()
+
+  const manager = page.locator('.group-manager')
+  const preview = manager.getByRole('img', { name: '设计与工程协作团队头像' })
+  await expect(preview).toHaveCSS('border-radius', '50%')
+  const uploadRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/rooms/22222222-2222-4222-8222-222222222222'))
+  await manager.locator('input[type="file"]').setInputFiles('public/brand/AppIcon-1024.png')
+  expect((await uploadRequest).postDataJSON().avatar).toMatch(/^data:image\/png;base64,/)
+  await expect(manager.getByText('使用上传的图片')).toBeVisible()
+  await expect(preview.locator('img')).toBeVisible()
+
+  const automaticRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/rooms/22222222-2222-4222-8222-222222222222'))
+  await manager.getByRole('button', { name: '自动生成' }).click()
+  expect((await automaticRequest).postDataJSON()).toMatchObject({ avatar: '' })
+  await expect(manager.getByText('自动组合当前 Agent 头像')).toBeVisible()
+})
+
 test('selects one protocol v5 host independently from no-mention replies', async ({ page }) => {
 test('selects one protocol v5 host independently from no-mention replies', async ({ page }) => {
   await page.getByRole('button', { name: '团队' }).click()
   await page.getByRole('button', { name: '团队' }).click()
   await expect(page.locator('.group-host-chip')).toHaveText('主持人 夭夭')
   await expect(page.locator('.group-host-chip')).toHaveText('主持人 夭夭')

+ 13 - 3
tests/fixtures/fake-hermes.mjs

@@ -82,7 +82,8 @@ const groupTopics = [
   { id: releaseTopicId, roomId, title: '发布检查', preview: releaseTopicMessage.content, messageCount: 1, unreadCount: 0, latestMessageSeq: 3, createdAt: now() - 500, updatedAt: now() - 500 },
   { id: releaseTopicId, roomId, title: '发布检查', preview: releaseTopicMessage.content, messageCount: 1, unreadCount: 0, latestMessageSeq: 3, createdAt: now() - 500, updatedAt: now() - 500 },
 ]
 ]
 let nextGroupSeq = 4
 let nextGroupSeq = 4
-const room = { id: roomId, name: '设计与工程协作', cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: groupAgents.length, maxReplyRounds: -1, lastMessage: groupAssistantMessage, unreadCount: 1, activeRunCount: 0 }
+const avatarMembers = groupAgents.map(agent => ({ profile: agent.profile, nodeId: 'local', displayName: agent.displayName }))
+const room = { id: roomId, name: '设计与工程协作', avatar: '', avatarMembers, cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: groupAgents.length, maxReplyRounds: -1, lastMessage: groupAssistantMessage, unreadCount: 1, activeRunCount: 0 }
 const rpcRequests = []
 const rpcRequests = []
 const previewPdf = Buffer.from('JVBERi0xLjQKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCAyMDAgMjAwXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDA1MyAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTY2CiUlRU9G', 'base64')
 const previewPdf = Buffer.from('JVBERi0xLjQKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCAyMDAgMjAwXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDA1MyAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTY2CiUlRU9G', 'base64')
 
 
@@ -143,10 +144,19 @@ const server = createServer(async (request, response) => {
     return session ? json(response, 200, session) : json(response, 404, { detail: 'Not found' })
     return session ? json(response, 200, session) : json(response, 404, { detail: 'Not found' })
   }
   }
   if (url.pathname === '/api/session-unread') return json(response, 200, { items: { 'session-demo': 0 } })
   if (url.pathname === '/api/session-unread') return json(response, 200, { items: { 'session-demo': 0 } })
-  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 8, journalEpoch: epoch, latestCursor: groupCursor, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'topic.updated', 'room.activity', 'run.updated', 'agent.status', 'agent.updated'] })
+  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 12, journalEpoch: epoch, latestCursor: groupCursor, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100, maxRoomAvatarLength: 524288 }, features: ['roomAvatar'], eventTypes: ['message.upsert', 'topic.updated', 'room.activity', 'run.updated', 'agent.status', 'agent.updated'] })
   if (url.pathname === '/api/plugins/yaoyao/v1/topics/pinned') return json(response, 200, { items: groupTopics.filter(topic => topic.pinned), nextCursor: null })
   if (url.pathname === '/api/plugins/yaoyao/v1/topics/pinned') return json(response, 200, { items: groupTopics.filter(topic => topic.pinned), nextCursor: null })
   if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
   if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
-  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: groupAgents, runs: [], pendingInteractions: [], latestCursor: groupCursor })
+  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) {
+    if (request.method === 'PATCH') {
+      const input = await body(request)
+      for (const key of ['name', 'cwd', 'instructions', 'avatar', 'maxReplyRounds', 'orchestrationMode']) {
+        if (Object.prototype.hasOwnProperty.call(input, key)) room[key] = input[key]
+      }
+      room.updatedAt = now()
+    }
+    return json(response, 200, { ...room, agents: groupAgents, runs: [], pendingInteractions: [], latestCursor: groupCursor })
+  }
   const agentPatchMatch = new RegExp(`^/api/plugins/yaoyao/v1/rooms/${roomId}/agents/([^/]+)$`).exec(url.pathname)
   const agentPatchMatch = new RegExp(`^/api/plugins/yaoyao/v1/rooms/${roomId}/agents/([^/]+)$`).exec(url.pathname)
   if (agentPatchMatch && request.method === 'PATCH') {
   if (agentPatchMatch && request.method === 'PATCH') {
     const target = groupAgents.find(agent => agent.id === decodeURIComponent(agentPatchMatch[1]))
     const target = groupAgents.find(agent => agent.id === decodeURIComponent(agentPatchMatch[1]))