GroupsView.vue 18 KB

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