CreateGroupDialog.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. <script setup lang="ts">
  2. import { computed, ref, watch } from 'vue'
  3. import AppIcon from '@/components/common/AppIcon.vue'
  4. import AgentAvatar from '@/components/common/AgentAvatar.vue'
  5. import TeamAvatar from '@/components/common/TeamAvatar.vue'
  6. import { processTeamAvatarFile, randomTeamAnimalAvatar, TEAM_ANIMAL_AVATARS } from '@/utils/teamAvatar'
  7. import type { GroupProfileOption } from './types'
  8. import { TEAM_PRESETS, type TeamPreset } from './teamPresets'
  9. interface CreateGroupMember extends GroupProfileOption {
  10. description?: string
  11. }
  12. interface CreateGroupPayload {
  13. name: string
  14. avatar?: string
  15. members: CreateGroupMember[]
  16. autoReply: boolean
  17. replyRounds: number
  18. instructions?: string
  19. hostProfile?: string
  20. orchestrationMode?: 'free' | 'host'
  21. }
  22. const props = withDefaults(defineProps<{ open: boolean; profiles: GroupProfileOption[]; avatarEnabled?: boolean; hostEnabled?: boolean; hostFlowEnabled?: boolean; roomInstructionsEnabled?: boolean; error?: string; busy?: boolean }>(), {
  23. avatarEnabled: false,
  24. hostEnabled: false,
  25. hostFlowEnabled: false,
  26. roomInstructionsEnabled: false,
  27. busy: false,
  28. })
  29. const emit = defineEmits<{ close: []; create: [payload: CreateGroupPayload] }>()
  30. const name = ref('')
  31. const instructions = ref('')
  32. const selected = ref<string[]>([])
  33. const selectedPresetId = ref('custom')
  34. const hostProfile = ref('')
  35. const autoReply = ref(true)
  36. const replyRounds = ref(3)
  37. const orchestrationMode = ref<'free' | 'host'>('free')
  38. const avatarMode = ref<'animal' | 'upload'>('animal')
  39. const animalAvatar = ref<string>(TEAM_ANIMAL_AVATARS[0].value)
  40. const avatarDataURL = ref('')
  41. const avatarError = ref('')
  42. const avatarInput = ref<HTMLInputElement>()
  43. const selectedPreset = computed(() => TEAM_PRESETS.find(preset => preset.id === selectedPresetId.value))
  44. const selectedMembers = computed<CreateGroupMember[]>(() => {
  45. if (!selectedPreset.value) return selected.value.flatMap(id => props.profiles.find(profile => profile.id === id) ?? [])
  46. return selectedPreset.value.roles.flatMap((role, index) => {
  47. const profile = props.profiles.find(item => item.id === selected.value[index])
  48. return profile ? [{ ...profile, displayName: role.name, description: role.description }] : []
  49. })
  50. })
  51. const valid = computed(() => name.value.trim().length > 0
  52. && selected.value.length >= 1
  53. && selected.value.length <= 8
  54. && (!selectedPreset.value || selected.value.length === selectedPreset.value.roles.length)
  55. && (!props.hostEnabled || selected.value.includes(hostProfile.value)))
  56. watch(() => props.open, open => {
  57. if (!open) return
  58. name.value = ''
  59. instructions.value = ''
  60. selectedPresetId.value = 'custom'
  61. selected.value = props.profiles.slice(0, 1).map(profile => profile.id)
  62. hostProfile.value = selected.value[0] ?? ''
  63. autoReply.value = true
  64. replyRounds.value = 3
  65. orchestrationMode.value = 'free'
  66. avatarMode.value = 'animal'
  67. animalAvatar.value = randomTeamAnimalAvatar().value
  68. avatarDataURL.value = ''
  69. avatarError.value = ''
  70. }, { immediate: true })
  71. function presetShortage(preset: TeamPreset): number {
  72. return Math.max(0, preset.roles.length - props.profiles.length)
  73. }
  74. function choosePreset(preset?: TeamPreset) {
  75. if (!preset) {
  76. selectedPresetId.value = 'custom'
  77. selected.value = selected.value.slice(0, 8)
  78. if (!selected.value.length && props.profiles[0]) selected.value = [props.profiles[0].id]
  79. hostProfile.value = selected.value[0] ?? ''
  80. orchestrationMode.value = 'free'
  81. autoReply.value = true
  82. return
  83. }
  84. if (presetShortage(preset)) return
  85. selectedPresetId.value = preset.id
  86. name.value = preset.name
  87. instructions.value = preset.instructions
  88. selected.value = props.profiles.slice(0, preset.roles.length).map(profile => profile.id)
  89. const hostIndex = Math.max(0, preset.roles.findIndex(role => role.host))
  90. hostProfile.value = selected.value[hostIndex] ?? selected.value[0] ?? ''
  91. orchestrationMode.value = props.hostFlowEnabled ? 'host' : 'free'
  92. autoReply.value = !props.hostFlowEnabled
  93. replyRounds.value = 3
  94. }
  95. function assignRole(index: number, profileId: string) {
  96. const previousProfileId = selected.value[index]
  97. const occupiedIndex = selected.value.findIndex(id => id === profileId)
  98. const next = [...selected.value]
  99. next[index] = profileId
  100. if (occupiedIndex >= 0 && occupiedIndex !== index && previousProfileId) next[occupiedIndex] = previousProfileId
  101. selected.value = next
  102. const hostIndex = selectedPreset.value?.roles.findIndex(role => role.host) ?? -1
  103. if (hostIndex >= 0) hostProfile.value = next[hostIndex] ?? ''
  104. }
  105. function toggle(profile: GroupProfileOption) {
  106. if (selected.value.includes(profile.id)) selected.value = selected.value.filter(item => item !== profile.id)
  107. else if (selected.value.length < 8) selected.value.push(profile.id)
  108. if (!selected.value.includes(hostProfile.value)) hostProfile.value = selected.value[0] ?? ''
  109. }
  110. async function chooseAvatar(event: Event) {
  111. const file = (event.target as HTMLInputElement).files?.[0]
  112. if (!file) return
  113. try {
  114. avatarDataURL.value = await processTeamAvatarFile(file)
  115. avatarMode.value = 'upload'
  116. avatarError.value = ''
  117. } catch (cause) {
  118. avatarError.value = cause instanceof Error ? cause.message : '处理头像失败'
  119. } finally {
  120. if (avatarInput.value) avatarInput.value.value = ''
  121. }
  122. }
  123. function create() {
  124. if (!valid.value) return
  125. const payload: CreateGroupPayload = {
  126. name: name.value.trim(),
  127. members: selectedMembers.value,
  128. autoReply: autoReply.value,
  129. replyRounds: replyRounds.value,
  130. }
  131. if (props.avatarEnabled) payload.avatar = avatarMode.value === 'upload' ? avatarDataURL.value : animalAvatar.value
  132. if (props.hostEnabled) payload.hostProfile = hostProfile.value
  133. if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
  134. if (props.roomInstructionsEnabled) payload.instructions = instructions.value.trim()
  135. emit('create', payload)
  136. }
  137. </script>
  138. <template>
  139. <Teleport to="body">
  140. <Transition name="dialog-fade">
  141. <div v-if="open" class="dialog-layer" role="presentation" @mousedown.self="emit('close')">
  142. <section class="create-dialog" role="dialog" aria-modal="true" aria-labelledby="create-group-title">
  143. <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>
  144. <section class="preset-picker" aria-labelledby="team-preset-label">
  145. <div class="preset-picker__heading"><span id="team-preset-label">团队预设</span><small>选择后自动分配现有 Agent</small></div>
  146. <div class="preset-grid">
  147. <button type="button" :class="{ selected: selectedPresetId === 'custom' }" @click="choosePreset()">
  148. <strong>自定义团队</strong><small>手动选择 Agent 和协作配置</small><em>自由配置</em>
  149. </button>
  150. <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)">
  151. <strong>{{ preset.name }}</strong><small>{{ preset.summary }}</small><em>{{ presetShortage(preset) ? `还缺 ${presetShortage(preset)} 人` : `${preset.roles.length} 人` }}</em>
  152. </button>
  153. </div>
  154. </section>
  155. <label class="field"><span>团队名称</span><input v-model="name" maxlength="80" autofocus placeholder="例如:产品评审" /></label>
  156. <section v-if="avatarEnabled" class="avatar-picker" aria-labelledby="team-avatar-label">
  157. <TeamAvatar :name="name || '团队'" :avatar="avatarMode === 'upload' ? avatarDataURL : animalAvatar" :size="58" />
  158. <div><strong id="team-avatar-label">团队头像</strong><small>{{ avatarMode === 'animal' ? '选择一个简洁的动物图标' : '使用上传的图片' }}</small><div class="animal-avatar-options" role="radiogroup" aria-label="动物团队头像"><button v-for="option in TEAM_ANIMAL_AVATARS" :key="option.id" type="button" role="radio" :aria-checked="avatarMode === 'animal' && animalAvatar === option.value" :aria-label="option.label" :class="{ selected: avatarMode === 'animal' && animalAvatar === option.value }" @click="animalAvatar = option.value; avatarMode = 'animal'"><img :src="option.src" alt="" /></button></div><p><button class="quiet-button" type="button" @click="animalAvatar = randomTeamAnimalAvatar().value; avatarMode = 'animal'">随机一个</button><button class="quiet-button" type="button" @click="avatarInput?.click()"><AppIcon name="image" :size="14" />上传图片</button></p></div>
  159. <input ref="avatarInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
  160. </section>
  161. <p v-if="avatarError" class="avatar-error" role="alert">{{ avatarError }}</p>
  162. <label v-if="roomInstructionsEnabled" class="field room-instructions"><span>说明 <small>供所有 Agent 查阅的规则和形式准则</small></span><textarea v-model="instructions" maxlength="4000" rows="4" placeholder="例如:先确认事实;结论使用中文;涉及发布必须等待确认。" /></label>
  163. <section v-if="selectedPreset" class="role-mapping" aria-labelledby="role-mapping-label">
  164. <div class="role-mapping__heading"><span id="role-mapping-label">角色分配</span><small>{{ selectedPreset.roles.length }} 个角色已对应当前 Agent</small></div>
  165. <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
  166. <span><strong>{{ role.name }}<em v-if="hostEnabled && role.host">管理员</em></strong><small>{{ role.description }}</small></span>
  167. <select :value="selected[index]" :aria-label="`${role.name}对应的 Agent`" @change="assignRole(index, ($event.target as HTMLSelectElement).value)">
  168. <option v-for="profile in profiles" :key="profile.id" :value="profile.id">{{ profile.displayName }} · {{ profile.nodeLabel }}</option>
  169. </select>
  170. </label>
  171. </section>
  172. <div v-else class="agent-picker">
  173. <div class="agent-picker__heading"><span>选择 Agent</span><small>{{ selected.length }}/8</small></div>
  174. <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)">
  175. <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" />
  176. </button>
  177. <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
  178. </div>
  179. <label v-if="hostEnabled && !selectedPreset" class="host-picker">
  180. <span>管理员<small>用户没有明确 @ 时,管理员始终负责回应。</small></span>
  181. <select v-model="hostProfile" aria-label="管理员">
  182. <option v-for="profileID in selected" :key="profileID" :value="profileID">{{ profiles.find(profile => profile.id === profileID)?.displayName || profileID }}</option>
  183. </select>
  184. </label>
  185. <label v-if="hostFlowEnabled" class="host-picker">
  186. <span>协作模式<small>管理员可按依赖串行调度,也可一次 @ 多人并列执行;整批结束后统一复核。</small></span>
  187. <select v-model="orchestrationMode" aria-label="协作模式">
  188. <option value="free">自由讨论</option>
  189. <option value="host">管理员协调</option>
  190. </select>
  191. </label>
  192. <div class="dialog-settings">
  193. <label><input v-model="autoReply" type="checkbox" :aria-label="hostEnabled ? '所有成员无需 @ 也回复' : '启用自动回复'" />{{ hostEnabled ? (orchestrationMode === 'host' ? '自由讨论时无需 @ 也回复' : '所有成员无需 @ 也回复') : '启用自动回复' }}</label>
  194. <label>最多轮数 <input v-model.number="replyRounds" type="number" min="1" max="12" /></label>
  195. </div>
  196. <p v-if="error" class="create-error" role="alert">{{ error }}</p>
  197. <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>
  198. </section>
  199. </div>
  200. </Transition>
  201. </Teleport>
  202. </template>
  203. <style scoped>
  204. .dialog-layer { position: fixed; z-index: 200; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(5px); }
  205. .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); }
  206. 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; }
  207. .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; }
  208. .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; }
  209. .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; }
  210. .animal-avatar-options { display: flex; gap: 6px; margin-top: 5px; }.animal-avatar-options button { display: grid; width: 34px; height: 34px; place-items: center; padding: 0; overflow: hidden; border: 2px solid transparent; border-radius: 50%; background: transparent; cursor: pointer; }.animal-avatar-options button:hover { border-color: var(--line-strong); }.animal-avatar-options button.selected { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent); }.animal-avatar-options img { width: 100%; height: 100%; object-fit: cover; }
  211. .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; }
  212. .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); }
  213. .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); }
  214. .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; }
  215. .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; }
  216. footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
  217. .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); }
  218. @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%; } }
  219. @media (max-width: 420px) { .preset-grid { grid-template-columns: 1fr; } }
  220. </style>