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

新增团队预设与角色分配

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

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

@@ -5,11 +5,16 @@ import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
 import { processTeamAvatarFile } from '@/utils/teamAvatar'
 import type { GroupProfileOption } from './types'
+import { TEAM_PRESETS, type TeamPreset } from './teamPresets'
+
+interface CreateGroupMember extends GroupProfileOption {
+  description?: string
+}
 
 interface CreateGroupPayload {
   name: string
   avatar?: string
-  members: GroupProfileOption[]
+  members: CreateGroupMember[]
   autoReply: boolean
   replyRounds: number
   instructions?: string
@@ -29,6 +34,7 @@ const emit = defineEmits<{ close: []; create: [payload: CreateGroupPayload] }>()
 const name = ref('')
 const instructions = ref('')
 const selected = ref<string[]>([])
+const selectedPresetId = ref('custom')
 const hostProfile = ref('')
 const autoReply = ref(true)
 const replyRounds = ref(3)
@@ -37,16 +43,25 @@ 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 selectedPreset = computed(() => TEAM_PRESETS.find(preset => preset.id === selectedPresetId.value))
+const selectedMembers = computed<CreateGroupMember[]>(() => {
+  if (!selectedPreset.value) return selected.value.flatMap(id => props.profiles.find(profile => profile.id === id) ?? [])
+  return selectedPreset.value.roles.flatMap((role, index) => {
+    const profile = props.profiles.find(item => item.id === selected.value[index])
+    return profile ? [{ ...profile, displayName: role.name, description: role.description }] : []
+  })
+})
 const valid = computed(() => name.value.trim().length > 0
   && selected.value.length >= 1
   && selected.value.length <= 8
+  && (!selectedPreset.value || selected.value.length === selectedPreset.value.roles.length)
   && (!props.hostEnabled || selected.value.includes(hostProfile.value)))
 
 watch(() => props.open, open => {
   if (!open) return
   name.value = ''
   instructions.value = ''
+  selectedPresetId.value = 'custom'
   selected.value = props.profiles.slice(0, 1).map(profile => profile.id)
   hostProfile.value = selected.value[0] ?? ''
   autoReply.value = true
@@ -57,6 +72,43 @@ watch(() => props.open, open => {
   avatarError.value = ''
 }, { immediate: true })
 
+function presetShortage(preset: TeamPreset): number {
+  return Math.max(0, preset.roles.length - props.profiles.length)
+}
+
+function choosePreset(preset?: TeamPreset) {
+  if (!preset) {
+    selectedPresetId.value = 'custom'
+    selected.value = selected.value.slice(0, 8)
+    if (!selected.value.length && props.profiles[0]) selected.value = [props.profiles[0].id]
+    hostProfile.value = selected.value[0] ?? ''
+    orchestrationMode.value = 'free'
+    autoReply.value = true
+    return
+  }
+  if (presetShortage(preset)) return
+  selectedPresetId.value = preset.id
+  name.value = preset.name
+  instructions.value = preset.instructions
+  selected.value = props.profiles.slice(0, preset.roles.length).map(profile => profile.id)
+  const hostIndex = Math.max(0, preset.roles.findIndex(role => role.host))
+  hostProfile.value = selected.value[hostIndex] ?? selected.value[0] ?? ''
+  orchestrationMode.value = props.hostFlowEnabled ? 'host' : 'free'
+  autoReply.value = !props.hostFlowEnabled
+  replyRounds.value = 3
+}
+
+function assignRole(index: number, profileId: string) {
+  const previousProfileId = selected.value[index]
+  const occupiedIndex = selected.value.findIndex(id => id === profileId)
+  const next = [...selected.value]
+  next[index] = profileId
+  if (occupiedIndex >= 0 && occupiedIndex !== index && previousProfileId) next[occupiedIndex] = previousProfileId
+  selected.value = next
+  const hostIndex = selectedPreset.value?.roles.findIndex(role => role.host) ?? -1
+  if (hostIndex >= 0) hostProfile.value = next[hostIndex] ?? ''
+}
+
 function toggle(profile: GroupProfileOption) {
   if (selected.value.includes(profile.id)) selected.value = selected.value.filter(item => item !== profile.id)
   else if (selected.value.length < 8) selected.value.push(profile.id)
@@ -99,6 +151,17 @@ function create() {
       <div v-if="open" class="dialog-layer" role="presentation" @mousedown.self="emit('close')">
         <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>
+          <section class="preset-picker" aria-labelledby="team-preset-label">
+            <div class="preset-picker__heading"><span id="team-preset-label">团队预设</span><small>选择后自动分配现有 Agent</small></div>
+            <div class="preset-grid">
+              <button type="button" :class="{ selected: selectedPresetId === 'custom' }" @click="choosePreset()">
+                <strong>自定义团队</strong><small>手动选择 Agent 和协作配置</small><em>自由配置</em>
+              </button>
+              <button v-for="preset in TEAM_PRESETS" :key="preset.id" type="button" :class="{ selected: selectedPresetId === preset.id }" :disabled="presetShortage(preset) > 0" :aria-label="`${preset.name},${preset.roles.length} 人${presetShortage(preset) ? `,还缺 ${presetShortage(preset)} 个 Agent` : ''}`" @click="choosePreset(preset)">
+                <strong>{{ preset.name }}</strong><small>{{ preset.summary }}</small><em>{{ presetShortage(preset) ? `还缺 ${presetShortage(preset)} 人` : `${preset.roles.length} 人` }}</em>
+              </button>
+            </div>
+          </section>
           <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" />
@@ -107,14 +170,23 @@ function create() {
           </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">
+          <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>
+              <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>
+            </label>
+          </section>
+          <div v-else 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.id" type="button" :class="{ selected: selected.includes(profile.id) }" :disabled="!selected.includes(profile.id) && selected.length >= 8" @click="toggle(profile)">
               <AgentAvatar :name="profile.displayName" :avatar="profile.avatar || ''" :size="28" /><strong>{{ profile.displayName }}<small>{{ profile.profile }} · {{ profile.nodeLabel }}</small></strong><AppIcon v-if="selected.includes(profile.id)" name="check" :size="15" />
             </button>
             <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
           </div>
-          <label v-if="hostEnabled" class="host-picker">
+          <label v-if="hostEnabled && !selectedPreset" class="host-picker">
             <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>
@@ -140,14 +212,17 @@ function create() {
 
 <style scoped>
 .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); }
+.create-dialog { width: min(680px, 100%); max-height: min(780px, 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; }
+.preset-picker { margin-bottom: 15px; }.preset-picker__heading, .role-mapping__heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.preset-picker__heading small, .role-mapping__heading small { color: var(--text-muted); font-size: 9px; }.preset-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }.preset-grid button { position: relative; display: grid; min-height: 82px; align-content: start; gap: 4px; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); color: var(--text-primary); cursor: pointer; text-align: left; }.preset-grid button:hover { border-color: var(--line-strong); background: var(--surface-hover); }.preset-grid button.selected { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 13%, transparent); }.preset-grid button:disabled { cursor: not-allowed; opacity: .48; }.preset-grid strong { padding-right: 42px; font-size: 10px; }.preset-grid small { color: var(--text-muted); font-size: 9px; line-height: 1.4; }.preset-grid em { position: absolute; top: 9px; right: 9px; color: var(--accent); font-size: 8px; font-style: normal; }
 .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; }
+.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; }
 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: 480px) { .host-picker { grid-template-columns: 1fr; }.host-picker select { width: 100%; } }
+@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%; } }
+@media (max-width: 420px) { .preset-grid { grid-template-columns: 1fr; } }
 </style>

+ 114 - 0
src/client/components/groups/teamPresets.ts

@@ -0,0 +1,114 @@
+export interface TeamPresetRole {
+  name: string
+  description: string
+  host?: boolean
+}
+
+export interface TeamPreset {
+  id: string
+  name: string
+  summary: string
+  instructions: string
+  roles: TeamPresetRole[]
+}
+
+export const TEAM_PRESETS: TeamPreset[] = [
+  {
+    id: 'information-research',
+    name: '信息收集团队',
+    summary: '检索、核验并整理可信资料',
+    instructions: '围绕用户目标拆解调研问题,分别完成资料检索、交叉核验和结构化整理。引用关键来源,明确事实、推断与待确认项,最后由调研负责人汇总可直接使用的结论。',
+    roles: [
+      { name: '调研负责人', description: '拆解问题、分派任务并汇总最终结论', host: true },
+      { name: '信息检索', description: '广泛查找资料、案例和一手来源' },
+      { name: '事实核验', description: '交叉验证关键数据、时效与来源可信度' },
+      { name: '资料整理', description: '归纳要点、证据和待确认事项' },
+    ],
+  },
+  {
+    id: 'product-design',
+    name: '产品设计团队',
+    summary: '从需求洞察推进到可评审方案',
+    instructions: '先澄清目标用户、问题和成功指标,再分别完成用户洞察、交互方案、视觉表达与设计评审。所有决策说明依据、约束和取舍,由产品负责人整合为可执行方案。',
+    roles: [
+      { name: '产品负责人', description: '定义目标、优先级和验收标准', host: true },
+      { name: '用户研究', description: '分析用户、场景、痛点和使用动机' },
+      { name: '交互设计', description: '设计信息架构、流程和交互细节' },
+      { name: '视觉设计', description: '负责界面层级、视觉规范和表现一致性' },
+      { name: '设计评审', description: '检查可用性、无障碍、边界状态和实现风险' },
+    ],
+  },
+  {
+    id: 'software-development',
+    name: '软件开发团队',
+    summary: '覆盖设计、实现、测试与审查',
+    instructions: '先确认需求、现有架构和验收方式,再按前端、后端、测试和审查分工推进。优先复用现有约定,保护无关行为;提交结论时列明改动、风险和真实验证证据。',
+    roles: [
+      { name: '技术负责人', description: '拆解方案、协调依赖并把控技术边界', host: true },
+      { name: '前端开发', description: '负责界面、交互、状态和客户端测试' },
+      { name: '后端开发', description: '负责接口、数据、服务逻辑和兼容性' },
+      { name: '测试工程师', description: '设计并执行功能、回归和边界验证' },
+      { name: '代码审查', description: '检查正确性、安全性、性能和可维护性' },
+    ],
+  },
+  {
+    id: 'copywriting',
+    name: '文案书写团队',
+    summary: '策划、撰写并校对完整内容',
+    instructions: '先明确读者、目标、语气和发布渠道,再完成资料整理、内容结构、文案撰写与校对。保持术语统一和事实准确,由内容策划整合最终版本。',
+    roles: [
+      { name: '内容策划', description: '确定受众、目标、结构和表达策略', host: true },
+      { name: '资料编辑', description: '整理素材、事实、术语和引用依据' },
+      { name: '文案撰写', description: '完成正文、标题和不同渠道版本' },
+      { name: '校对审核', description: '检查事实、逻辑、语气、语法和一致性' },
+    ],
+  },
+  {
+    id: 'project-management',
+    name: '项目管理团队',
+    summary: '协调需求、进度、依赖与风险',
+    instructions: '围绕目标、范围、时间和责任人建立项目计划。持续同步需求变化、进度、依赖、风险与决策,所有行动项必须包含负责人和完成条件,由项目负责人维护统一结论。',
+    roles: [
+      { name: '项目负责人', description: '维护目标、范围、计划和最终决策', host: true },
+      { name: '需求协调', description: '澄清需求、优先级和跨方依赖' },
+      { name: '进度跟踪', description: '跟进里程碑、行动项和交付状态' },
+      { name: '风险管理', description: '识别风险、阻塞并准备应对方案' },
+    ],
+  },
+  {
+    id: 'data-analysis',
+    name: '数据分析团队',
+    summary: '从数据处理到业务洞察汇报',
+    instructions: '先定义问题、指标口径和数据边界,再完成数据处理、指标分析和洞察验证。明确数据质量、假设和不确定性,由分析负责人输出可复核的结论与建议。',
+    roles: [
+      { name: '分析负责人', description: '定义分析问题、口径并整合结论', host: true },
+      { name: '数据处理', description: '负责数据获取、清洗和质量检查' },
+      { name: '指标分析', description: '计算指标、识别趋势并验证假设' },
+      { name: '洞察汇报', description: '将发现转化为图表、结论和行动建议' },
+    ],
+  },
+  {
+    id: 'meeting-collaboration',
+    name: '会议协作团队',
+    summary: '会前准备、会议记录与会后跟进',
+    instructions: '会前整理议题、背景和待决策事项;会中记录关键观点、结论和分歧;会后输出带负责人和截止条件的行动项。由会议主持控制议程并确认最终记录。',
+    roles: [
+      { name: '会议主持', description: '组织议程、推动决策并控制讨论范围', host: true },
+      { name: '议题研究', description: '准备背景资料、备选方案和关键问题' },
+      { name: '会议记录', description: '记录观点、决策、分歧和上下文' },
+      { name: '行动跟进', description: '整理责任人、完成条件并跟踪后续事项' },
+    ],
+  },
+  {
+    id: 'operations',
+    name: '运维保障团队',
+    summary: '发布、监控、排障与安全检查',
+    instructions: '所有操作先确认环境、影响范围、回滚方式和验证标准。发布、监控排障与安全审查分别执行并互相复核,由运维负责人统一判断是否继续、回滚或升级处理。',
+    roles: [
+      { name: '运维负责人', description: '制定方案、控制风险并负责最终决策', host: true },
+      { name: '发布执行', description: '负责变更步骤、配置检查和回滚准备' },
+      { name: '监控排障', description: '观察指标日志、定位故障并验证恢复' },
+      { name: '安全审查', description: '检查权限、数据、依赖和操作安全性' },
+    ],
+  },
+]

+ 2 - 1
src/client/views/GroupsView.vue

@@ -390,7 +390,7 @@ async function startTopic(roomId: string) {
   } catch { /* store publishes the error */ }
 }
 
-async function createRoom(payload: { name: string; avatar?: string; members: GroupProfileOption[]; autoReply: boolean; replyRounds: number; instructions?: string; hostProfile?: string; orchestrationMode?: 'free' | 'host' }) {
+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,
@@ -401,6 +401,7 @@ async function createRoom(payload: { name: string; avatar?: string; members: Gro
       nodeId: member.nodeId,
       nodeLabel: member.nodeLabel,
       displayName: member.displayName,
+      description: member.description,
       replyWithoutMention: payload.autoReply,
       ...(groups.hostProtocol ? { isHost: member.id === hostProfile } : {}),
     })),

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

@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
 import type { GroupAgent, GroupMessage, GroupRoomSummary } from '@shared/types'
 import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
+import { TEAM_PRESETS } from '@/components/groups/teamPresets'
 import { groupMessageToUi, roomSidebarItem } from '@/components/workspace/viewModels'
 
 const profiles = (...names: string[]) => names.map(name => ({
@@ -20,6 +21,68 @@ function agent(id: string, profile: string, displayName: string, isHost: boolean
 }
 
 describe('group host controls', () => {
+  it('provides office and development presets with unique roles within the Agent limit', () => {
+    expect(TEAM_PRESETS.map(preset => preset.name)).toEqual([
+      '信息收集团队', '产品设计团队', '软件开发团队', '文案书写团队',
+      '项目管理团队', '数据分析团队', '会议协作团队', '运维保障团队',
+    ])
+    for (const preset of TEAM_PRESETS) {
+      expect(preset.roles.length).toBeGreaterThanOrEqual(4)
+      expect(preset.roles.length).toBeLessThanOrEqual(8)
+      expect(new Set(preset.roles.map(role => role.name)).size).toBe(preset.roles.length)
+      expect(preset.roles.filter(role => role.host)).toHaveLength(1)
+    }
+  })
+
+  it('maps preset roles onto existing Agents and submits their role descriptions', async () => {
+    const choices = profiles('yaoyao', 'yaoer', 'reviewer', 'writer')
+    const wrapper = mount(CreateGroupDialog, {
+      attachTo: document.body,
+      props: {
+        open: true,
+        profiles: choices,
+        hostEnabled: true,
+        hostFlowEnabled: true,
+        roomInstructionsEnabled: true,
+      },
+      global: { stubs: { teleport: true } },
+    })
+    await wrapper.get('button[aria-label^="信息收集团队"]').trigger('click')
+    expect(wrapper.get<HTMLInputElement>('input[placeholder="例如:产品评审"]').element.value).toBe('信息收集团队')
+    expect(wrapper.findAll('.role-mapping select')).toHaveLength(4)
+    await wrapper.get<HTMLSelectElement>('select[aria-label="信息检索对应的 Agent"]').setValue('local|yaoyao')
+    await wrapper.get<HTMLSelectElement>('select[aria-label="信息检索对应的 Agent"]').setValue('local|reviewer')
+    await wrapper.get('.solid-button').trigger('click')
+
+    const payload = wrapper.emitted('create')?.[0]?.[0] as { members: Array<{ profile: string; displayName: string; description: string }>; hostProfile: string; autoReply: boolean; orchestrationMode: string; instructions: string }
+    expect(payload.members.map(member => [member.profile, member.displayName])).toEqual([
+      ['yaoer', '调研负责人'],
+      ['reviewer', '信息检索'],
+      ['yaoyao', '事实核验'],
+      ['writer', '资料整理'],
+    ])
+    expect(payload.members.every(member => member.description.length > 0)).toBe(true)
+    expect(payload.hostProfile).toBe('local|yaoer')
+    expect(payload.autoReply).toBe(false)
+    expect(payload.orchestrationMode).toBe('host')
+    expect(payload.instructions).toContain('交叉核验')
+    wrapper.unmount()
+  })
+
+  it('disables presets when the current Agent count is insufficient', async () => {
+    const wrapper = mount(CreateGroupDialog, {
+      attachTo: document.body,
+      props: { open: true, profiles: profiles('yaoyao', 'yaoer', 'reviewer', 'writer') },
+      global: { stubs: { teleport: true } },
+    })
+    const softwarePreset = wrapper.get<HTMLButtonElement>('button[aria-label^="软件开发团队"]')
+    expect(softwarePreset.element.disabled).toBe(true)
+    expect(softwarePreset.text()).toContain('还缺 1 人')
+    await softwarePreset.trigger('click')
+    expect(wrapper.find('.role-mapping').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
   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,

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

@@ -143,6 +143,9 @@ test('shows the team avatar in the list and automatic avatar controls during cre
 
   await page.getByRole('button', { name: '新建团队' }).click()
   const dialog = page.getByRole('dialog', { name: '新建团队' })
+  await expect(dialog.getByRole('region', { name: '团队预设' })).toBeVisible()
+  await expect(dialog.getByRole('button', { name: /信息收集团队/ })).toBeDisabled()
+  await expect(dialog.getByRole('button', { name: /信息收集团队/ })).toContainText('还缺 2 人')
   await expect(dialog.getByText('自动组合已选 Agent 的头像')).toBeVisible()
   await expect(dialog.getByRole('button', { name: '上传图片' })).toBeVisible()
   await expect(dialog.getByRole('button', { name: '自动生成' })).toBeVisible()