GroupsView.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. <script setup lang="ts">
  2. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import AppIcon from '@/components/common/AppIcon.vue'
  5. import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
  6. import EmptyState from '@/components/common/EmptyState.vue'
  7. import ComposerShell from '@/components/composer/ComposerShell.vue'
  8. import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
  9. import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
  10. import GroupManager from '@/components/groups/GroupManager.vue'
  11. import PreviewModal from '@/components/library/PreviewModal.vue'
  12. import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
  13. import type { PreviewMedia } from '@/components/library/ImagePreviewLightbox.vue'
  14. import type { UiLibraryItem } from '@/components/library/types'
  15. import { mediaItemsFromMessages, mediaUrlIdentity, previewItemFromUrl } from '@/components/library/mediaSequence'
  16. import MessageTimeline from '@/components/messages/MessageTimeline.vue'
  17. import type { UiMessage } from '@/components/messages/types'
  18. import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
  19. import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
  20. import { loadComposerFile } from '@/components/workspace/pendingComposer'
  21. import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
  22. import { agentToUi, groupInteraction, groupMessageToUi, roomSidebarItem, roomToUi } from '@/components/workspace/viewModels'
  23. import { useAuthStore } from '@/stores/auth'
  24. import { useGroupsStore } from '@/stores/groups'
  25. const auth = useAuthStore()
  26. const groups = useGroupsStore()
  27. const route = useRoute()
  28. const router = useRouter()
  29. const search = ref('')
  30. const createOpen = ref(false)
  31. const managerOpen = ref(false)
  32. const showThinking = ref(true)
  33. const quoted = ref<UiMessage | null>(null)
  34. const preview = ref<UiLibraryItem | null>(null)
  35. const mediaPreviewIndex = ref<number | null>(null)
  36. const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
  37. const filteredRooms = computed(() => groups.rooms
  38. .filter(room => !room.archived)
  39. .filter(room => !search.value.trim() || `${room.name} ${room.lastMessage?.content || ''}`.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())))
  40. const sidebarItems = computed(() => filteredRooms.value.map(roomSidebarItem))
  41. const messages = computed(() => groups.messages.map(groupMessageToUi))
  42. const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
  43. const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
  44. const agents = computed(() => groups.agents.map(agentToUi))
  45. const connected = computed(() => ['connected', 'ready'].includes(groups.connectionState))
  46. const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
  47. const room = computed(() => groups.selectedRoom ? roomToUi(groups.selectedRoom) : null)
  48. const profiles = computed(() => auth.profiles.map(profile => profile.name))
  49. const availableProfiles = computed(() => profiles.value.filter(profile => !groups.agents.some(agent => agent.profile === profile)))
  50. const uploadsEnabled = computed(() => auth.groupUploadsEnabled)
  51. const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, author: quoted.value.author, content: quoted.value.content } : null)
  52. const mentionNames = computed(() => ['所有人', ...groups.agents.map(agent => agent.displayName || agent.profile)])
  53. const mentionOptions = computed<ComposerOption[]>(() => [
  54. { id: 'all', label: '所有人', detail: '通知房间内全部 Agent' },
  55. ...groups.agents.map(agent => ({ id: agent.id, label: agent.displayName || agent.profile, detail: agent.profile, disabled: !agent.enabled })),
  56. ])
  57. function restoreShowThinking(profile = auth.activeProfile?.name || 'default') {
  58. showThinking.value = readAgentShowThinking(auth.user?.id ?? 'local', profile)
  59. }
  60. function toggleShowThinking() {
  61. showThinking.value = !showThinking.value
  62. writeAgentShowThinking(auth.user?.id ?? 'local', auth.activeProfile?.name || 'default', showThinking.value)
  63. }
  64. async function selectRoom(id: string) {
  65. await router.push(`/groups/${encodeURIComponent(id)}`)
  66. await groups.selectRoom(id)
  67. quoted.value = null
  68. }
  69. async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number }) {
  70. const detail = await groups.createRoom({
  71. name: payload.name,
  72. agents: payload.profiles.map(profile => ({ profile, displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile, replyWithoutMention: payload.autoReply })),
  73. maxReplyRounds: payload.replyRounds,
  74. })
  75. createOpen.value = false
  76. await router.push(`/groups/${encodeURIComponent(detail.id)}`)
  77. }
  78. async function send(payload: ComposerSubmit) {
  79. const prefix = quoted.value ? `> ${quoted.value.content.replace(/\n/g, '\n> ')}\n\n` : ''
  80. try {
  81. await groups.sendMessage(`${prefix}${payload.text}`, payload.mentionIds, payload.files)
  82. quoted.value = null
  83. composer.value?.clearAfterSend()
  84. } catch { /* store publishes the error */ }
  85. }
  86. async function updateRoom(patch: { name?: string; replyRounds?: number }) {
  87. if (!groups.selectedRoom) return
  88. await groups.updateRoom(groups.selectedRoom.id, { name: patch.name, maxReplyRounds: patch.replyRounds })
  89. }
  90. async function addAgent(profile: string) {
  91. if (!groups.selectedRoom) return
  92. await groups.addAgent(groups.selectedRoom.id, { profile, displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile, replyWithoutMention: true })
  93. }
  94. async function updateAgent(id: string, patch: { enabled?: boolean; autoReply?: boolean }) {
  95. if (!groups.selectedRoom) return
  96. await groups.updateAgent(groups.selectedRoom.id, id, { enabled: patch.enabled, replyWithoutMention: patch.autoReply })
  97. }
  98. async function archiveRoom() {
  99. if (!groups.selectedRoom) return
  100. await groups.archiveRoom(groups.selectedRoom.id)
  101. managerOpen.value = false
  102. await router.replace(groups.selectedRoomId ? `/groups/${encodeURIComponent(groups.selectedRoomId)}` : '/groups')
  103. }
  104. function openLocalFile({ name, url }: { name: string; url: string }) {
  105. const item = previewItemFromUrl(name, url)
  106. if (item.kind === 'image' || item.kind === 'video') openMedia(item)
  107. else preview.value = item
  108. }
  109. function openMedia(item: UiLibraryItem) {
  110. const target = mediaUrlIdentity(item.previewUrl || item.downloadUrl || '')
  111. const index = lightboxMedia.value.findIndex(media => mediaUrlIdentity(media.url) === target)
  112. mediaPreviewIndex.value = index >= 0 ? index : null
  113. }
  114. function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number]) {
  115. const item = previewItemFromUrl(attachment.name, attachment.url || '', attachment.id, attachment.kind)
  116. item.size = attachment.size
  117. if (item.kind === 'image' || item.kind === 'video') openMedia(item)
  118. else preview.value = item
  119. }
  120. async function addPreviewToComposer(item: UiLibraryItem) {
  121. if (!uploadsEnabled.value) return
  122. const file = await loadComposerFile(item)
  123. if (!file || !composer.value) return
  124. composer.value.attachFiles([file])
  125. preview.value = null
  126. mediaPreviewIndex.value = null
  127. await nextTick()
  128. composer.value?.focus()
  129. }
  130. async function addMediaToComposer(media: PreviewMedia) {
  131. await addPreviewToComposer(previewItemFromUrl(media.name, media.url, `preview:${media.url}`, media.type))
  132. }
  133. onMounted(async () => {
  134. restoreShowThinking()
  135. try {
  136. await groups.start()
  137. const requested = typeof route.params.roomId === 'string' ? route.params.roomId : ''
  138. if (requested && requested !== groups.selectedRoomId) await groups.selectRoom(requested)
  139. else if (groups.selectedRoomId) await router.replace(`/groups/${encodeURIComponent(groups.selectedRoomId)}`)
  140. } catch { /* availability state renders the error */ }
  141. })
  142. onBeforeUnmount(() => groups.stop())
  143. watch(() => route.params.roomId, async roomId => {
  144. if (typeof roomId === 'string' && roomId && roomId !== groups.selectedRoomId) await groups.selectRoom(roomId)
  145. })
  146. watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThinking(profile) })
  147. </script>
  148. <template>
  149. <WorkspaceView sidebar-title="群聊" :sidebar-subtitle="groups.availability === 'available' ? `${filteredRooms.length} 个活跃房间` : '9119 群聊 v2'" :inspector-open="managerOpen && !!room" @close-inspector="managerOpen = false">
  150. <template #sidebar-action>
  151. <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" title="新建群聊" aria-label="新建群聊" @click="createOpen = true">
  152. <YaoYaoSidebarIcon name="add" />
  153. <span>新建群聊</span>
  154. </button>
  155. </template>
  156. <template #sidebar>
  157. <ResourceSidebar
  158. :items="sidebarItems"
  159. :active-id="groups.selectedRoomId"
  160. :loading="groups.isLoading"
  161. :search="search"
  162. search-placeholder="搜索群聊"
  163. empty-title="还没有群聊"
  164. empty-description="新建群聊,邀请 1–8 个 Agent 一起协作。"
  165. @search="search = $event"
  166. @select="selectRoom"
  167. @more="id => { selectRoom(id); managerOpen = true }"
  168. />
  169. </template>
  170. <div v-if="groups.availability === 'unsupported' || groups.availability === 'unavailable'" class="groups-unavailable">
  171. <EmptyState icon="alert" :title="groups.availability === 'unsupported' ? '群聊协议版本不兼容' : '群聊服务暂不可用'" :description="groups.error || '请确认 9119 已安装 YaoYao 群聊 protocol v2 插件。'" action-label="重新检查" @action="groups.refresh" />
  172. </div>
  173. <div v-else class="groups-workspace">
  174. <MessageTimeline
  175. :messages="messages"
  176. :title="groups.selectedRoom?.name || '群聊'"
  177. :subtitle="groups.selectedRoom ? `${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复` : '选择或新建一个群聊'"
  178. :loading="groups.isLoading"
  179. :has-older="groups.hasMoreBefore"
  180. :connected="connected"
  181. :synced="!!groups.selectedRoom"
  182. :show-tools="showThinking"
  183. :interaction="activeInteraction"
  184. :mention-names="mentionNames"
  185. empty-title="让多个 Agent 一起工作"
  186. empty-description="使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。"
  187. @load-older="groups.loadOlder"
  188. @quote="quoted = $event"
  189. @preview="openAttachment"
  190. @preview-file="openLocalFile"
  191. @approve="activeInteraction && groups.approveInteraction(activeInteraction.id, $event ? 'once' : 'deny')"
  192. @clarify="activeInteraction && groups.clarifyInteraction(activeInteraction.id, $event)"
  193. >
  194. <template #header-actions>
  195. <div class="group-header-actions">
  196. <div class="group-avatars" aria-label="群聊成员"><span v-for="agent in agents.slice(0, 4)" :key="agent.id" :title="agent.name">{{ agent.name.slice(0, 1).toUpperCase() }}</span><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
  197. <button class="icon-button" type="button" title="管理群聊" aria-label="管理群聊" :disabled="!groups.selectedRoom" @click="managerOpen = true"><AppIcon name="dots" /></button>
  198. </div>
  199. </template>
  200. </MessageTimeline>
  201. <p v-if="groups.error" class="group-error" role="alert"><AppIcon name="alert" :size="13" />{{ groups.error }}</p>
  202. <ComposerShell
  203. ref="composer"
  204. mode="group"
  205. :draft-key="`${auth.user?.id || 'local'}:${groups.selectedRoomId || 'new'}`"
  206. :disabled="!groups.selectedRoom || groups.availability !== 'available'"
  207. :streaming="groups.agents.some(agent => ['queued', 'running'].includes(agent.status))"
  208. :sending="groups.isSending"
  209. :tool-trace-visible="showThinking"
  210. :reference="reference"
  211. :mention-options="mentionOptions"
  212. :attachments-enabled="uploadsEnabled"
  213. placeholder="发消息给群聊,输入 @ 提及 Agent"
  214. @send="send"
  215. @stop="groups.agents.filter(agent => ['queued', 'running'].includes(agent.status)).forEach(agent => groups.interruptAgent(agent.id))"
  216. @tool-trace-toggle="toggleShowThinking"
  217. @clear-reference="quoted = null"
  218. />
  219. </div>
  220. <template #inspector>
  221. <GroupManager
  222. v-if="room && groups.selectedRoom"
  223. :room="room"
  224. :agents="agents"
  225. :available-profiles="availableProfiles"
  226. :busy="groups.isLoading"
  227. @close="managerOpen = false"
  228. @update-room="updateRoom"
  229. @add-agent="addAgent"
  230. @update-agent="updateAgent"
  231. @remove-agent="groups.removeAgent(groups.selectedRoom.id, $event)"
  232. @interrupt-agent="groups.interruptAgent($event, groups.selectedRoom.id)"
  233. @archive-room="archiveRoom"
  234. />
  235. </template>
  236. </WorkspaceView>
  237. <CreateGroupDialog :open="createOpen" :profiles="profiles" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
  238. <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
  239. <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
  240. </template>
  241. <style scoped>
  242. .groups-workspace, .groups-unavailable { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; background: var(--conversation-canvas); }.groups-unavailable { overflow: auto; }
  243. .sidebar-primary-action { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 10px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; font-size: 12px; font-weight: 610; text-align: left; transition: background-color 120ms ease; }
  244. .sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
  245. .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
  246. .sidebar-primary-action:disabled { cursor: not-allowed; opacity: .35; }
  247. .group-header-actions { display: flex; align-items: center; gap: 8px; }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
  248. .group-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
  249. @media (max-width: 480px) { .group-avatars { display: none; } }
  250. @media (prefers-reduced-motion: reduce) { .sidebar-primary-action { transition: none; } }
  251. </style>