CreateGroupDialog.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  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 type { GroupProfileOption } from './types'
  6. interface CreateGroupPayload {
  7. name: string
  8. members: GroupProfileOption[]
  9. autoReply: boolean
  10. replyRounds: number
  11. instructions?: string
  12. hostProfile?: string
  13. orchestrationMode?: 'free' | 'host'
  14. }
  15. const props = withDefaults(defineProps<{ open: boolean; profiles: GroupProfileOption[]; hostEnabled?: boolean; hostFlowEnabled?: boolean; roomInstructionsEnabled?: boolean; busy?: boolean }>(), {
  16. hostEnabled: false,
  17. hostFlowEnabled: false,
  18. roomInstructionsEnabled: false,
  19. busy: false,
  20. })
  21. const emit = defineEmits<{ close: []; create: [payload: CreateGroupPayload] }>()
  22. const name = ref('')
  23. const instructions = ref('')
  24. const selected = ref<string[]>([])
  25. const hostProfile = ref('')
  26. const autoReply = ref(true)
  27. const replyRounds = ref(3)
  28. const orchestrationMode = ref<'free' | 'host'>('free')
  29. const valid = computed(() => name.value.trim().length > 0
  30. && selected.value.length >= 1
  31. && selected.value.length <= 8
  32. && (!props.hostEnabled || selected.value.includes(hostProfile.value)))
  33. watch(() => props.open, open => {
  34. if (!open) return
  35. name.value = ''
  36. instructions.value = ''
  37. selected.value = props.profiles.slice(0, 1).map(profile => profile.id)
  38. hostProfile.value = selected.value[0] ?? ''
  39. autoReply.value = true
  40. replyRounds.value = 3
  41. orchestrationMode.value = 'free'
  42. }, { immediate: true })
  43. function toggle(profile: GroupProfileOption) {
  44. if (selected.value.includes(profile.id)) selected.value = selected.value.filter(item => item !== profile.id)
  45. else if (selected.value.length < 8) selected.value.push(profile.id)
  46. if (!selected.value.includes(hostProfile.value)) hostProfile.value = selected.value[0] ?? ''
  47. }
  48. function create() {
  49. if (!valid.value) return
  50. const payload: CreateGroupPayload = {
  51. name: name.value.trim(),
  52. members: selected.value.flatMap(id => props.profiles.find(profile => profile.id === id) ?? []),
  53. autoReply: autoReply.value,
  54. replyRounds: replyRounds.value,
  55. }
  56. if (props.hostEnabled) payload.hostProfile = hostProfile.value
  57. if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
  58. if (props.roomInstructionsEnabled) payload.instructions = instructions.value.trim()
  59. emit('create', payload)
  60. }
  61. </script>
  62. <template>
  63. <Teleport to="body">
  64. <Transition name="dialog-fade">
  65. <div v-if="open" class="dialog-layer" role="presentation" @mousedown.self="emit('close')">
  66. <section class="create-dialog" role="dialog" aria-modal="true" aria-labelledby="create-group-title">
  67. <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>
  68. <label class="field"><span>团队名称</span><input v-model="name" maxlength="80" autofocus placeholder="例如:产品评审" /></label>
  69. <label v-if="roomInstructionsEnabled" class="field room-instructions"><span>说明 <small>供所有 Agent 查阅的规则和形式准则</small></span><textarea v-model="instructions" maxlength="4000" rows="4" placeholder="例如:先确认事实;结论使用中文;涉及发布必须等待确认。" /></label>
  70. <div class="agent-picker">
  71. <div class="agent-picker__heading"><span>选择 Agent</span><small>{{ selected.length }}/8</small></div>
  72. <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)">
  73. <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" />
  74. </button>
  75. <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
  76. </div>
  77. <label v-if="hostEnabled" class="host-picker">
  78. <span>主持人<small>用户没有明确 @ 时,主持人始终负责回应。</small></span>
  79. <select v-model="hostProfile" aria-label="主持人">
  80. <option v-for="profileID in selected" :key="profileID" :value="profileID">{{ profiles.find(profile => profile.id === profileID)?.displayName || profileID }}</option>
  81. </select>
  82. </label>
  83. <label v-if="hostFlowEnabled" class="host-picker">
  84. <span>协作模式<small>主持人可按依赖串行调度,也可一次 @ 多人并列执行;整批结束后统一复核。</small></span>
  85. <select v-model="orchestrationMode" aria-label="协作模式">
  86. <option value="free">自由讨论</option>
  87. <option value="host">主持流程</option>
  88. </select>
  89. </label>
  90. <div class="dialog-settings">
  91. <label><input v-model="autoReply" type="checkbox" :aria-label="hostEnabled ? '所有成员无需 @ 也回复' : '启用自动回复'" />{{ hostEnabled ? (orchestrationMode === 'host' ? '自由讨论时无需 @ 也回复' : '所有成员无需 @ 也回复') : '启用自动回复' }}</label>
  92. <label>最多轮数 <input v-model.number="replyRounds" type="number" min="1" max="12" /></label>
  93. </div>
  94. <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>
  95. </section>
  96. </div>
  97. </Transition>
  98. </Teleport>
  99. </template>
  100. <style scoped>
  101. .dialog-layer { position: fixed; z-index: 200; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(5px); }
  102. .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); }
  103. 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; }
  104. .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; }
  105. .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; }
  106. .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); }
  107. .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; }
  108. footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
  109. .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); }
  110. @media (max-width: 480px) { .host-picker { grid-template-columns: 1fr; }.host-picker select { width: 100%; } }
  111. </style>