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

统一团队管理员称呼并修复预设创建

LSamien 2 месяцев назад
Родитель
Сommit
637f6a0e84

+ 4 - 4
hermes-plugins/yaoyao/dashboard/group_orchestrator.py

@@ -451,7 +451,7 @@ def build_run_prompt(
         )
     elif envelope["run"]["requiredReply"]:
         automatic_rule = (
-            "你是本房间唯一主持人。该用户消息没有有效 @,你必须公开处理;"
+            "你是本房间唯一管理员。该用户消息没有有效 @,你必须公开处理;"
             "禁止保持静默,禁止输出任何 YAOYAO_NO_REPLY 标记。必须三选一:"
             "直接回答;使用成员列表中的精确 @显示名 转派;或发起澄清请求。"
         )
@@ -462,11 +462,11 @@ def build_run_prompt(
         )
     elif host_flow and not envelope["agent"]["isHost"]:
         automatic_rule = (
-            "你正在执行主持人委派的当前步骤。只回答本步骤,不要 @任何成员;"
-            "完成后系统会把结果交还主持人复核并决定下一步。"
+            "你正在执行管理员委派的当前步骤。只回答本步骤,不要 @任何成员;"
+            "完成后系统会把结果交还管理员复核并决定下一步。"
         )
     collaboration_rule = (
-        "主持流程中只有主持人可以调度成员;成员可以按依赖串行,也可以成批并列执行。"
+        "管理员协调中只有管理员可以调度成员;成员可以按依赖串行,也可以成批并列执行。"
         if host_flow
         else "仅在确实需要成员协作时,使用成员列表中的精确 @显示名 或 @all 发起下一轮。"
     )

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

@@ -44,6 +44,13 @@ REASONING_EFFORTS = frozenset({
     "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"
 })
 ORCHESTRATION_MODES = frozenset({"free", "host"})
+BUILTIN_TEAM_AVATARS = frozenset({
+    "builtin:team-animal:fox",
+    "builtin:team-animal:whale",
+    "builtin:team-animal:owl",
+    "builtin:team-animal:rabbit",
+    "builtin:team-animal:bear",
+})
 _ROOM_AVATAR_PATTERN = re.compile(
     r"^data:image/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$",
     re.IGNORECASE,
@@ -99,17 +106,19 @@ def limits_payload() -> dict[str, int]:
 
 
 def normalize_room_avatar(value: object) -> str:
-    """Validate a persisted custom avatar; an empty string selects auto mode."""
+    """Validate a persisted built-in or custom team avatar."""
     if not isinstance(value, str):
-        raise ValueError("avatar must be a data image URL or an empty string")
+        raise ValueError("avatar must be a built-in avatar, data image URL, or an empty string")
     normalized = value.strip()
     if not normalized:
         return ""
+    if normalized in BUILTIN_TEAM_AVATARS:
+        return normalized
     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")
+        raise ValueError("avatar must be a known built-in avatar or a PNG, JPEG, or WebP data image URL")
     try:
         decoded = base64.b64decode(match.group(2), validate=True)
     except (binascii.Error, ValueError) as error:
@@ -207,7 +216,7 @@ class CreateRoomRequest(GroupModel):
     @model_validator(mode="after")
     def validate_unique_host(self) -> "CreateRoomRequest":
         if sum(agent.is_host for agent in self.agents) > 1:
-            raise ValueError("Room may contain only one host Agent")
+            raise ValueError("Room may contain only one administrator Agent")
         return self
 
 

+ 11 - 11
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -1073,7 +1073,7 @@ class GroupStore:
                 (room["id"],),
             ).fetchone()
             if host is None:
-                raise GroupStoreError("GroupStore room has no host candidate")
+                raise GroupStoreError("GroupStore room has no administrator candidate")
             connection.execute(
                 "UPDATE group_agents SET is_host = 1 WHERE id = ? AND room_id = ?",
                 (host["id"], room["id"]),
@@ -1601,7 +1601,7 @@ class GroupStore:
             try:
                 connection.execute(host_definition)
             except sqlite3.IntegrityError as error:
-                raise GroupStoreError("Room hosts are not unique") from error
+                raise GroupStoreError("Room administrators are not unique") from error
         active_runtime_definition = """CREATE UNIQUE INDEX
             idx_group_agent_runs_active_runtime
             ON group_agent_runs(runtime_session_id)
@@ -2575,10 +2575,10 @@ class GroupStore:
                 raise ValueError("isHost must be a boolean")
             if requested_host is False and bool(previous["is_host"]):
                 raise GroupConflictError(
-                    "Current host cannot be cleared without selecting a replacement"
+                    "Current administrator cannot be cleared without selecting a replacement"
                 )
             if requested_host is True and command.get("enabled") is False:
-                raise GroupConflictError("Host Agent must be enabled")
+                raise GroupConflictError("Administrator Agent must be enabled")
             replacement_id: str | None = None
             if (
                 bool(previous["is_host"])
@@ -2592,7 +2592,7 @@ class GroupStore:
                 )
                 if replacement is None:
                     raise GroupConflictError(
-                        "Host Agent cannot be disabled without an enabled replacement"
+                        "Administrator Agent cannot be disabled without an enabled replacement"
                     )
                 replacement_id = str(replacement["id"])
             assignments: list[str] = []
@@ -2769,7 +2769,7 @@ class GroupStore:
                 )
                 if replacement is None:
                     raise GroupConflictError(
-                        "Host Agent cannot be removed without an enabled replacement"
+                        "Administrator Agent cannot be removed without an enabled replacement"
                     )
                 switched = self._switch_host(
                     connection,
@@ -3761,7 +3761,7 @@ class GroupStore:
                     None,
                 )
                 if host is None:
-                    raise GroupStoreError("Host flow has no enabled host Agent")
+                    raise GroupStoreError("Administrator flow has no enabled administrator Agent")
                 target_ids, warnings = [str(host["id"])], []
                 explicit_ids = []
             else:
@@ -4463,7 +4463,7 @@ class GroupStore:
                 None,
             )
             if current_host is None:
-                raise GroupStoreError("Host flow has no enabled host Agent")
+                raise GroupStoreError("Administrator flow has no enabled administrator Agent")
             canonical_agent_ids = [str(current_host["id"])]
             reply_modes = {str(current_host["id"]): "automatic"}
             unfinished_sibling = connection.execute(
@@ -6193,7 +6193,7 @@ class GroupStore:
             (room_id,),
         ).fetchone()
         if host is None:
-            raise GroupStoreError("Room host is missing")
+            raise GroupStoreError("Room administrator is missing")
         return host
 
     @staticmethod
@@ -6223,7 +6223,7 @@ class GroupStore:
             return None
         replacement = self._owned_agent(connection, room_id, agent_id)
         if not replacement["enabled"]:
-            raise GroupConflictError("Host Agent must be enabled")
+            raise GroupConflictError("Administrator Agent must be enabled")
         connection.execute(
             """UPDATE group_agents SET is_host = 0, updated_at = ?
             WHERE id = ? AND room_id = ?""",
@@ -6820,7 +6820,7 @@ class GroupStore:
             raise GroupConflictError("Agent display name must be unique within a room")
         host_count = sum(bool(agent["is_host"]) for agent in agents)
         if host_count > 1:
-            raise GroupConflictError("Room may contain only one host Agent")
+            raise GroupConflictError("Room may contain only one administrator Agent")
         if host_count == 0:
             agents[0]["is_host"] = True
         return agents

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

@@ -67,7 +67,7 @@ class GroupHostContractTests(unittest.TestCase):
         })
         self.assertEqual(flow.orchestration_mode, "host")
 
-        with self.assertRaisesRegex(ValueError, "only one host"):
+        with self.assertRaisesRegex(ValueError, "only one administrator"):
             PROTOCOL.CreateRoomRequest.model_validate({
                 "requestId": new_id(),
                 "name": "非法群",
@@ -582,7 +582,7 @@ class GroupHostContractTests(unittest.TestCase):
                 "cwd": "",
                 "agents": [{"profile": "host", "isHost": True}],
             })
-            created = self._send(store, room["id"], "请主持", [])
+            created = self._send(store, room["id"], "请管理员", [])
             [run] = created["runs"]
             claimed = store.claim_next_runnable_run()
             projection = store.read_run_projection(claimed["id"])
@@ -592,7 +592,7 @@ class GroupHostContractTests(unittest.TestCase):
             )
             envelope = json.loads(prompt.rsplit("GROUP_CONTEXT_JSON=", 1)[1])
             self.assertIs(envelope["run"]["requiredReply"], True)
-            self.assertIn("唯一主持人", prompt)
+            self.assertIn("唯一管理员", prompt)
             self.assertNotIn("[[YAOYAO_NO_REPLY_V1]]", prompt)
 
             before = store.latest_cursor()
@@ -685,11 +685,11 @@ class GroupHostContractTests(unittest.TestCase):
             store.initialize()
             room = store.create_room({
                 "requestId": new_id(),
-                "name": "主持流程群",
+                "name": "管理员协调群",
                 "cwd": "",
                 "orchestrationMode": "host",
                 "agents": [
-                    {"profile": "host", "displayName": "主持人", "isHost": True},
+                    {"profile": "host", "displayName": "管理员", "isHost": True},
                     {"profile": "one", "displayName": "成员一"},
                     {"profile": "two", "displayName": "成员二"},
                 ],
@@ -738,7 +738,7 @@ class GroupHostContractTests(unittest.TestCase):
                 "cwd": "",
                 "orchestrationMode": "host",
                 "agents": [
-                    {"profile": "host", "displayName": "主持人", "isHost": True},
+                    {"profile": "host", "displayName": "管理员", "isHost": True},
                     {"profile": "one", "displayName": "成员一"},
                     {"profile": "two", "displayName": "成员二"},
                 ],
@@ -826,11 +826,11 @@ class GroupHostContractTests(unittest.TestCase):
             store.initialize()
             room = store.create_room({
                 "requestId": new_id(),
-                "name": "主持复核群",
+                "name": "管理员复核团队",
                 "cwd": "",
                 "orchestrationMode": "host",
                 "agents": [
-                    {"profile": "host", "displayName": "主持人", "isHost": True},
+                    {"profile": "host", "displayName": "管理员", "isHost": True},
                     {"profile": "worker", "displayName": "执行者"},
                     {"profile": "other", "displayName": "其他成员"},
                 ],
@@ -896,7 +896,7 @@ class GroupHostContractTests(unittest.TestCase):
                 "cwd": "",
                 "orchestrationMode": "host",
                 "agents": [
-                    {"profile": "host", "displayName": "主持人", "isHost": True},
+                    {"profile": "host", "displayName": "管理员", "isHost": True},
                     {"profile": "one", "displayName": "成员一"},
                     {"profile": "two", "displayName": "成员二"},
                 ],

+ 12 - 0
hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py

@@ -777,6 +777,7 @@ class GroupSettingsContractTests(unittest.TestCase):
 
         for invalid in (
             "https://example.com/avatar.png",
+            "builtin:team-animal:unknown",
             "data:image/svg+xml;base64,AA==",
             "data:image/png;base64,***",
         ):
@@ -788,6 +789,17 @@ class GroupSettingsContractTests(unittest.TestCase):
                     "agents": [{"profile": "planner"}],
                 })
 
+    def test_builtin_team_avatars_are_valid_room_values(self) -> None:
+        for avatar in PROTOCOL.BUILTIN_TEAM_AVATARS:
+            with self.subTest(avatar=avatar):
+                request = PROTOCOL.CreateRoomRequest.model_validate({
+                    "requestId": request_id(),
+                    "name": "动物头像团队",
+                    "avatar": avatar,
+                    "agents": [{"profile": "planner"}],
+                })
+                self.assertEqual(request.avatar, avatar)
+
     def test_reserved_all_aliases_cannot_be_agent_display_names(self) -> None:
         model_payloads = (
             (

+ 9 - 0
src/client/api/client.ts

@@ -58,6 +58,15 @@ function errorMessage(payload: unknown, fallback: string): string {
     for (const candidate of [record.message, record.error, nested?.message, record.detail]) {
       if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
     }
+    if (Array.isArray(record.detail)) {
+      const validation = record.detail.find(item => item && typeof item === 'object') as Record<string, unknown> | undefined
+      if (validation && typeof validation.msg === 'string' && validation.msg.trim()) {
+        const location = Array.isArray(validation.loc)
+          ? validation.loc.filter(item => typeof item === 'string' || typeof item === 'number').join('.')
+          : ''
+        return location ? `${location}:${validation.msg.trim()}` : validation.msg.trim()
+      }
+    }
   }
   if (typeof payload === 'string' && payload.trim()) return payload.trim()
   return fallback

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

@@ -22,7 +22,7 @@ interface CreateGroupPayload {
   orchestrationMode?: 'free' | 'host'
 }
 
-const props = withDefaults(defineProps<{ open: boolean; profiles: GroupProfileOption[]; avatarEnabled?: boolean; hostEnabled?: boolean; hostFlowEnabled?: boolean; roomInstructionsEnabled?: boolean; busy?: boolean }>(), {
+const props = withDefaults(defineProps<{ open: boolean; profiles: GroupProfileOption[]; avatarEnabled?: boolean; hostEnabled?: boolean; hostFlowEnabled?: boolean; roomInstructionsEnabled?: boolean; error?: string; busy?: boolean }>(), {
   avatarEnabled: false,
   hostEnabled: false,
   hostFlowEnabled: false,
@@ -175,7 +175,7 @@ function create() {
           <section v-if="selectedPreset" class="role-mapping" aria-labelledby="role-mapping-label">
             <div class="role-mapping__heading"><span id="role-mapping-label">角色分配</span><small>{{ selectedPreset.roles.length }} 个角色已对应当前 Agent</small></div>
             <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
-              <span><strong>{{ role.name }}<em v-if="hostEnabled && role.host">主持人</em></strong><small>{{ role.description }}</small></span>
+              <span><strong>{{ role.name }}<em v-if="hostEnabled && role.host">管理员</em></strong><small>{{ role.description }}</small></span>
               <select :value="selected[index]" :aria-label="`${role.name}对应的 Agent`" @change="assignRole(index, ($event.target as HTMLSelectElement).value)">
                 <option v-for="profile in profiles" :key="profile.id" :value="profile.id">{{ profile.displayName }} · {{ profile.nodeLabel }}</option>
               </select>
@@ -189,22 +189,23 @@ function create() {
             <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
           </div>
           <label v-if="hostEnabled && !selectedPreset" class="host-picker">
-            <span>主持人<small>用户没有明确 @ 时,主持人始终负责回应。</small></span>
-            <select v-model="hostProfile" aria-label="主持人">
+            <span>管理员<small>用户没有明确 @ 时,管理员始终负责回应。</small></span>
+            <select v-model="hostProfile" aria-label="管理员">
               <option v-for="profileID in selected" :key="profileID" :value="profileID">{{ profiles.find(profile => profile.id === profileID)?.displayName || profileID }}</option>
             </select>
           </label>
           <label v-if="hostFlowEnabled" class="host-picker">
-            <span>协作模式<small>主持人可按依赖串行调度,也可一次 @ 多人并列执行;整批结束后统一复核。</small></span>
+            <span>协作模式<small>管理员可按依赖串行调度,也可一次 @ 多人并列执行;整批结束后统一复核。</small></span>
             <select v-model="orchestrationMode" aria-label="协作模式">
               <option value="free">自由讨论</option>
-              <option value="host">主持流程</option>
+              <option value="host">管理员协调</option>
             </select>
           </label>
           <div class="dialog-settings">
             <label><input v-model="autoReply" type="checkbox" :aria-label="hostEnabled ? '所有成员无需 @ 也回复' : '启用自动回复'" />{{ hostEnabled ? (orchestrationMode === 'host' ? '自由讨论时无需 @ 也回复' : '所有成员无需 @ 也回复') : '启用自动回复' }}</label>
             <label>最多轮数 <input v-model.number="replyRounds" type="number" min="1" max="12" /></label>
           </div>
+          <p v-if="error" class="create-error" role="alert">{{ error }}</p>
           <footer><button class="quiet-button" type="button" @click="emit('close')">取消</button><button class="solid-button" type="button" :disabled="!valid || busy" @click="create">{{ busy ? '创建中…' : '创建团队' }}</button></footer>
         </section>
       </div>
@@ -224,6 +225,7 @@ header { display: flex; align-items: flex-start; justify-content: space-between;
 .role-mapping { margin-top: 17px; padding: 11px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); }.role-mapping > label { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, 42%); align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--line); }.role-mapping > label:first-of-type { border-top: 0; }.role-mapping > label > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; }.role-mapping strong { display: flex; align-items: center; gap: 6px; font-size: 10px; }.role-mapping strong em { padding: 2px 5px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); font-size: 8px; font-style: normal; font-weight: 600; }.role-mapping small { color: var(--text-muted); font-size: 9px; line-height: 1.4; }.role-mapping select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; outline: none; background: var(--surface-raised); color: var(--text-primary); font-size: 9px; }.role-mapping select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .host-picker { display: grid; grid-template-columns: minmax(0, 1fr) 148px; align-items: center; gap: 12px; margin-top: 13px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; color: var(--text-secondary); font-size: 10px; }.host-picker > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; font-weight: 650; }.host-picker small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.45; }.host-picker select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 10px; }.host-picker select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .dialog-settings { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; padding: 11px; border-radius: 10px; background: var(--surface-soft); }.dialog-settings label { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 10px; }.dialog-settings input[type="checkbox"] { accent-color: var(--accent); }.dialog-settings input[type="number"] { width: 51px; padding: 4px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-raised); color: var(--text-primary); text-align: center; }
+.create-error { margin: 12px 0 0; padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--danger) 34%, var(--line)); border-radius: 9px; background: color-mix(in srgb, var(--danger) 7%, transparent); color: var(--danger); font-size: 9px; line-height: 1.45; }
 footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
 .dialog-fade-enter-active, .dialog-fade-leave-active { transition: opacity 140ms ease; }.dialog-fade-enter-active .create-dialog, .dialog-fade-leave-active .create-dialog { transition: transform 170ms var(--ease-out); }.dialog-fade-enter-from, .dialog-fade-leave-to { opacity: 0; }.dialog-fade-enter-from .create-dialog, .dialog-fade-leave-to .create-dialog { transform: translateY(8px) scale(.985); }
 @media (max-width: 620px) { .preset-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.role-mapping > label { grid-template-columns: 1fr; gap: 6px; }.host-picker { grid-template-columns: 1fr; }.host-picker select { width: 100%; } }

+ 8 - 8
src/client/components/groups/GroupManager.vue

@@ -181,7 +181,7 @@ function canRemoveAgent(agent: GroupAgent): boolean {
 function isRemoteAgent(agent: GroupAgent): boolean { return agent.nodeId !== 'local' }
 
 function removeAgentTitle(agent: GroupAgent): string {
-  return canRemoveAgent(agent) ? '移除' : agent.isHost ? '需要另一位已启用成员才能移除主持人' : '团队必须保留至少一位成员'
+  return canRemoveAgent(agent) ? '移除' : agent.isHost ? '需要另一位已启用成员才能移除管理员' : '团队必须保留至少一位成员'
 }
 
 function openAgentSettings(agent: GroupAgent) {
@@ -275,10 +275,10 @@ function statusLabel(status: GroupAgent['status']): string {
       <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>
+        <span>协作模式<small>管理员可按依赖逐步调度,也可一次 @ 多人并列执行。</small></span>
         <select v-model="form.orchestrationMode" aria-label="协作模式" :disabled="busy || hasActiveAgents" :title="hasActiveAgents ? '请等待当前回复完成或先中断 Agent' : ''" @change="saveRoom">
           <option value="free">自由讨论</option>
-          <option value="host">主持流程</option>
+          <option value="host">管理员协调</option>
         </select>
       </label>
     </section>
@@ -286,8 +286,8 @@ function statusLabel(status: GroupAgent['status']): string {
     <section>
       <div class="section-heading"><h3>成员 <em>{{ agents.length }}/8</em></h3></div>
       <label v-if="hostEnabled" class="host-selector">
-        <span>主持人<small>用户没有明确 @ 时始终负责回应;“无需 @ 也回复”仍独立生效。</small></span>
-        <select :value="hostAgent?.id || ''" aria-label="主持人" :disabled="busy" @change="selectHost">
+        <span>管理员<small>用户没有明确 @ 时始终负责回应;“无需 @ 也回复”仍独立生效。</small></span>
+        <select :value="hostAgent?.id || ''" aria-label="管理员" :disabled="busy" @change="selectHost">
           <option v-for="agent in agents" :key="agent.id" :value="agent.id" :disabled="!agent.enabled && !agent.isHost">{{ agent.displayName }}</option>
         </select>
       </label>
@@ -295,7 +295,7 @@ function statusLabel(status: GroupAgent['status']): string {
       <div class="agent-list">
         <article v-for="agent in agents" :key="agent.id">
           <span class="agent-avatar"><AgentAvatar :name="agent.displayName" :avatar="agent.nodeId === 'local' ? agentAvatars[agent.profile] || '' : ''" :size="32" /><i :class="`status-${agent.status}`" /></span>
-          <span class="agent-copy"><span class="agent-name-line"><strong>{{ agent.displayName }}</strong><em v-if="hostEnabled && agent.isHost" class="host-badge">主持人</em></span><small>{{ agent.profile }}<template v-if="agent.nodeId !== 'local'"> · {{ agent.nodeLabel || agent.nodeId.slice(0, 8) }}</template> · {{ statusLabel(agent.status) }}</small></span>
+          <span class="agent-copy"><span class="agent-name-line"><strong>{{ agent.displayName }}</strong><em v-if="hostEnabled && agent.isHost" class="host-badge">管理员</em></span><small>{{ agent.profile }}<template v-if="agent.nodeId !== 'local'"> · {{ agent.nodeLabel || agent.nodeId.slice(0, 8) }}</template> · {{ statusLabel(agent.status) }}</small></span>
           <button class="agent-action" type="button" :title="`设置 ${agent.displayName}`" :aria-label="`设置${agent.displayName}`" @click="openAgentSettings(agent)"><AppIcon name="settings" :size="14" /></button>
           <button v-if="agent.status === 'running' || agent.status === 'queued'" class="agent-action" type="button" title="中断" @click="emit('interruptAgent', agent.id)"><AppIcon name="stop" :size="13" /></button>
           <button class="agent-action danger" type="button" :title="removeAgentTitle(agent)" :aria-label="`移除${agent.displayName}`" :disabled="!canRemoveAgent(agent) || busy" @click="emit('removeAgent', agent.id)"><AppIcon name="trash" :size="14" /></button>
@@ -322,7 +322,7 @@ function statusLabel(status: GroupAgent['status']): string {
         <header>
           <span>
             <small>Agent 设置</small>
-            <strong class="settings-agent-name">{{ selectedAgent.displayName }}<em v-if="hostEnabled && selectedAgent.isHost" class="host-badge">主持人</em></strong>
+            <strong class="settings-agent-name">{{ selectedAgent.displayName }}<em v-if="hostEnabled && selectedAgent.isHost" class="host-badge">管理员</em></strong>
           </span>
           <button class="agent-settings-close" type="button" aria-label="关闭 Agent 设置" title="关闭 Agent 设置" @click="closeAgentSettings"><AppIcon name="close" :size="17" /></button>
         </header>
@@ -360,7 +360,7 @@ function statusLabel(status: GroupAgent['status']): string {
             <label><input v-model="agentDrafts[selectedAgent.id].enabled" type="checkbox" aria-label="启用" @change="markDirty(selectedAgent.id)" />启用</label>
             <label><input v-model="agentDrafts[selectedAgent.id].replyWithoutMention" type="checkbox" :aria-label="hostEnabled ? '无需 @ 也回复' : '自动回复'" @change="markDirty(selectedAgent.id)" />{{ hostEnabled ? '无需 @ 也回复' : '自动回复' }}</label>
           </div>
-          <p v-if="hostEnabled" class="host-explanation">{{ form.orchestrationMode === 'host' ? '主持流程下此开关暂不触发自动并发;切回自由讨论后继续按原设置生效。' : (selectedAgent.isHost ? '主持人始终处理用户无 @ 消息;此开关仅决定是否自动参与 Agent 发出的无 @ 消息。' : '开启后,该成员会自动参与未明确 @ 的消息;用户无 @ 消息仍由主持人兜底。') }}</p>
+          <p v-if="hostEnabled" class="host-explanation">{{ form.orchestrationMode === 'host' ? '管理员协调下此开关暂不触发自动并发;切回自由讨论后继续按原设置生效。' : (selectedAgent.isHost ? '管理员始终处理用户无 @ 消息;此开关仅决定是否自动参与 Agent 发出的无 @ 消息。' : '开启后,该成员会自动参与未明确 @ 的消息;用户无 @ 消息仍由管理员兜底。') }}</p>
           <p v-if="agentUpdateError?.[selectedAgent.id]" class="agent-save-error" role="alert">{{ agentUpdateError[selectedAgent.id] }}</p>
           <div class="editor-actions">
             <button class="quiet-button" type="button" :disabled="busy || !hasAgentChanges(selectedAgent)" @click="resetAgent(selectedAgent)">取消更改</button>

+ 35 - 24
src/client/views/GroupsView.vue

@@ -47,6 +47,8 @@ const modelOptionsError = ref<Record<string, string>>({})
 const agentUpdateBusy = ref<Record<string, boolean>>({})
 const agentUpdateError = ref<Record<string, string>>({})
 const managerError = ref('')
+const createError = ref('')
+const creatingRoom = ref(false)
 const roomActionMenu = ref<{ roomId: string; topicId?: string; x: number; y: number } | null>(null)
 const topicActionRenaming = ref(false)
 const topicActionRenameValue = ref('')
@@ -199,14 +201,14 @@ const mentionOptions = computed<ComposerOption[]>(() => [
   ...displayAgents.value.map(agent => ({
     id: agent.id,
     label: agent.displayName || agent.profile,
-    detail: groups.hostProtocol && agent.isHost ? `主持人 · ${agent.profile}` : agent.profile,
+    detail: groups.hostProtocol && agent.isHost ? `管理员 · ${agent.profile}` : agent.profile,
     disabled: !agent.enabled,
   })),
 ])
 const roomSubtitle = computed(() => {
   if (!groups.selectedRoom) return '选择或新建一个团队'
-  const host = hostAgent.value ? `主持人 ${hostAgent.value.displayName || hostAgent.value.profile} · ` : ''
-  const mode = groups.selectedRoom.orchestrationMode === 'host' ? '主持流程 · ' : ''
+  const host = hostAgent.value ? `管理员 ${hostAgent.value.displayName || hostAgent.value.profile} · ` : ''
+  const mode = groups.selectedRoom.orchestrationMode === 'host' ? '管理员协调 · ' : ''
   return `${groups.selectedRoom.name} · ${mode}${host}${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复`
 })
 const activeAgentIds = computed(() => {
@@ -321,6 +323,7 @@ async function openSelectedRoomManager() {
 async function openCreateGroup() {
   try { await groups.refreshNodes() }
   catch { /* A node refresh must not prevent creating a local-only group. */ }
+  createError.value = ''
   createOpen.value = true
 }
 
@@ -392,24 +395,32 @@ async function startTopic(roomId: string) {
 
 async function createRoom(payload: { name: string; avatar?: string; members: Array<GroupProfileOption & { description?: string }>; 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,
-      nodeId: member.nodeId,
-      nodeLabel: member.nodeLabel,
-      displayName: member.displayName,
-      description: member.description,
-      replyWithoutMention: payload.autoReply,
-      ...(groups.hostProtocol ? { isHost: member.id === hostProfile } : {}),
-    })),
-    maxReplyRounds: payload.replyRounds,
-    ...(groups.hostFlowProtocol ? { orchestrationMode: payload.orchestrationMode ?? 'free' } : {}),
-  })
-  createOpen.value = false
-  await router.push(groupRoute(detail.id, groups.selectedTopicId))
+  createError.value = ''
+  creatingRoom.value = true
+  try {
+    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,
+        nodeId: member.nodeId,
+        nodeLabel: member.nodeLabel,
+        displayName: member.displayName,
+        description: member.description,
+        replyWithoutMention: payload.autoReply,
+        ...(groups.hostProtocol ? { isHost: member.id === hostProfile } : {}),
+      })),
+      maxReplyRounds: payload.replyRounds,
+      ...(groups.hostFlowProtocol ? { orchestrationMode: payload.orchestrationMode ?? 'free' } : {}),
+    })
+    createOpen.value = false
+    await router.push(groupRoute(detail.id, groups.selectedTopicId))
+  } catch (cause) {
+    createError.value = cause instanceof Error ? cause.message : '创建团队失败'
+  } finally {
+    creatingRoom.value = false
+  }
 }
 
 async function send(payload: ComposerSubmit) {
@@ -690,8 +701,8 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
       >
         <template #header-actions>
           <div class="group-header-actions">
-            <span v-if="hostAgent" class="group-host-chip" :title="`用户未明确 @ 时由 ${hostAgent.displayName || hostAgent.profile} 负责回应`">主持人 {{ hostAgent.displayName || hostAgent.profile }}</span>
-            <div class="group-avatars" aria-label="团队成员"><AgentAvatar v-for="agent in agents.slice(0, 4)" :key="agent.id" :name="agent.name" :avatar="agent.nodeId === 'local' ? agentAvatars[agent.profile || ''] || '' : ''" :size="24" :title="agent.isHost ? `${agent.name} · 主持人` : agent.name" /><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
+            <span v-if="hostAgent" class="group-host-chip" :title="`用户未明确 @ 时由管理员 ${hostAgent.displayName || hostAgent.profile} 负责回应`">管理员 {{ hostAgent.displayName || hostAgent.profile }}</span>
+            <div class="group-avatars" aria-label="团队成员"><AgentAvatar v-for="agent in agents.slice(0, 4)" :key="agent.id" :name="agent.name" :avatar="agent.nodeId === 'local' ? agentAvatars[agent.profile || ''] || '' : ''" :size="24" :title="agent.isHost ? `${agent.name} · 管理员` : agent.name" /><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
             <button class="icon-button" type="button" title="管理团队" aria-label="管理团队" :disabled="!groups.selectedRoom" @click="openSelectedRoomManager"><AppIcon name="dots" /></button>
           </div>
         </template>
@@ -748,7 +759,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
   </WorkspaceView>
 
   <FloatingResourceSearch section="groups" label="搜索团队" :items="roomSidebarItems" @select="selectRoom" />
-  <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" />
+  <CreateGroupDialog :open="createOpen" :profiles="profiles" :avatar-enabled="groups.roomAvatarProtocol" :host-enabled="groups.hostProtocol" :host-flow-enabled="groups.hostFlowProtocol" :room-instructions-enabled="groups.roomInstructionsProtocol" :error="createError" :busy="groups.isLoading || creatingRoom" @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" />
 

+ 15 - 0
tests/client/authProtocol.test.ts

@@ -89,4 +89,19 @@ describe('authentication and CSRF protocol', () => {
       unsubscribe()
     }
   })
+
+  it('surfaces the first structured upstream validation error', async () => {
+    setApiCsrfToken('csrf-validation')
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
+      detail: [{ loc: ['body', 'avatar'], msg: 'Value error, unsupported team avatar', type: 'value_error' }],
+    }), { status: 422, headers: { 'content-type': 'application/json' } })))
+
+    await expect(apiRequest('/api/app/groups/rooms', {
+      method: 'POST',
+      body: { avatar: 'invalid' },
+    })).rejects.toMatchObject({
+      status: 422,
+      message: 'body.avatar:Value error, unsupported team avatar',
+    })
+  })
 })

+ 6 - 6
tests/client/groupHost.test.ts

@@ -157,10 +157,10 @@ describe('group host controls', () => {
       global: { stubs: { teleport: true } },
     })
     await nextTick()
-    await wrapper.get('input[placeholder="例如:产品评审"]').setValue('主持人验收')
+    await wrapper.get('input[placeholder="例如:产品评审"]').setValue('管理员验收')
     const profileButtons = wrapper.findAll('.agent-picker > button')
     await profileButtons[1]!.trigger('click')
-    const host = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
+    const host = wrapper.get<HTMLSelectElement>('select[aria-label="管理员"]')
     await host.setValue('local|yaoer')
     await profileButtons[1]!.trigger('click')
     expect(host.element.value).toBe('local|yaoyao')
@@ -170,7 +170,7 @@ describe('group host controls', () => {
     await wrapper.get('.solid-button').trigger('click')
 
     expect(wrapper.emitted('create')?.[0]?.[0]).toEqual({
-      name: '主持人验收', members: profiles('yaoyao', 'yaoer'), hostProfile: 'local|yaoer', autoReply: true, replyRounds: 3,
+      name: '管理员验收', members: profiles('yaoyao', 'yaoer'), hostProfile: 'local|yaoer', autoReply: true, replyRounds: 3,
     })
     wrapper.unmount()
   })
@@ -182,7 +182,7 @@ describe('group host controls', () => {
       global: { stubs: { teleport: true } },
     })
     await nextTick()
-    expect(wrapper.find('select[aria-label="主持人"]').exists()).toBe(false)
+    expect(wrapper.find('select[aria-label="管理员"]').exists()).toBe(false)
     expect(wrapper.find('input[aria-label="启用自动回复"]').exists()).toBe(true)
     await wrapper.get('input[placeholder="例如:产品评审"]').setValue('v4 房间')
     await wrapper.get('.solid-button').trigger('click')
@@ -251,7 +251,7 @@ describe('group host controls', () => {
         hostEnabled: true,
       },
     })
-    const selector = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
+    const selector = wrapper.get<HTMLSelectElement>('select[aria-label="管理员"]')
     expect(selector.element.value).toBe('agent-1')
     expect(wrapper.findAll('.host-badge')).toHaveLength(1)
     await selector.setValue('agent-2')
@@ -261,7 +261,7 @@ describe('group host controls', () => {
     await wrapper.get('button[aria-label="设置瑶儿"]').trigger('click')
     await nextTick()
     const dialog = document.querySelector('[role="dialog"][aria-label="瑶儿 Agent 设置"]')
-    expect(dialog?.textContent).toContain('主持人始终处理用户无 @ 消息')
+    expect(dialog?.textContent).toContain('管理员始终处理用户无 @ 消息')
     expect(dialog?.querySelector('input[aria-label="无需 @ 也回复"]')).not.toBeNull()
     wrapper.unmount()
   })

+ 12 - 12
tests/e2e/workspace.spec.ts

@@ -55,7 +55,7 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
   await expect(page.getByRole('button', { name: '新建团队' })).toBeVisible()
   await expect(page.getByRole('button', { name: '新建团队' })).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
   await expect(page.getByRole('button', { name: '新建团队' })).toHaveCSS('font-weight', '580')
-  await expect(page.getByRole('button', { name: '管理团队' })).toBeVisible()
+  await expect(page.getByRole('button', { name: '管理团队', exact: true })).toBeVisible()
   await expect(page.getByRole('button', { name: '发送消息' })).toBeVisible()
 
   await page.goto('/files')
@@ -143,7 +143,7 @@ test('shows the team animal avatar in the list and five choices during creation'
 
 test('updates an existing team avatar from the Web manager', async ({ page }) => {
   await page.getByRole('button', { name: '团队' }).click()
-  await page.getByRole('button', { name: '管理团队' }).click()
+  await page.getByRole('button', { name: '管理团队', exact: true }).click()
 
   const manager = page.locator('.group-manager')
   const preview = manager.getByRole('img', { name: '设计与工程协作团队头像' })
@@ -162,34 +162,34 @@ test('updates an existing team avatar from the Web manager', async ({ page }) =>
 
 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('主持人 夭夭')
+  await expect(page.locator('.group-host-chip')).toHaveText('管理员 夭夭')
 
   await page.getByRole('button', { name: '新建团队' }).click()
   const createDialog = page.getByRole('dialog', { name: '新建团队' })
   await createDialog.getByRole('button', { name: /yaoer/ }).click()
-  await createDialog.getByLabel('主持人').selectOption('yaoer')
+  await createDialog.getByLabel('管理员').selectOption({ label: '瑶儿' })
   await expect(createDialog.getByLabel('所有成员无需 @ 也回复')).toBeChecked()
   await createDialog.getByRole('button', { name: '关闭' }).click()
 
-  await page.getByRole('button', { name: '管理团队' }).click()
+  await page.getByRole('button', { name: '管理团队', exact: true }).click()
   const manager = page.locator('.group-manager')
-  await expect(manager.getByLabel('主持人')).toHaveValue('33333333-3333-4333-8333-333333333333')
+  await expect(manager.getByLabel('管理员')).toHaveValue('33333333-3333-4333-8333-333333333333')
   const promoteRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/agents/34343434-3434-4434-8434-343434343434'))
-  await manager.getByLabel('主持人').selectOption({ label: '瑶儿' })
+  await manager.getByLabel('管理员').selectOption({ label: '瑶儿' })
   expect((await promoteRequest).postDataJSON()).toMatchObject({ isHost: true })
-  await expect(manager.locator('article').filter({ hasText: '瑶儿' }).getByText('主持人', { exact: true })).toBeVisible()
-  await expect(page.locator('.group-host-chip')).toHaveText('主持人 瑶儿')
+  await expect(manager.locator('article').filter({ hasText: '瑶儿' }).getByText('管理员', { exact: true })).toBeVisible()
+  await expect(page.locator('.group-host-chip')).toHaveText('管理员 瑶儿')
 
   const restoreRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/agents/33333333-3333-4333-8333-333333333333'))
-  await manager.getByLabel('主持人').selectOption({ label: '夭夭' })
+  await manager.getByLabel('管理员').selectOption({ label: '夭夭' })
   expect((await restoreRequest).postDataJSON()).toMatchObject({ isHost: true })
-  await expect(page.locator('.group-host-chip')).toHaveText('主持人 夭夭')
+  await expect(page.locator('.group-host-chip')).toHaveText('管理员 夭夭')
 })
 
 test('edits every protocol v5 Agent setting with one inspector close control', async ({ page }) => {
   await page.getByRole('button', { name: '团队' }).click()
   await expect(page.getByRole('heading', { name: '设计验收' })).toBeVisible()
-  await page.getByRole('button', { name: '管理团队' }).click()
+  await page.getByRole('button', { name: '管理团队', exact: true }).click()
 
   await expect(page.getByRole('button', { name: '关闭团队管理' })).toHaveCount(1)
   await expect(page.getByRole('button', { name: '关闭预览' })).toHaveCount(0)