Просмотр исходного кода

Add custom room avatar support

LSamien 2 месяцев назад
Родитель
Сommit
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)
     if "instructions" not in request.model_fields_set:
         command.pop("instructions", None)
+    if "avatar" not in request.model_fields_set:
+        command.pop("avatar", None)
     agents = command.get("agents")
     if isinstance(agents, list):
         for seed, item in zip(request.agents, agents):
@@ -1135,7 +1137,7 @@ async def capabilities() -> dict[str, object]:
         "limits": limits_payload(),
         "eventTypes": sorted(EVENT_TYPES),
         "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
 
+import base64
+import binascii
+import re
 import unicodedata
 from pathlib import Path
 from typing import Any, Literal
@@ -18,7 +21,7 @@ from pydantic import (
 )
 
 
-PROTOCOL_VERSION = 11
+PROTOCOL_VERSION = 12
 MAX_AGENTS_PER_ROOM = 8
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
@@ -32,6 +35,7 @@ UNLIMITED_REPLY_ROUNDS = -1
 MAX_FINITE_REPLY_ROUNDS = 100
 MAX_AGENT_DISPLAY_NAME_LENGTH = 100
 MAX_ROOM_INSTRUCTIONS_LENGTH = 4_000
+MAX_ROOM_AVATAR_LENGTH = 512 * 1024
 MAX_ROOM_CONCURRENCY = 3
 MAX_PLUGIN_CONCURRENCY = 4
 INITIAL_CONTEXT_MESSAGE_LIMIT = 50
@@ -40,6 +44,10 @@ REASONING_EFFORTS = frozenset({
     "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"
 })
 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
 # 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,
         "maxAgentDisplayNameLength": MAX_AGENT_DISPLAY_NAME_LENGTH,
         "maxRoomInstructionsLength": MAX_ROOM_INSTRUCTIONS_LENGTH,
+        "maxRoomAvatarLength": MAX_ROOM_AVATAR_LENGTH,
         "maxRoomConcurrency": MAX_ROOM_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):
     """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)
     cwd: str = Field(default="", max_length=4096)
     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(
         default=DEFAULT_MAX_REPLY_ROUNDS, alias="maxReplyRounds"
     )
@@ -168,6 +199,11 @@ class CreateRoomRequest(GroupModel):
     def normalize_instructions(cls, value: str) -> str:
         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")
     def validate_unique_host(self) -> "CreateRoomRequest":
         if sum(agent.is_host for agent in self.agents) > 1:
@@ -182,13 +218,14 @@ class UpdateRoomRequest(GroupModel):
     instructions: str | None = Field(
         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")
     orchestration_mode: Literal["free", "host"] | None = Field(
         default=None, alias="orchestrationMode"
     )
 
     @field_validator(
-        "name", "cwd", "instructions", "max_reply_rounds", "orchestration_mode",
+        "name", "cwd", "instructions", "avatar", "max_reply_rounds", "orchestration_mode",
         mode="before"
     )
     @classmethod
@@ -200,11 +237,11 @@ class UpdateRoomRequest(GroupModel):
     @model_validator(mode="after")
     def require_change(self) -> "UpdateRoomRequest":
         if not (
-            {"name", "cwd", "instructions", "max_reply_rounds", "orchestration_mode"}
+            {"name", "cwd", "instructions", "avatar", "max_reply_rounds", "orchestration_mode"}
             & self.model_fields_set
         ):
             raise ValueError(
-                "Room update requires name, cwd, instructions, maxReplyRounds, or orchestrationMode"
+                "Room update requires name, cwd, instructions, avatar, maxReplyRounds, or orchestrationMode"
             )
         return self
 
@@ -213,6 +250,11 @@ class UpdateRoomRequest(GroupModel):
     def normalize_instructions(cls, value: str | None) -> str | None:
         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")
     @classmethod
     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_room_cwd,
     normalize_room_instructions,
+    normalize_room_avatar,
     normalize_room_name,
 )
 
 
-SCHEMA_VERSION = 13
+SCHEMA_VERSION = 14
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
@@ -116,6 +117,7 @@ _SCHEMA_STATEMENTS = (
         name TEXT NOT NULL,
         cwd TEXT NOT NULL DEFAULT '',
         instructions TEXT NOT NULL DEFAULT '',
+        avatar_data_url TEXT NOT NULL DEFAULT '',
         max_reply_rounds INTEGER NOT NULL DEFAULT 3,
         orchestration_mode TEXT NOT NULL DEFAULT 'free',
         created_at REAL NOT NULL,
@@ -505,6 +507,9 @@ class GroupStore:
             raw_version = "12"
         if raw_version == "12":
             self._migrate_v12_to_v13(connection)
+            raw_version = "13"
+        if raw_version == "13":
+            self._migrate_v13_to_v14(connection)
         self._validated_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'"
         )
 
+    @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
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
             "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_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"},
@@ -1292,6 +1312,7 @@ class GroupStore:
             ("group_rooms", "max_reply_rounds"): ("INTEGER", 1, "3"),
             ("group_rooms", "orchestration_mode"): ("TEXT", 1, "'free'"),
             ("group_rooms", "instructions"): ("TEXT", 1, "''"),
+            ("group_rooms", "avatar_data_url"): ("TEXT", 1, "''"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
             ("group_agents", "is_host"): ("INTEGER", 1, "0"),
             ("group_agents", "node_id"): ("TEXT", 1, "'local'"),
@@ -1749,6 +1770,7 @@ class GroupStore:
                 "name",
                 "cwd",
                 "instructions",
+                "avatar",
                 "maxReplyRounds",
                 "orchestrationMode",
                 "agents",
@@ -1762,6 +1784,7 @@ class GroupStore:
             instructions = normalize_room_instructions(
                 command.get("instructions", "")
             )
+            avatar = normalize_room_avatar(command.get("avatar", ""))
             max_reply_rounds = normalize_max_reply_rounds(
                 command.get("maxReplyRounds", DEFAULT_MAX_REPLY_ROUNDS)
             )
@@ -1773,11 +1796,11 @@ class GroupStore:
             room_id = str(uuid.uuid4())
             connection.execute(
                 """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)
-                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,
                 ),
             )
@@ -1853,7 +1876,7 @@ class GroupStore:
         ):
             raise ValueError("limit must be an integer from 1 to 100")
         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,
             (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
@@ -1868,7 +1891,9 @@ class GroupStore:
             rows = connection.execute(query, params).fetchall()
             items = []
             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(
                     connection, str(row["id"])
                 )
@@ -2005,7 +2030,7 @@ class GroupStore:
         command = self._command(
             command,
             {
-                "requestId", "name", "cwd", "instructions",
+                "requestId", "name", "cwd", "instructions", "avatar",
                 "maxReplyRounds", "orchestrationMode",
             },
         )
@@ -2013,13 +2038,13 @@ class GroupStore:
         changes = {
             key
             for key in (
-                "name", "cwd", "instructions", "maxReplyRounds", "orchestrationMode"
+                "name", "cwd", "instructions", "avatar", "maxReplyRounds", "orchestrationMode"
             )
             if key in command
         }
         if not changes:
             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]:
@@ -2035,6 +2060,9 @@ class GroupStore:
             if "instructions" in changes:
                 assignments.append("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:
                 assignments.append("max_reply_rounds = ?")
                 values.append(normalize_max_reply_rounds(command["maxReplyRounds"]))
@@ -5872,7 +5900,7 @@ class GroupStore:
         created_at: float,
     ) -> None:
         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,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id)
                 AS agent_count,
@@ -5887,7 +5915,9 @@ class GroupStore:
             connection,
             room_id,
             "room.updated",
-            self._room_summary(room),
+            self._room_summary(
+                room, self._room_avatar_members(connection, room_id)
+            ),
             created_at=created_at,
         )
 
@@ -5905,6 +5935,9 @@ class GroupStore:
         ).fetchall()
         result = self._room_wire(room)
         result["agents"] = [self._agent_wire(agent) for agent in agents]
+        result["avatarMembers"] = [
+            self._avatar_member_wire(agent) for agent in agents[:4]
+        ]
         return result
 
     def prepare_run_session_configuration(
@@ -6905,6 +6938,7 @@ class GroupStore:
             "name": row["name"],
             "cwd": row["cwd"],
             "instructions": row["instructions"],
+            "avatar": row["avatar_data_url"],
             "maxReplyRounds": row["max_reply_rounds"],
             "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],
@@ -6913,12 +6947,17 @@ class GroupStore:
         }
 
     @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 {
             "id": row["id"],
             "name": row["name"],
             "cwd": row["cwd"],
             "instructions": row["instructions"],
+            "avatar": row["avatar_data_url"],
+            "avatarMembers": avatar_members or [],
             "maxReplyRounds": row["max_reply_rounds"],
             "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],
@@ -6928,6 +6967,24 @@ class GroupStore:
             "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
     def _topic_summary(row: sqlite3.Row) -> dict[str, object]:
         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):
     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({
             "requestId": new_id(),
             "name": "兼容群",
@@ -496,7 +496,7 @@ class GroupHostContractTests(unittest.TestCase):
 
             migrated = GroupStore(path)
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 13)
+            self.assertEqual(migrated.schema_version(), 14)
             self.assertTrue(
                 all(
                     room["orchestrationMode"] == "free"
@@ -881,7 +881,7 @@ class GroupHostContractTests(unittest.TestCase):
 
             migrated = GroupStore(path)
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 13)
+            self.assertEqual(migrated.schema_version(), 14)
             self.assertEqual(
                 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[node_id]["profile"], "default")
             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:
                 stored = connection.execute(
                     "SELECT profile, execution_profile FROM group_agents "
@@ -391,7 +400,7 @@ class GroupSettingsContractTests(unittest.TestCase):
 
             migrated = GroupStore(path)
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 13)
+            self.assertEqual(migrated.schema_version(), 14)
             self.assertEqual(migrated.get_room(room["id"])["instructions"], "")
 
     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:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 11)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 12)
         self.assertEqual(
             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:
         model_payloads = (
             (
@@ -1070,7 +1129,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             self._create_v1_database(path)
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 13)
+            self.assertEqual(store.schema_version(), 14)
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             with store.connection() as connection:
                 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):
     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("room.activity", PROTOCOL.EVENT_TYPES)
         request = PROTOCOL.SendMessageRequest.model_validate({
@@ -268,7 +268,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             migrated = GroupStore(path)
             migrated.initialize()
 
-            self.assertEqual(migrated.schema_version(), 13)
+            self.assertEqual(migrated.schema_version(), 14)
             with migrated.connection() as connection:
                 topic = connection.execute(
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
@@ -1504,7 +1504,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 13)
+            self.assertEqual(store.schema_version(), 14)
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
@@ -1534,6 +1534,7 @@ class GroupTopicsContractTests(unittest.TestCase):
                     **expected_room,
                     "orchestration_mode": "free",
                     "instructions": "",
+                    "avatar_data_url": "",
                 }
             )
             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
 }
 
-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`, {
     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)}`, {
     method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
   })))

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

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import TeamAvatar from '@/components/common/TeamAvatar.vue'
 import type { SidebarItem } from './types'
 
 const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
@@ -148,7 +149,8 @@ defineExpose({ focusSearch })
               <AppIcon name="chevron-down" :size="13" />
             </button>
             <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>
               <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />
             </span>

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

@@ -14,4 +14,6 @@ export type SidebarItem = {
   expanded?: boolean
   status?: 'online' | 'working' | 'offline'
   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 AppIcon from '@/components/common/AppIcon.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'
 
 interface CreateGroupPayload {
   name: string
+  avatar?: string
   members: GroupProfileOption[]
   autoReply: boolean
   replyRounds: number
@@ -14,7 +17,8 @@ interface CreateGroupPayload {
   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,
   hostFlowEnabled: false,
   roomInstructionsEnabled: false,
@@ -29,6 +33,11 @@ const hostProfile = ref('')
 const autoReply = ref(true)
 const replyRounds = ref(3)
 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
   && selected.value.length >= 1
   && selected.value.length <= 8
@@ -43,6 +52,9 @@ watch(() => props.open, open => {
   autoReply.value = true
   replyRounds.value = 3
   orchestrationMode.value = 'free'
+  avatarMode.value = 'auto'
+  avatarDataURL.value = ''
+  avatarError.value = ''
 }, { immediate: true })
 
 function toggle(profile: GroupProfileOption) {
@@ -51,14 +63,29 @@ function toggle(profile: GroupProfileOption) {
   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() {
   if (!valid.value) return
   const payload: CreateGroupPayload = {
     name: name.value.trim(),
-    members: selected.value.flatMap(id => props.profiles.find(profile => profile.id === id) ?? []),
+    members: selectedMembers.value,
     autoReply: autoReply.value,
     replyRounds: replyRounds.value,
   }
+  if (props.avatarEnabled) payload.avatar = avatarMode.value === 'upload' ? avatarDataURL.value : ''
   if (props.hostEnabled) payload.hostProfile = hostProfile.value
   if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
   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">
           <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>
+          <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>
           <div class="agent-picker">
             <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); }
 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; }
+.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; }
 .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; }

+ 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 AppIcon from '@/components/common/AppIcon.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 { GroupProfileOption } from './types'
 
@@ -39,6 +41,7 @@ const props = withDefaults(defineProps<{
   hostEnabled?: boolean
   hostFlowEnabled?: boolean
   roomInstructionsEnabled?: boolean
+  avatarEnabled?: boolean
   availableProfiles?: GroupProfileOption[]
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
@@ -48,7 +51,7 @@ const props = withDefaults(defineProps<{
   agentAvatars?: Record<string, string>
   managerError?: string
   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<{
   updateRoom: [patch: Partial<UiRoom>]
@@ -64,15 +67,22 @@ const emit = defineEmits<{
 const form = reactive({
   name: props.room.name,
   instructions: props.room.instructions ?? '',
+  avatar: props.room.avatar ?? '',
   replyRounds: props.room.replyRounds ?? 3,
   orchestrationMode: props.room.orchestrationMode ?? 'free' as 'free' | 'host',
 })
 const expandedAgentId = ref('')
+const avatarInput = ref<HTMLInputElement>()
+const avatarError = ref('')
 const agentDrafts = reactive<Record<string, AgentDraft>>({})
 const dirtyAgents = reactive(new Set<string>())
 const selectedAgent = computed(() => props.agents.find(agent => agent.id === expandedAgentId.value))
 const hostAgent = computed(() => props.agents.find(agent => agent.isHost))
 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 {
   return provider && model ? JSON.stringify([provider, model]) : ''
@@ -104,6 +114,7 @@ function draftFrom(agent: GroupAgent): AgentDraft {
 watch(() => props.room, room => {
   form.name = room.name
   form.instructions = room.instructions ?? ''
+  form.avatar = room.avatar ?? ''
   form.replyRounds = room.replyRounds ?? 3
   form.orchestrationMode = room.orchestrationMode ?? 'free'
 }, { deep: true })
@@ -127,11 +138,33 @@ function saveRoom() {
   emit('updateRoom', {
     name: form.name.trim() || props.room.name,
     ...(props.roomInstructionsEnabled ? { instructions: form.instructions.trim() } : {}),
+    ...(props.avatarEnabled ? { avatar: form.avatar } : {}),
     replyRounds: replyRounds === -1 ? -1 : Math.min(100, Math.max(1, replyRounds || 1)),
     ...(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) {
   const id = (event.target as HTMLSelectElement).value
   if (!id || id === hostAgent.value?.id) return
@@ -224,6 +257,19 @@ function statusLabel(status: GroupAgent['status']): string {
     <section>
       <h3>团队设置</h3>
       <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 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">
@@ -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; }
 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); }
+.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; }
 .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); }
@@ -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-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-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); }
 .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; }

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

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

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

@@ -203,7 +203,11 @@ export function chatInteraction(approval?: ApprovalRequest, clarification?: Clar
   return null
 }
 
-export function roomSidebarItem(room: GroupRoomSummary): SidebarItem {
+export function roomSidebarItem(
+  room: GroupRoomSummary,
+  avatars: Record<string, string> = {},
+  avatarsByName: Record<string, string> = {},
+): SidebarItem {
   return {
     id: room.id,
     title: room.name || '未命名团队',
@@ -211,7 +215,11 @@ export function roomSidebarItem(room: GroupRoomSummary): SidebarItem {
     meta: formatRelative(room.updatedAt),
     section: historySection(room.updatedAt),
     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,
     archived: room.archived,
     instructions: room.instructions,
+    avatar: room.avatar,
     memberIds: room.agents.map(agent => agent.id),
     replyRounds: room.maxReplyRounds,
     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 hostFlowProtocol = computed(() => capabilities.value?.features?.includes('hostFlow') ?? 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 topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
   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 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)
     protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
     selectedRoomId.value = detail.id
@@ -459,7 +460,7 @@ export const useGroupsStore = defineStore('groups', () => {
     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 topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
     const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
@@ -754,7 +755,7 @@ export const useGroupsStore = defineStore('groups', () => {
   }
 
   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,
     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,

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

@@ -443,7 +443,13 @@ export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
   const rawAgents = source.agents
   const orchestrationMode = string(pick(source, 'orchestrationMode', 'orchestration_mode'))
   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')),
     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,
@@ -469,7 +475,7 @@ export function normalizeGroupRoomDetail(value: unknown): GroupRoomDetail {
   const source = record(value)
   const summary = normalizeGroupRoom(source)
   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,
     agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(normalizeGroupRun),
     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 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 topicListRoom = computed(() => topicListRoomId.value ? activeRooms.value.find(room => room.id === topicListRoomId.value) : undefined)
 const sidebarSubtitle = computed(() => topicListRoom.value
@@ -81,7 +90,7 @@ function topicTimeSection(updatedAt: number): string {
 }
 const sidebarItems = computed<SidebarItem[]>(() => {
   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[] = [{
     id: GROUP_LIST_BACK_ID,
     title: '返回团队列表',
@@ -147,6 +156,9 @@ const agents = computed(() => displayAgents.value.map(agentToUi))
 const agentAvatars = computed(() => Object.fromEntries(auth.profiles.flatMap(profile =>
   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 {
   try { return new URL(value).host }
   catch { return value.replace(/^https?:\/\//i, '').replace(/\/.*$/, '') }
@@ -378,10 +390,11 @@ async function startTopic(roomId: string) {
   } 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 detail = await groups.createRoom({
     name: payload.name,
+    ...(groups.roomAvatarProtocol ? { avatar: payload.avatar ?? '' } : {}),
     ...(groups.roomInstructionsProtocol ? { instructions: payload.instructions ?? '' } : {}),
     agents: payload.members.map(member => ({
       profile: member.profile,
@@ -407,11 +420,12 @@ async function send(payload: ComposerSubmit) {
   } 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
   await groups.updateRoom(groups.selectedRoom.id, {
     name: patch.name,
     ...(groups.roomInstructionsProtocol ? { instructions: patch.instructions } : {}),
+    ...(groups.roomAvatarProtocol ? { avatar: patch.avatar } : {}),
     maxReplyRounds: patch.replyRounds,
     ...(groups.hostFlowProtocol ? { orchestrationMode: patch.orchestrationMode } : {}),
   })
@@ -710,6 +724,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :host-enabled="groups.hostProtocol"
         :host-flow-enabled="groups.hostFlowProtocol"
         :room-instructions-enabled="groups.roomInstructionsProtocol"
+        :avatar-enabled="groups.roomAvatarProtocol"
         :available-profiles="availableProfiles"
         :busy="managerBusy"
         :model-options-by-profile="modelOptionsByProfile"
@@ -732,7 +747,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
   </WorkspaceView>
 
   <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" />
   <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 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 function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -364,6 +364,8 @@ export interface GroupRoomSummary {
   name: string
   cwd: string
   instructions?: string
+  avatar?: string
+  avatarMembers?: GroupRoomAvatarMember[]
   createdAt: number
   updatedAt: number
   archived: boolean
@@ -375,6 +377,12 @@ export interface GroupRoomSummary {
   orchestrationMode?: 'free' | 'host'
 }
 
+export interface GroupRoomAvatarMember {
+  profile: string
+  nodeId: string
+  displayName: string
+}
+
 export interface GroupRoomDetail extends Omit<GroupRoomSummary, 'agentCount' | 'lastMessage' | 'unreadCount'> {
   agents: GroupAgent[]
   runs: GroupRun[]

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

@@ -1,10 +1,10 @@
 import { mount } from '@vue/test-utils'
 import { nextTick } from 'vue'
 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 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 => ({
   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', () => {
+  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 () => {
     const local = profiles('default')[0]!
     const remote = {
@@ -132,6 +148,29 @@ describe('group host controls', () => {
     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 () => {
     const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
     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(10)).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()
 })
 
+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 }) => {
   await page.getByRole('button', { name: '团队' }).click()
   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 },
 ]
 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 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' })
   }
   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/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)
   if (agentPatchMatch && request.method === 'PATCH') {
     const target = groupAgents.find(agent => agent.id === decodeURIComponent(agentPatchMatch[1]))