Преглед изворни кода

新增群聊房间说明与规则上下文

samien пре 2 месеци
родитељ
комит
e0b79b6b70

+ 10 - 0
hermes-plugins/yaoyao/dashboard/group_orchestrator.py

@@ -385,6 +385,9 @@ def build_run_prompt(
         "room": {
             "id": _required_string(room.get("id"), "projection.room.id"),
             "name": _required_string(room.get("name"), "projection.room.name"),
+            "instructions": _required_string_allowing_empty(
+                room.get("instructions", ""), "projection.room.instructions"
+            ),
             "maxReplyRounds": room.get("maxReplyRounds", 3),
             "orchestrationMode": _required_string(
                 room.get("orchestrationMode", "free"),
@@ -466,9 +469,16 @@ def build_run_prompt(
         if host_flow
         else "仅在确实需要成员协作时,使用成员列表中的精确 @显示名 或 @all 发起下一轮。"
     )
+    instructions_rule = (
+        "GROUP_CONTEXT_JSON.room.instructions 是本房间的长期说明、协作规则和形式准则;"
+        "必须在本次答复和工具使用中遵守。\n"
+        if envelope["room"]["instructions"]
+        else ""
+    )
     return (
         "你正在 Hermes 多设备群聊中回复。只输出要发送到公共房间的答复;"
         f"{collaboration_rule}\n"
+        f"{instructions_rule}"
         f"{automatic_rule}\n"
         f"runId={envelope['run']['id']} roomId={envelope['room']['id']} "
         f"agentId={envelope['agent']['id']}\n"

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

@@ -995,6 +995,8 @@ def _create_room_command(request: CreateRoomRequest) -> dict[str, object]:
         command.pop("maxReplyRounds", None)
     if "orchestration_mode" not in request.model_fields_set:
         command.pop("orchestrationMode", None)
+    if "instructions" not in request.model_fields_set:
+        command.pop("instructions", None)
     agents = command.get("agents")
     if isinstance(agents, list):
         for seed, item in zip(request.agents, agents):
@@ -1050,7 +1052,7 @@ async def capabilities() -> dict[str, object]:
         "latestCursor": latest_cursor,
         "limits": limits_payload(),
         "eventTypes": sorted(EVENT_TYPES),
-        "features": ["hostFlow"],
+        "features": ["hostFlow", "roomInstructions"],
     }
 
 

+ 37 - 3
hermes-plugins/yaoyao/dashboard/group_protocol.py

@@ -31,6 +31,7 @@ DEFAULT_MAX_REPLY_ROUNDS = 3
 UNLIMITED_REPLY_ROUNDS = -1
 MAX_FINITE_REPLY_ROUNDS = 100
 MAX_AGENT_DISPLAY_NAME_LENGTH = 100
+MAX_ROOM_INSTRUCTIONS_LENGTH = 4_000
 MAX_ROOM_CONCURRENCY = 3
 MAX_PLUGIN_CONCURRENCY = 4
 INITIAL_CONTEXT_MESSAGE_LIMIT = 50
@@ -82,6 +83,7 @@ def limits_payload() -> dict[str, int]:
         "defaultMaxReplyRounds": DEFAULT_MAX_REPLY_ROUNDS,
         "unlimitedReplyRoundsValue": UNLIMITED_REPLY_ROUNDS,
         "maxAgentDisplayNameLength": MAX_AGENT_DISPLAY_NAME_LENGTH,
+        "maxRoomInstructionsLength": MAX_ROOM_INSTRUCTIONS_LENGTH,
         "maxRoomConcurrency": MAX_ROOM_CONCURRENCY,
         "maxPluginConcurrency": MAX_PLUGIN_CONCURRENCY,
     }
@@ -135,6 +137,7 @@ class CreateRoomRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
     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)
     max_reply_rounds: StrictInt = Field(
         default=DEFAULT_MAX_REPLY_ROUNDS, alias="maxReplyRounds"
     )
@@ -148,6 +151,11 @@ class CreateRoomRequest(GroupModel):
     def validate_max_reply_rounds(cls, value: int) -> int:
         return normalize_max_reply_rounds(value)
 
+    @field_validator("instructions")
+    @classmethod
+    def normalize_instructions(cls, value: str) -> str:
+        return normalize_room_instructions(value)
+
     @model_validator(mode="after")
     def validate_unique_host(self) -> "CreateRoomRequest":
         if sum(agent.is_host for agent in self.agents) > 1:
@@ -159,13 +167,17 @@ class UpdateRoomRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
     name: str | None = Field(default=None, min_length=1, max_length=100)
     cwd: str | None = Field(default=None, max_length=4096)
+    instructions: str | None = Field(
+        default=None, max_length=MAX_ROOM_INSTRUCTIONS_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", "max_reply_rounds", "orchestration_mode", mode="before"
+        "name", "cwd", "instructions", "max_reply_rounds", "orchestration_mode",
+        mode="before"
     )
     @classmethod
     def reject_explicit_null(cls, value: Any) -> Any:
@@ -176,14 +188,19 @@ class UpdateRoomRequest(GroupModel):
     @model_validator(mode="after")
     def require_change(self) -> "UpdateRoomRequest":
         if not (
-            {"name", "cwd", "max_reply_rounds", "orchestration_mode"}
+            {"name", "cwd", "instructions", "max_reply_rounds", "orchestration_mode"}
             & self.model_fields_set
         ):
             raise ValueError(
-                "Room update requires name, cwd, maxReplyRounds, or orchestrationMode"
+                "Room update requires name, cwd, instructions, maxReplyRounds, or orchestrationMode"
             )
         return self
 
+    @field_validator("instructions")
+    @classmethod
+    def normalize_instructions(cls, value: str | None) -> str | None:
+        return None if value is None else normalize_room_instructions(value)
+
     @field_validator("max_reply_rounds")
     @classmethod
     def validate_max_reply_rounds(cls, value: int | None) -> int | None:
@@ -405,6 +422,23 @@ def normalize_room_name(value: str) -> str:
     return normalized
 
 
+def normalize_room_instructions(value: str) -> str:
+    """Normalize optional multiline room rules without changing their structure."""
+    if not isinstance(value, str):
+        raise ValueError("Room instructions must be a string")
+    normalized = value.replace("\r\n", "\n").replace("\r", "\n").strip()
+    if len(normalized) > MAX_ROOM_INSTRUCTIONS_LENGTH:
+        raise ValueError(
+            f"Room instructions must contain at most {MAX_ROOM_INSTRUCTIONS_LENGTH} characters"
+        )
+    if any(
+        unicodedata.category(character) == "Cc" and character not in {"\n", "\t"}
+        for character in normalized
+    ):
+        raise ValueError("Room instructions contain unsupported control characters")
+    return normalized
+
+
 def normalize_display_name(value: str) -> tuple[str, str]:
     """Return a trimmed display name and its case-insensitive uniqueness key."""
     normalized = value.strip()

+ 54 - 10
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -31,6 +31,7 @@ from .group_protocol import (
     MAX_MESSAGE_PAGE_SIZE,
     MAX_PLUGIN_CONCURRENCY,
     MAX_ROOM_CONCURRENCY,
+    MAX_ROOM_INSTRUCTIONS_LENGTH,
     MAX_TOOL_STATE_BYTES,
     ORCHESTRATION_MODES,
     is_reserved_mention_alias,
@@ -38,11 +39,12 @@ from .group_protocol import (
     normalize_interaction_id,
     normalize_max_reply_rounds,
     normalize_room_cwd,
+    normalize_room_instructions,
     normalize_room_name,
 )
 
 
-SCHEMA_VERSION = 11
+SCHEMA_VERSION = 12
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
@@ -113,6 +115,7 @@ _SCHEMA_STATEMENTS = (
         id TEXT PRIMARY KEY,
         name TEXT NOT NULL,
         cwd TEXT NOT NULL DEFAULT '',
+        instructions TEXT NOT NULL DEFAULT '',
         max_reply_rounds INTEGER NOT NULL DEFAULT 3,
         orchestration_mode TEXT NOT NULL DEFAULT 'free',
         created_at REAL NOT NULL,
@@ -493,6 +496,9 @@ class GroupStore:
             raw_version = "10"
         if raw_version == "10":
             self._migrate_v10_to_v11(connection)
+            raw_version = "11"
+        if raw_version == "11":
+            self._migrate_v11_to_v12(connection)
         self._validated_schema_version(
             self._metadata_value_from_connection(connection, "schema_version")
         )
@@ -1223,11 +1229,26 @@ class GroupStore:
             "UPDATE group_meta SET value = '11' WHERE key = 'schema_version'"
         )
 
+    @staticmethod
+    def _migrate_v11_to_v12(connection: sqlite3.Connection) -> None:
+        columns = {
+            str(row["name"])
+            for row in connection.execute("PRAGMA table_info(group_rooms)")
+        }
+        if "instructions" not in columns:
+            connection.execute(
+                "ALTER TABLE group_rooms ADD COLUMN instructions "
+                "TEXT NOT NULL DEFAULT ''"
+            )
+        connection.execute(
+            "UPDATE group_meta SET value = '12' WHERE key = 'schema_version'"
+        )
+
     @staticmethod
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
             "group_meta": {"key", "value"},
-            "group_rooms": {"id", "name", "cwd", "max_reply_rounds", "orchestration_mode", "created_at", "updated_at", "archived"},
+            "group_rooms": {"id", "name", "cwd", "instructions", "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", "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"},
@@ -1240,6 +1261,7 @@ class GroupStore:
         exact_specs = {
             ("group_rooms", "max_reply_rounds"): ("INTEGER", 1, "3"),
             ("group_rooms", "orchestration_mode"): ("TEXT", 1, "'free'"),
+            ("group_rooms", "instructions"): ("TEXT", 1, "''"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
             ("group_agents", "is_host"): ("INTEGER", 1, "0"),
             ("group_messages", "visible"): ("INTEGER", 1, "1"),
@@ -1282,6 +1304,11 @@ class GroupStore:
                 WHERE typeof(orchestration_mode) != 'text'
                    OR orchestration_mode NOT IN ('free', 'host')
                 LIMIT 1""",
+            f"""SELECT 1 FROM group_rooms
+                WHERE typeof(instructions) != 'text'
+                   OR length(instructions) > {MAX_ROOM_INSTRUCTIONS_LENGTH}
+                   OR instr(instructions, char(13)) > 0
+                LIMIT 1""",
             """SELECT 1 FROM group_agents
                 WHERE typeof(reply_without_mention) != 'integer'
                    OR reply_without_mention NOT IN (0, 1)
@@ -1680,6 +1707,7 @@ class GroupStore:
                 "requestId",
                 "name",
                 "cwd",
+                "instructions",
                 "maxReplyRounds",
                 "orchestrationMode",
                 "agents",
@@ -1690,6 +1718,9 @@ class GroupStore:
         def action(connection: sqlite3.Connection) -> dict[str, object]:
             name = normalize_room_name(self._string(command, "name"))
             cwd = normalize_room_cwd(self._string(command, "cwd"))
+            instructions = normalize_room_instructions(
+                command.get("instructions", "")
+            )
             max_reply_rounds = normalize_max_reply_rounds(
                 command.get("maxReplyRounds", DEFAULT_MAX_REPLY_ROUNDS)
             )
@@ -1701,10 +1732,13 @@ class GroupStore:
             room_id = str(uuid.uuid4())
             connection.execute(
                 """INSERT INTO group_rooms
-                (id, name, cwd, max_reply_rounds, orchestration_mode,
+                (id, name, cwd, instructions, max_reply_rounds, orchestration_mode,
                  created_at, updated_at, archived)
-                VALUES (?, ?, ?, ?, ?, ?, ?, 0)""",
-                (room_id, name, cwd, max_reply_rounds, orchestration_mode, now, now),
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)""",
+                (
+                    room_id, name, cwd, instructions, max_reply_rounds,
+                    orchestration_mode, now, now,
+                ),
             )
             for agent in agents:
                 self._insert_agent(connection, room_id, agent, now)
@@ -1778,7 +1812,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, max_reply_rounds, orchestration_mode,
+        query = """SELECT id, name, cwd, instructions, 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
@@ -1929,17 +1963,22 @@ class GroupStore:
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         command = self._command(
             command,
-            {"requestId", "name", "cwd", "maxReplyRounds", "orchestrationMode"},
+            {
+                "requestId", "name", "cwd", "instructions",
+                "maxReplyRounds", "orchestrationMode",
+            },
         )
         request_id = self._command_request_id(command)
         changes = {
             key
-            for key in ("name", "cwd", "maxReplyRounds", "orchestrationMode")
+            for key in (
+                "name", "cwd", "instructions", "maxReplyRounds", "orchestrationMode"
+            )
             if key in command
         }
         if not changes:
             raise ValueError(
-                "Room update requires name, cwd, maxReplyRounds, or orchestrationMode"
+                "Room update requires name, cwd, instructions, maxReplyRounds, or orchestrationMode"
             )
 
         def action(connection: sqlite3.Connection) -> dict[str, object]:
@@ -1952,6 +1991,9 @@ class GroupStore:
             if "cwd" in changes:
                 assignments.append("cwd = ?")
                 values.append(normalize_room_cwd(self._string(command, "cwd")))
+            if "instructions" in changes:
+                assignments.append("instructions = ?")
+                values.append(normalize_room_instructions(command["instructions"]))
             if "maxReplyRounds" in changes:
                 assignments.append("max_reply_rounds = ?")
                 values.append(normalize_max_reply_rounds(command["maxReplyRounds"]))
@@ -5785,7 +5827,7 @@ class GroupStore:
         created_at: float,
     ) -> None:
         room = connection.execute(
-            """SELECT id, name, cwd, max_reply_rounds, orchestration_mode,
+            """SELECT id, name, cwd, instructions, max_reply_rounds, orchestration_mode,
             created_at, updated_at, archived,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id)
                 AS agent_count,
@@ -6792,6 +6834,7 @@ class GroupStore:
             "id": row["id"],
             "name": row["name"],
             "cwd": row["cwd"],
+            "instructions": row["instructions"],
             "maxReplyRounds": row["max_reply_rounds"],
             "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],
@@ -6805,6 +6848,7 @@ class GroupStore:
             "id": row["id"],
             "name": row["name"],
             "cwd": row["cwd"],
+            "instructions": row["instructions"],
             "maxReplyRounds": row["max_reply_rounds"],
             "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],

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

@@ -496,7 +496,7 @@ class GroupHostContractTests(unittest.TestCase):
 
             migrated = GroupStore(path)
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 11)
+            self.assertEqual(migrated.schema_version(), 12)
             self.assertTrue(
                 all(
                     room["orchestrationMode"] == "free"
@@ -881,7 +881,7 @@ class GroupHostContractTests(unittest.TestCase):
 
             migrated = GroupStore(path)
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 11)
+            self.assertEqual(migrated.schema_version(), 12)
             self.assertEqual(
                 migrated.get_room(room["id"])["orchestrationMode"], "free"
             )

+ 75 - 1
hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py

@@ -36,6 +36,80 @@ def request_id() -> str:
 
 
 class GroupSettingsContractTests(unittest.TestCase):
+    def test_room_instructions_round_trip_into_agent_prompt(self) -> None:
+        create = PROTOCOL.CreateRoomRequest.model_validate({
+            "requestId": request_id(),
+            "name": "规则群",
+            "instructions": "  先核对事实。\r\n结论使用中文。  ",
+            "agents": [{"profile": "default"}],
+        })
+        self.assertEqual(create.instructions, "先核对事实。\n结论使用中文。")
+        self.assertEqual(
+            PROTOCOL.limits_payload()["maxRoomInstructionsLength"], 4_000
+        )
+        with self.assertRaises(ValueError):
+            PROTOCOL.UpdateRoomRequest.model_validate({
+                "requestId": request_id(),
+                "instructions": "x" * 4_001,
+            })
+
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": request_id(),
+                "name": "规则群",
+                "cwd": "",
+                "instructions": create.instructions,
+                "agents": [{"profile": "default"}],
+            })
+            self.assertEqual(room["instructions"], create.instructions)
+            [agent] = room["agents"]
+            created = store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="请执行",
+                mention_agent_ids=[agent["id"]],
+            )
+            claimed = store.claim_next_runnable_run()
+            self.assertEqual(claimed["id"], created["runs"][0]["id"])
+            projection = store.read_run_projection(claimed["id"])
+            self.assertEqual(
+                projection["room"]["instructions"], create.instructions
+            )
+            prompt = ORCHESTRATOR.build_run_prompt(projection, room["agents"])
+            self.assertIn("长期说明、协作规则和形式准则", prompt)
+            self.assertIn("先核对事实", prompt)
+
+            updated = store.update_room(
+                room["id"],
+                {"requestId": request_id(), "instructions": ""},
+            )
+            self.assertEqual(updated["instructions"], "")
+
+    def test_v11_database_migrates_empty_room_instructions(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "group.db"
+            store = GroupStore(path)
+            store.initialize()
+            room = store.create_room({
+                "requestId": request_id(),
+                "name": "旧房间",
+                "cwd": "",
+                "agents": [{"profile": "default"}],
+            })
+            with store.connection() as connection:
+                connection.execute("ALTER TABLE group_rooms DROP COLUMN instructions")
+                connection.execute(
+                    "UPDATE group_meta SET value = '11' WHERE key = 'schema_version'"
+                )
+
+            migrated = GroupStore(path)
+            migrated.initialize()
+            self.assertEqual(migrated.schema_version(), 12)
+            self.assertEqual(migrated.get_room(room["id"])["instructions"], "")
+
     def test_terminal_session_info_captures_effective_model_for_settlement(self) -> None:
         async def exercise() -> tuple[object, dict[str, object]]:
             orchestrator = ORCHESTRATOR.GroupOrchestrator(object())
@@ -712,7 +786,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             self._create_v1_database(path)
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 11)
+            self.assertEqual(store.schema_version(), 12)
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             with store.connection() as connection:
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()

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

@@ -268,7 +268,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             migrated = GroupStore(path)
             migrated.initialize()
 
-            self.assertEqual(migrated.schema_version(), 11)
+            self.assertEqual(migrated.schema_version(), 12)
             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(), 11)
+            self.assertEqual(store.schema_version(), 12)
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
@@ -1530,7 +1530,11 @@ class GroupTopicsContractTests(unittest.TestCase):
                     ).fetchone()[0]
                 )
             self.assertEqual(
-                dict(room), {**expected_room, "orchestration_mode": "free"}
+                dict(room), {
+                    **expected_room,
+                    "orchestration_mode": "free",
+                    "instructions": "",
+                }
             )
             self.assertEqual(dict(agent), {**expected_agent, "is_host": 1})
             self.assertEqual(

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

@@ -82,13 +82,13 @@ export interface AgentSeed {
   fastMode?: boolean | null
 }
 
-export async function createGroupRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
+export async function createGroupRoom(input: { name: string; cwd?: string; instructions?: 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; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
+export async function updateGroupRoom(roomId: string, input: { name?: string; cwd?: string; instructions?: 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,
   })))

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

@@ -7,18 +7,21 @@ interface CreateGroupPayload {
   profiles: string[]
   autoReply: boolean
   replyRounds: number
+  instructions?: string
   hostProfile?: string
   orchestrationMode?: 'free' | 'host'
 }
 
-const props = withDefaults(defineProps<{ open: boolean; profiles: string[]; hostEnabled?: boolean; hostFlowEnabled?: boolean; busy?: boolean }>(), {
+const props = withDefaults(defineProps<{ open: boolean; profiles: string[]; hostEnabled?: boolean; hostFlowEnabled?: boolean; roomInstructionsEnabled?: boolean; busy?: boolean }>(), {
   hostEnabled: false,
   hostFlowEnabled: false,
+  roomInstructionsEnabled: false,
   busy: false,
 })
 const emit = defineEmits<{ close: []; create: [payload: CreateGroupPayload] }>()
 
 const name = ref('')
+const instructions = ref('')
 const selected = ref<string[]>([])
 const hostProfile = ref('')
 const autoReply = ref(true)
@@ -32,6 +35,7 @@ const valid = computed(() => name.value.trim().length > 0
 watch(() => props.open, open => {
   if (!open) return
   name.value = ''
+  instructions.value = ''
   selected.value = props.profiles.slice(0, 1)
   hostProfile.value = selected.value[0] ?? ''
   autoReply.value = true
@@ -55,6 +59,7 @@ function create() {
   }
   if (props.hostEnabled) payload.hostProfile = hostProfile.value
   if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
+  if (props.roomInstructionsEnabled) payload.instructions = instructions.value.trim()
   emit('create', payload)
 }
 </script>
@@ -66,6 +71,7 @@ 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>
+          <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>
             <button v-for="profile in profiles" :key="profile" type="button" :class="{ selected: selected.includes(profile) }" :disabled="!selected.includes(profile) && selected.length >= 8" @click="toggle(profile)">
@@ -101,7 +107,7 @@ function create() {
 .dialog-layer { position: fixed; z-index: 200; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(5px); }
 .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 input { height: 38px; padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: none; background: var(--surface-soft); color: var(--text-primary); }.field input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
+.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; }
 .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 > span { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; }.agent-picker > button strong { flex: 1; font-size: 11px; }.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; }

+ 6 - 1
src/client/components/groups/GroupManager.vue

@@ -36,6 +36,7 @@ const props = withDefaults(defineProps<{
   agents: GroupAgent[]
   hostEnabled?: boolean
   hostFlowEnabled?: boolean
+  roomInstructionsEnabled?: boolean
   availableProfiles?: string[]
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
@@ -43,7 +44,7 @@ const props = withDefaults(defineProps<{
   agentUpdateError?: Record<string, string>
   managerError?: string
   busy?: boolean
-}>(), { hostEnabled: false, hostFlowEnabled: false, busy: false })
+}>(), { hostEnabled: false, hostFlowEnabled: false, roomInstructionsEnabled: false, busy: false })
 
 const emit = defineEmits<{
   updateRoom: [patch: Partial<UiRoom>]
@@ -58,6 +59,7 @@ const emit = defineEmits<{
 
 const form = reactive({
   name: props.room.name,
+  instructions: props.room.instructions ?? '',
   replyRounds: props.room.replyRounds ?? 3,
   orchestrationMode: props.room.orchestrationMode ?? 'free' as 'free' | 'host',
 })
@@ -97,6 +99,7 @@ function draftFrom(agent: GroupAgent): AgentDraft {
 
 watch(() => props.room, room => {
   form.name = room.name
+  form.instructions = room.instructions ?? ''
   form.replyRounds = room.replyRounds ?? 3
   form.orchestrationMode = room.orchestrationMode ?? 'free'
 }, { deep: true })
@@ -119,6 +122,7 @@ function saveRoom() {
   const replyRounds = Number(form.replyRounds)
   emit('updateRoom', {
     name: form.name.trim() || props.room.name,
+    ...(props.roomInstructionsEnabled ? { instructions: form.instructions.trim() } : {}),
     replyRounds: replyRounds === -1 ? -1 : Math.min(100, Math.max(1, replyRounds || 1)),
     ...(props.hostFlowEnabled ? { orchestrationMode: form.orchestrationMode } : {}),
   })
@@ -214,6 +218,7 @@ function statusLabel(status: GroupAgent['status']): string {
     <section>
       <h3>房间</h3>
       <label><span>名称</span><input v-model="form.name" maxlength="80" @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 v-if="hostFlowEnabled" class="flow-mode">
         <span>协作模式<small>主持人可按依赖逐步调度,也可一次 @ 多人并列执行。</small></span>

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

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

+ 1 - 0
src/client/components/workspace/viewModels.ts

@@ -252,6 +252,7 @@ export function roomToUi(room: GroupRoomDetail): UiRoom {
     id: room.id,
     name: room.name,
     archived: room.archived,
+    instructions: room.instructions,
     memberIds: room.agents.map(agent => agent.id),
     replyRounds: room.maxReplyRounds,
     orchestrationMode: room.orchestrationMode,

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

@@ -63,6 +63,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
   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 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)
@@ -436,7 +437,7 @@ export const useGroupsStore = defineStore('groups', () => {
 
   async function refresh(): Promise<void> { await rebuildSnapshot() }
 
-  async function createRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
+  async function createRoom(input: { name: string; cwd?: string; instructions?: 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
@@ -446,7 +447,7 @@ export const useGroupsStore = defineStore('groups', () => {
     return detail
   }
 
-  async function updateRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
+  async function updateRoom(roomId: string, input: { name?: string; cwd?: string; instructions?: 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] ?? []
@@ -741,7 +742,7 @@ export const useGroupsStore = defineStore('groups', () => {
   }
 
   return {
-    availability, capabilities, topicProtocol, hostProtocol, hostFlowProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
+    availability, capabilities, topicProtocol, hostProtocol, hostFlowProtocol, roomInstructionsProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
     messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     pinnedTopics, start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, setTopicPinned, renameTopic, loadPinnedTopics, archivedRooms, archivedTopics, addAgent, updateAgent,

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

@@ -432,7 +432,7 @@ 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),
+    id: string(source.id), name: string(source.name, '未命名群聊'), cwd: string(source.cwd), instructions: string(source.instructions),
     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,
@@ -458,7 +458,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, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
+    id: summary.id, name: summary.name, cwd: summary.cwd, instructions: summary.instructions, 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),

+ 6 - 3
src/client/views/GroupsView.vue

@@ -328,10 +328,11 @@ async function startTopic(roomId: string) {
   } catch { /* store publishes the error */ }
 }
 
-async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number; hostProfile?: string; orchestrationMode?: 'free' | 'host' }) {
+async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number; instructions?: string; hostProfile?: string; orchestrationMode?: 'free' | 'host' }) {
   const hostProfile = payload.hostProfile || payload.profiles[0]
   const detail = await groups.createRoom({
     name: payload.name,
+    ...(groups.roomInstructionsProtocol ? { instructions: payload.instructions ?? '' } : {}),
     agents: payload.profiles.map(profile => ({
       profile,
       displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile,
@@ -354,10 +355,11 @@ async function send(payload: ComposerSubmit) {
   } catch { /* store publishes the error */ }
 }
 
-async function updateRoom(patch: { name?: string; replyRounds?: number; orchestrationMode?: 'free' | 'host' }) {
+async function updateRoom(patch: { name?: string; instructions?: string; replyRounds?: number; orchestrationMode?: 'free' | 'host' }) {
   if (!groups.selectedRoom) return
   await groups.updateRoom(groups.selectedRoom.id, {
     name: patch.name,
+    ...(groups.roomInstructionsProtocol ? { instructions: patch.instructions } : {}),
     maxReplyRounds: patch.replyRounds,
     ...(groups.hostFlowProtocol ? { orchestrationMode: patch.orchestrationMode } : {}),
   })
@@ -649,6 +651,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :agents="groups.agents"
         :host-enabled="groups.hostProtocol"
         :host-flow-enabled="groups.hostFlowProtocol"
+        :room-instructions-enabled="groups.roomInstructionsProtocol"
         :available-profiles="availableProfiles"
         :busy="managerBusy"
         :model-options-by-profile="modelOptionsByProfile"
@@ -669,7 +672,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" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
+  <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" />
   <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" />
 

+ 1 - 0
src/shared/types.ts

@@ -344,6 +344,7 @@ export interface GroupRoomSummary {
   id: string
   name: string
   cwd: string
+  instructions?: string
   createdAt: number
   updatedAt: number
   archived: boolean

+ 26 - 0
tests/client/groupHost.test.ts

@@ -53,6 +53,7 @@ describe('group host controls', () => {
     await wrapper.get('.solid-button').trigger('click')
     expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('hostProfile')
     expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('orchestrationMode')
+    expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('instructions')
     wrapper.unmount()
   })
 
@@ -64,14 +65,17 @@ describe('group host controls', () => {
         profiles: ['yaoyao', 'yaoer'],
         hostEnabled: true,
         hostFlowEnabled: true,
+        roomInstructionsEnabled: true,
       },
       global: { stubs: { teleport: true } },
     })
     await wrapper.get('input[placeholder="例如:产品评审"]').setValue('顺序协作')
+    await wrapper.get<HTMLTextAreaElement>('textarea').setValue('先核对事实,再输出中文结论。')
     await wrapper.get<HTMLSelectElement>('select[aria-label="协作模式"]').setValue('host')
     await wrapper.get('.solid-button').trigger('click')
     expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
       name: '顺序协作',
+      instructions: '先核对事实,再输出中文结论。',
       orchestrationMode: 'host',
     })
     wrapper.unmount()
@@ -138,4 +142,26 @@ describe('group host controls', () => {
       orchestrationMode: 'host',
     })
   })
+
+  it('edits room instructions when the capability is available', async () => {
+    const first = agent('agent-1', 'yaoyao', '夭夭', true, false)
+    const wrapper = mount(GroupManager, {
+      props: {
+        room: {
+          id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3,
+          orchestrationMode: 'free', instructions: '旧规则',
+        },
+        agents: [first],
+        hostEnabled: true,
+        hostFlowEnabled: true,
+        roomInstructionsEnabled: true,
+      },
+    })
+    const instructions = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="房间说明"]')
+    await instructions.setValue('新规则\n输出中文')
+    await instructions.trigger('change')
+    expect(wrapper.emitted('updateRoom')?.at(-1)?.[0]).toMatchObject({
+      instructions: '新规则\n输出中文',
+    })
+  })
 })