GroupsView.vue 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  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, GroupRoomSummary, GroupTopicSummary, 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 FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
  22. import type { SidebarItem } from '@/components/app/types'
  23. import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
  24. import { loadComposerFile } from '@/components/workspace/pendingComposer'
  25. import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
  26. import { agentToUi, groupInteraction, groupMessageToUi, roomSidebarItem, roomToUi } from '@/components/workspace/viewModels'
  27. import { getModels } from '@/api/profiles'
  28. import { useAuthStore } from '@/stores/auth'
  29. import { useGroupsStore } from '@/stores/groups'
  30. const auth = useAuthStore()
  31. const groups = useGroupsStore()
  32. const route = useRoute()
  33. const router = useRouter()
  34. const createOpen = ref(false)
  35. const managerOpen = ref(false)
  36. const showThinking = ref(true)
  37. const quoted = ref<UiMessage | null>(null)
  38. const preview = ref<UiLibraryItem | null>(null)
  39. const mediaPreviewIndex = ref<number | null>(null)
  40. const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
  41. const modelOptionsByProfile = ref<Record<string, ModelOption[]>>({})
  42. const modelOptionsLoading = ref<Record<string, boolean>>({})
  43. const modelOptionsError = ref<Record<string, string>>({})
  44. const agentUpdateBusy = ref<Record<string, boolean>>({})
  45. const agentUpdateError = ref<Record<string, string>>({})
  46. const managerError = ref('')
  47. const roomActionMenu = ref<{ roomId: string; topicId?: string; x: number; y: number } | null>(null)
  48. const topicActionRenaming = ref(false)
  49. const topicActionRenameValue = ref('')
  50. const topicListRoomId = ref<string | null>(null)
  51. const archivedOverlayOpen = ref(false)
  52. const archivedRoomList = ref<GroupRoomSummary[]>([])
  53. const archivedTopicList = ref<GroupTopicSummary[]>([])
  54. const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
  55. const roomSidebarItems = computed(() => activeRooms.value.map(roomSidebarItem))
  56. const pinnedTopics = computed(() => groups.pinnedTopics.filter(topic => !topic.archived).sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id)))
  57. const topicListRoom = computed(() => topicListRoomId.value ? activeRooms.value.find(room => room.id === topicListRoomId.value) : undefined)
  58. const sidebarSubtitle = computed(() => topicListRoom.value
  59. ? `${topicListRoom.value.agentCount} 个 Agent`
  60. : groups.availability === 'available' ? `${activeRooms.value.length} 个活跃房间` : `9119 群聊 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL}`)
  61. const GROUP_LIST_BACK_ID = 'group-list'
  62. const NEW_TOPIC_ID = 'new-topic'
  63. function topicSidebarItemId(roomId: string, topicId: string): string { return `topic:${roomId}:${topicId}` }
  64. function topicFromSidebarItemId(id: string): { roomId: string; topicId: string } | undefined {
  65. const match = /^topic:([^:]+):([^:]+)$/.exec(id)
  66. return match ? { roomId: match[1]!, topicId: match[2]! } : undefined
  67. }
  68. function topicTimeSection(updatedAt: number): string {
  69. const timestamp = updatedAt < 10_000_000_000 ? updatedAt * 1000 : updatedAt
  70. const today = new Date()
  71. today.setHours(0, 0, 0, 0)
  72. const yesterday = new Date(today)
  73. yesterday.setDate(yesterday.getDate() - 1)
  74. if (timestamp >= today.getTime()) return '今天'
  75. if (timestamp >= yesterday.getTime()) return '昨天'
  76. return '更早'
  77. }
  78. const sidebarItems = computed<SidebarItem[]>(() => {
  79. const room = topicListRoom.value
  80. if (!room) return activeRooms.value.map(item => ({ ...roomSidebarItem(item), section: undefined }))
  81. const items: SidebarItem[] = [{
  82. id: GROUP_LIST_BACK_ID,
  83. title: '返回群聊记录',
  84. section: room.name,
  85. icon: 'chevron-left',
  86. showMore: false,
  87. }, {
  88. id: NEW_TOPIC_ID,
  89. title: '新建话题',
  90. icon: 'plus',
  91. showMore: false,
  92. }]
  93. const roomTopics = groups.topicsForRoom(room.id)
  94. items.push(...roomTopics.filter(topic => topic.pinned).map(topic => ({
  95. id: topicSidebarItemId(room.id, topic.id),
  96. title: topic.title,
  97. subtitle: topic.preview || `${topic.messageCount} 条消息`,
  98. meta: topic.unreadCount ? `${topic.unreadCount} 未读` : `${topic.messageCount} 条`,
  99. unread: topic.unreadCount,
  100. pinned: true,
  101. section: '话题置顶',
  102. icon: 'topic' as const,
  103. topic: true,
  104. showMore: false,
  105. active: topic.id === groups.selectedTopicId,
  106. })))
  107. items.push(...roomTopics.filter(topic => !topic.pinned).map(topic => ({
  108. id: topicSidebarItemId(room.id, topic.id),
  109. title: topic.title,
  110. subtitle: topic.preview || `${topic.messageCount} 条消息`,
  111. meta: topic.unreadCount ? `${topic.unreadCount} 未读` : `${topic.messageCount} 条`,
  112. unread: topic.unreadCount,
  113. section: topicTimeSection(topic.updatedAt),
  114. icon: 'topic' as const,
  115. topic: true,
  116. showMore: false,
  117. active: topic.id === groups.selectedTopicId,
  118. })))
  119. if (room.id === groups.selectedRoomId && groups.selectedTopicId && !groups.selectedTopic) {
  120. items.push({
  121. id: topicSidebarItemId(room.id, groups.selectedTopicId),
  122. title: '新话题',
  123. subtitle: '发送第一条消息以创建',
  124. section: '今天',
  125. icon: 'topic',
  126. topic: true,
  127. showMore: false,
  128. active: true,
  129. })
  130. }
  131. return items
  132. })
  133. const messages = computed(() => groups.messages.map(groupMessageToUi))
  134. const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
  135. 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))
  136. const agents = computed(() => groups.agents.map(agentToUi))
  137. const hostAgent = computed(() => groups.hostProtocol ? groups.agents.find(agent => agent.isHost) : undefined)
  138. const connected = computed(() => ['connected', 'ready'].includes(groups.connectionState))
  139. const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
  140. const room = computed(() => groups.selectedRoom ? roomToUi(groups.selectedRoom) : null)
  141. const profiles = computed(() => auth.profiles.map(profile => profile.name))
  142. const availableProfiles = computed(() => profiles.value.filter(profile => !groups.agents.some(agent => agent.profile === profile)))
  143. const uploadsEnabled = computed(() => auth.groupUploadsEnabled)
  144. const managerBusy = computed(() => groups.isLoading || Object.values(agentUpdateBusy.value).some(Boolean))
  145. const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, author: quoted.value.author, content: quoted.value.content } : null)
  146. const mentionNames = computed(() => ['所有人', ...groups.agents.map(agent => agent.displayName || agent.profile)])
  147. const mentionOptions = computed<ComposerOption[]>(() => [
  148. { id: 'all', label: '所有人', detail: '通知房间内全部 Agent' },
  149. ...groups.agents.map(agent => ({
  150. id: agent.id,
  151. label: agent.displayName || agent.profile,
  152. detail: groups.hostProtocol && agent.isHost ? `主持人 · ${agent.profile}` : agent.profile,
  153. disabled: !agent.enabled,
  154. })),
  155. ])
  156. const roomSubtitle = computed(() => {
  157. if (!groups.selectedRoom) return '选择或新建一个群聊'
  158. const host = hostAgent.value ? `主持人 ${hostAgent.value.displayName || hostAgent.value.profile} · ` : ''
  159. const mode = groups.selectedRoom.orchestrationMode === 'host' ? '主持流程 · ' : ''
  160. return `${groups.selectedRoom.name} · ${mode}${host}${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复`
  161. })
  162. const activeAgentIds = computed(() => {
  163. if (!groups.topicProtocol) {
  164. return new Set(groups.agents.filter(agent => ['queued', 'running'].includes(agent.status)).map(agent => agent.id))
  165. }
  166. return new Set((groups.selectedRoom?.runs ?? [])
  167. .filter(run => run.topicId === groups.selectedTopicId && ['queued', 'running'].includes(run.status))
  168. .map(run => run.agentId))
  169. })
  170. const typingAgentIds = computed(() => {
  171. if (!groups.topicProtocol) return new Set(groups.agents.filter(agent => agent.status === 'running').map(agent => agent.id))
  172. return new Set((groups.selectedRoom?.runs ?? [])
  173. .filter(run => run.topicId === groups.selectedTopicId && run.status === 'running')
  174. .map(run => run.agentId))
  175. })
  176. const typingAgentNames = computed(() => [...new Set(groups.agents
  177. .filter(agent => typingAgentIds.value.has(agent.id))
  178. .map(agent => agent.displayName || agent.profile))])
  179. const typingActivity = computed(() => {
  180. if (!connected.value) return ''
  181. const names = typingAgentNames.value
  182. if (!names.length) return ''
  183. if (names.length <= 3) return `${names.join('、')}正在输入…`
  184. return `${names.slice(0, 2).join('、')}等 ${names.length} 个 Agent 正在输入…`
  185. })
  186. function groupRoute(roomId = groups.selectedRoomId, topicId = groups.selectedTopicId): string {
  187. if (!roomId) return '/groups'
  188. const roomPath = `/groups/${encodeURIComponent(roomId)}`
  189. return groups.topicProtocol && topicId ? `${roomPath}/${encodeURIComponent(topicId)}` : roomPath
  190. }
  191. const roomActionMenuStyle = computed(() => roomActionMenu.value
  192. ? { left: `${roomActionMenu.value.x}px`, top: `${roomActionMenu.value.y}px` }
  193. : {})
  194. const actionTopic = computed(() => {
  195. const action = roomActionMenu.value
  196. if (!action?.topicId) return undefined
  197. return groups.topicsForRoom(action.roomId).find(item => item.id === action.topicId)
  198. || groups.pinnedTopics.find(item => item.id === action.topicId)
  199. })
  200. function restoreShowThinking(profile = auth.activeProfile?.name || 'default') {
  201. showThinking.value = readAgentShowThinking(auth.user?.id ?? 'local', profile)
  202. }
  203. function toggleShowThinking() {
  204. showThinking.value = !showThinking.value
  205. writeAgentShowThinking(auth.user?.id ?? 'local', auth.activeProfile?.name || 'default', showThinking.value)
  206. }
  207. function stopActiveTopic() {
  208. for (const agentId of activeAgentIds.value) void groups.interruptAgent(agentId).catch(() => undefined)
  209. }
  210. async function selectRoom(id: string) {
  211. try { await groups.selectRoom(id) }
  212. catch { return }
  213. topicListRoomId.value = id
  214. await router.push(groupRoute())
  215. quoted.value = null
  216. }
  217. async function selectTopic(id: string) {
  218. if (!id || id === groups.selectedTopicId) return
  219. try { await groups.selectTopic(id) }
  220. catch { return }
  221. await router.push(groupRoute())
  222. quoted.value = null
  223. }
  224. async function selectSidebarItem(id: string) {
  225. if (id === GROUP_LIST_BACK_ID) {
  226. topicListRoomId.value = null
  227. return
  228. }
  229. if (id === NEW_TOPIC_ID && topicListRoom.value) {
  230. await startTopic(topicListRoom.value.id)
  231. return
  232. }
  233. const topic = topicFromSidebarItemId(id)
  234. if (topic) {
  235. if (topic.roomId !== groups.selectedRoomId) {
  236. try { await groups.selectRoom(topic.roomId, topic.topicId) }
  237. catch { return }
  238. topicListRoomId.value = topic.roomId
  239. await router.push(groupRoute())
  240. quoted.value = null
  241. return
  242. }
  243. await selectTopic(topic.topicId)
  244. return
  245. }
  246. await selectRoom(id)
  247. }
  248. async function openRoomManager(id: string) {
  249. if (topicFromSidebarItemId(id)) return
  250. await selectRoom(id)
  251. managerOpen.value = true
  252. }
  253. function openRoomActions(roomId: string, event: MouseEvent) {
  254. const topic = topicFromSidebarItemId(roomId)
  255. const width = 174
  256. const height = topic ? 80 : 80
  257. const inset = 8
  258. if (topic) {
  259. topicActionRenaming.value = false
  260. topicActionRenameValue.value = groups.topicsForRoom(topic.roomId).find(item => item.id === topic.topicId)?.title
  261. || groups.pinnedTopics.find(item => item.id === topic.topicId)?.title
  262. || ''
  263. roomActionMenu.value = {
  264. roomId: topic.roomId,
  265. topicId: topic.topicId,
  266. x: Math.max(inset, Math.min(event.clientX, window.innerWidth - width - inset)),
  267. y: Math.max(inset, Math.min(event.clientY, window.innerHeight - height - inset)),
  268. }
  269. return
  270. }
  271. if (!groups.topicProtocol || !activeRooms.value.some(room => room.id === roomId)) return
  272. roomActionMenu.value = {
  273. roomId,
  274. x: Math.max(inset, Math.min(event.clientX, window.innerWidth - width - inset)),
  275. y: Math.max(inset, Math.min(event.clientY, window.innerHeight - height - inset)),
  276. }
  277. }
  278. function openTopicActions(roomId: string, topicId: string, event: MouseEvent) {
  279. openRoomActions(topicSidebarItemId(roomId, topicId), event)
  280. }
  281. function closeRoomActions() {
  282. roomActionMenu.value = null
  283. topicActionRenaming.value = false
  284. topicActionRenameValue.value = ''
  285. }
  286. function handleRoomActionPointer(event: PointerEvent) {
  287. if (!roomActionMenu.value || (event.target as HTMLElement).closest('.group-room-actions')) return
  288. closeRoomActions()
  289. }
  290. function handleRoomActionKey(event: KeyboardEvent) {
  291. if (event.key === 'Escape' && roomActionMenu.value) closeRoomActions()
  292. }
  293. async function startTopicFromRoomAction() {
  294. const roomId = roomActionMenu.value?.roomId
  295. if (roomActionMenu.value?.topicId) return
  296. closeRoomActions()
  297. if (!roomId) return
  298. await startTopic(roomId)
  299. }
  300. async function startTopic(roomId: string) {
  301. try {
  302. await groups.selectRoom(roomId)
  303. topicListRoomId.value = roomId
  304. const topicId = await groups.startNewTopic()
  305. if (!topicId) return
  306. await router.push(groupRoute(roomId, topicId))
  307. quoted.value = null
  308. await nextTick()
  309. composer.value?.focus()
  310. } catch { /* store publishes the error */ }
  311. }
  312. async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number; hostProfile?: string; orchestrationMode?: 'free' | 'host' }) {
  313. const hostProfile = payload.hostProfile || payload.profiles[0]
  314. const detail = await groups.createRoom({
  315. name: payload.name,
  316. agents: payload.profiles.map(profile => ({
  317. profile,
  318. displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile,
  319. replyWithoutMention: payload.autoReply,
  320. ...(groups.hostProtocol ? { isHost: profile === hostProfile } : {}),
  321. })),
  322. maxReplyRounds: payload.replyRounds,
  323. ...(groups.hostFlowProtocol ? { orchestrationMode: payload.orchestrationMode ?? 'free' } : {}),
  324. })
  325. createOpen.value = false
  326. await router.push(groupRoute(detail.id, groups.selectedTopicId))
  327. }
  328. async function send(payload: ComposerSubmit) {
  329. const prefix = quoted.value ? `> ${quoted.value.content.replace(/\n/g, '\n> ')}\n\n` : ''
  330. try {
  331. await groups.sendMessage(`${prefix}${payload.text}`, payload.mentionIds, payload.files)
  332. quoted.value = null
  333. composer.value?.clearAfterSend()
  334. } catch { /* store publishes the error */ }
  335. }
  336. async function updateRoom(patch: { name?: string; replyRounds?: number; orchestrationMode?: 'free' | 'host' }) {
  337. if (!groups.selectedRoom) return
  338. await groups.updateRoom(groups.selectedRoom.id, {
  339. name: patch.name,
  340. maxReplyRounds: patch.replyRounds,
  341. ...(groups.hostFlowProtocol ? { orchestrationMode: patch.orchestrationMode } : {}),
  342. })
  343. }
  344. async function addAgent(profile: string) {
  345. if (!groups.selectedRoom) return
  346. await groups.addAgent(groups.selectedRoom.id, {
  347. profile,
  348. displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile,
  349. replyWithoutMention: true,
  350. ...(groups.hostProtocol ? { isHost: false } : {}),
  351. })
  352. }
  353. type AgentSettingsPatch = Partial<Pick<GroupAgent,
  354. 'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'isHost' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
  355. async function loadAgentModels(profile: string) {
  356. if (modelOptionsLoading.value[profile] || Object.prototype.hasOwnProperty.call(modelOptionsByProfile.value, profile)) return
  357. modelOptionsLoading.value = { ...modelOptionsLoading.value, [profile]: true }
  358. modelOptionsError.value = { ...modelOptionsError.value, [profile]: '' }
  359. try {
  360. modelOptionsByProfile.value = { ...modelOptionsByProfile.value, [profile]: await getModels(profile) }
  361. } catch (cause) {
  362. modelOptionsError.value = { ...modelOptionsError.value, [profile]: cause instanceof Error ? cause.message : '模型选项加载失败' }
  363. } finally {
  364. modelOptionsLoading.value = { ...modelOptionsLoading.value, [profile]: false }
  365. }
  366. }
  367. async function updateAgent(id: string, patch: AgentSettingsPatch) {
  368. if (!groups.selectedRoom) return
  369. managerError.value = ''
  370. agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: true }
  371. agentUpdateError.value = { ...agentUpdateError.value, [id]: '' }
  372. try { await groups.updateAgent(groups.selectedRoom.id, id, patch) }
  373. catch (cause) {
  374. const message = cause instanceof Error ? cause.message : 'Agent 设置保存失败'
  375. agentUpdateError.value = { ...agentUpdateError.value, [id]: message }
  376. managerError.value = message
  377. }
  378. finally { agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: false } }
  379. }
  380. async function removeAgent(id: string) {
  381. if (!groups.selectedRoom) return
  382. managerError.value = ''
  383. agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: true }
  384. try { await groups.removeAgent(groups.selectedRoom.id, id) }
  385. catch (cause) { managerError.value = cause instanceof Error ? cause.message : '移除 Agent 失败' }
  386. finally { agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: false } }
  387. }
  388. function clearAgentError(id: string) {
  389. if (!agentUpdateError.value[id]) return
  390. agentUpdateError.value = { ...agentUpdateError.value, [id]: '' }
  391. managerError.value = ''
  392. }
  393. async function archiveRoom() {
  394. if (!groups.selectedRoom) return
  395. await groups.archiveRoom(groups.selectedRoom.id)
  396. managerOpen.value = false
  397. await router.replace(groupRoute())
  398. }
  399. async function openArchivedOverlay() {
  400. archivedOverlayOpen.value = true
  401. archivedRoomList.value = await groups.archivedRooms()
  402. archivedTopicList.value = groups.selectedRoomId
  403. ? await groups.archivedTopics(groups.selectedRoomId)
  404. : []
  405. }
  406. async function restoreArchivedRoom(roomId: string) {
  407. await groups.restoreRoom(roomId)
  408. archivedRoomList.value = await groups.archivedRooms()
  409. }
  410. async function archiveTopicFromAction() {
  411. const action = roomActionMenu.value
  412. closeRoomActions()
  413. if (!action?.topicId) return
  414. await groups.archiveTopic(action.roomId, action.topicId)
  415. }
  416. async function toggleTopicPinnedFromAction() {
  417. const action = roomActionMenu.value
  418. const topic = actionTopic.value
  419. closeRoomActions()
  420. if (!action?.topicId) return
  421. await groups.setTopicPinned(action.roomId, action.topicId, !topic?.pinned)
  422. }
  423. async function renameTopicFromAction() {
  424. const action = roomActionMenu.value
  425. const title = topicActionRenameValue.value.trim()
  426. if (!action?.topicId || !title) return
  427. await groups.renameTopic(action.roomId, action.topicId, title)
  428. closeRoomActions()
  429. }
  430. async function archiveRoomFromAction() {
  431. const action = roomActionMenu.value
  432. closeRoomActions()
  433. if (!action || action.topicId) return
  434. await groups.archiveRoom(action.roomId)
  435. if (route.params.roomId === action.roomId) await router.replace(groupRoute())
  436. }
  437. async function restoreArchivedTopic(topicId: string) {
  438. const roomId = groups.selectedRoomId
  439. if (!roomId) return
  440. await groups.restoreTopic(roomId, topicId)
  441. archivedTopicList.value = await groups.archivedTopics(roomId)
  442. }
  443. function openLocalFile({ name, url }: { name: string; url: string }) {
  444. const item = previewItemFromUrl(name, url)
  445. if (item.kind === 'image' || item.kind === 'video') openMedia(item)
  446. else preview.value = item
  447. }
  448. function openMedia(item: UiLibraryItem) {
  449. const target = mediaUrlIdentity(item.previewUrl || item.downloadUrl || '')
  450. const index = lightboxMedia.value.findIndex(media => mediaUrlIdentity(media.url) === target)
  451. mediaPreviewIndex.value = index >= 0 ? index : null
  452. }
  453. function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number]) {
  454. const item = previewItemFromUrl(attachment.name, attachment.url || '', attachment.id, attachment.kind)
  455. item.size = attachment.size
  456. if (item.kind === 'image' || item.kind === 'video') openMedia(item)
  457. else preview.value = item
  458. }
  459. async function addPreviewToComposer(item: UiLibraryItem) {
  460. if (!uploadsEnabled.value) return
  461. const file = await loadComposerFile(item)
  462. if (!file || !composer.value) return
  463. composer.value.attachFiles([file])
  464. preview.value = null
  465. mediaPreviewIndex.value = null
  466. await nextTick()
  467. composer.value?.focus()
  468. }
  469. async function addMediaToComposer(media: PreviewMedia) {
  470. await addPreviewToComposer(previewItemFromUrl(media.name, media.url, `preview:${media.url}`, media.type))
  471. }
  472. onMounted(async () => {
  473. document.addEventListener('pointerdown', handleRoomActionPointer)
  474. document.addEventListener('keydown', handleRoomActionKey)
  475. restoreShowThinking()
  476. try {
  477. await groups.start()
  478. const requested = typeof route.params.roomId === 'string' ? route.params.roomId : ''
  479. const requestedTopic = typeof route.params.topicId === 'string' ? route.params.topicId : undefined
  480. if (requested) {
  481. try { await groups.selectRoom(requested, requestedTopic) }
  482. catch {
  483. if (groups.selectedRoomId) await router.replace(groupRoute())
  484. return
  485. }
  486. }
  487. if (groups.selectedRoomId) {
  488. if (route.fullPath !== groupRoute()) await router.replace(groupRoute())
  489. }
  490. } catch { /* availability state renders the error */ }
  491. })
  492. onBeforeUnmount(() => {
  493. document.removeEventListener('pointerdown', handleRoomActionPointer)
  494. document.removeEventListener('keydown', handleRoomActionKey)
  495. groups.stop()
  496. })
  497. watch(() => [route.params.roomId, route.params.topicId] as const, async ([roomValue, topicValue]) => {
  498. const roomId = typeof roomValue === 'string' ? roomValue : ''
  499. const topicId = typeof topicValue === 'string' ? topicValue : undefined
  500. if (roomId && (roomId !== groups.selectedRoomId || (groups.topicProtocol && topicId && topicId !== groups.selectedTopicId))) {
  501. try { await groups.selectRoom(roomId, topicId) }
  502. catch { if (groups.selectedRoomId && route.fullPath !== groupRoute()) await router.replace(groupRoute()) }
  503. }
  504. })
  505. watch(() => groups.selectedRoomId, () => {
  506. managerError.value = ''
  507. agentUpdateError.value = {}
  508. })
  509. watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThinking(profile) })
  510. </script>
  511. <template>
  512. <WorkspaceView sidebar-title="群聊" :sidebar-context-title="topicListRoom?.name" :sidebar-subtitle="sidebarSubtitle" :inspector-open="managerOpen && !!room" inspector-close-label="关闭群聊管理" @close-inspector="managerOpen = false">
  513. <template #sidebar-action>
  514. <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" title="新建群聊" aria-label="新建群聊" @click="createOpen = true">
  515. <YaoYaoSidebarIcon name="add" />
  516. <span>新建群聊</span>
  517. </button>
  518. <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" @click="openArchivedOverlay">
  519. <AppIcon name="archive" :size="15" />
  520. <span>已归档</span>
  521. </button>
  522. </template>
  523. <template #sidebar-before-heading>
  524. <section v-if="pinnedTopics.length && !topicListRoom" class="pinned-topic-list" aria-label="话题置顶">
  525. <header class="pinned-topic-list__heading"><strong>话题置顶</strong><span>{{ pinnedTopics.length }} 个置顶话题</span></header>
  526. <button v-for="topic in pinnedTopics" :key="topic.id" class="pinned-topic-list__item" type="button" :class="{ active: topic.id === groups.selectedTopicId }" @click="selectSidebarItem(topicSidebarItemId(topic.roomId, topic.id))" @contextmenu.prevent.stop="openTopicActions(topic.roomId, topic.id, $event)">
  527. <AppIcon name="topic" :size="14" />
  528. <span>{{ topic.title }}</span>
  529. <small>{{ activeRooms.find(room => room.id === topic.roomId)?.name || topic.preview }}</small>
  530. </button>
  531. </section>
  532. </template>
  533. <template #sidebar>
  534. <ResourceSidebar
  535. :items="sidebarItems"
  536. :active-id="groups.selectedRoomId"
  537. :loading="groups.isLoading"
  538. external-search
  539. search-placeholder="搜索群聊"
  540. empty-title="还没有群聊"
  541. empty-description="新建群聊,邀请 1–8 个 Agent 一起协作。"
  542. @select="selectSidebarItem"
  543. @more="openRoomManager"
  544. @context-menu="openRoomActions"
  545. />
  546. </template>
  547. <div v-if="groups.availability === 'unsupported' || groups.availability === 'unavailable'" class="groups-unavailable">
  548. <EmptyState icon="alert" :title="groups.availability === 'unsupported' ? '群聊协议版本不兼容' : '群聊服务暂不可用'" :description="groups.error || `请确认 9119 已安装 YaoYao 群聊 protocol ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL} 插件。`" action-label="重新检查" @action="groups.refresh" />
  549. </div>
  550. <div v-else class="groups-workspace">
  551. <MessageTimeline
  552. :messages="messages"
  553. :title="groups.topicProtocol ? (groups.selectedTopic?.title || '新话题') : (groups.selectedRoom?.name || '群聊')"
  554. :subtitle="roomSubtitle"
  555. :loading="groups.isLoading"
  556. :has-older="groups.hasMoreBefore"
  557. :connected="connected"
  558. :synced="!!groups.selectedRoom"
  559. :show-tools="showThinking"
  560. :interaction="activeInteraction"
  561. :mention-names="mentionNames"
  562. :empty-title="groups.topicProtocol ? '开始一个新话题' : '让多个 Agent 一起工作'"
  563. :empty-description="groups.topicProtocol ? '第一条消息会创建独立话题,各话题分别保留 Agent 上下文。' : '使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。'"
  564. @load-older="groups.loadOlder"
  565. @quote="quoted = $event"
  566. @preview="openAttachment"
  567. @preview-file="openLocalFile"
  568. @approve="activeInteraction && groups.approveInteraction(activeInteraction.id, $event ? 'once' : 'deny')"
  569. @clarify="activeInteraction && groups.clarifyInteraction(activeInteraction.id, $event)"
  570. >
  571. <template #header-actions>
  572. <div class="group-header-actions">
  573. <span v-if="hostAgent" class="group-host-chip" :title="`用户未明确 @ 时由 ${hostAgent.displayName || hostAgent.profile} 负责回应`">主持人 {{ hostAgent.displayName || hostAgent.profile }}</span>
  574. <div class="group-avatars" aria-label="群聊成员"><span v-for="agent in agents.slice(0, 4)" :key="agent.id" :title="agent.isHost ? `${agent.name} · 主持人` : agent.name" :class="{ host: agent.isHost }">{{ agent.name.slice(0, 1).toUpperCase() }}</span><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
  575. <button class="icon-button" type="button" title="管理群聊" aria-label="管理群聊" :disabled="!groups.selectedRoom" @click="managerOpen = true"><AppIcon name="dots" /></button>
  576. </div>
  577. </template>
  578. </MessageTimeline>
  579. <p v-if="groups.error" class="group-error" role="alert"><AppIcon name="alert" :size="13" />{{ groups.error }}</p>
  580. <ComposerShell
  581. ref="composer"
  582. mode="group"
  583. :draft-key="`${auth.user?.id || 'local'}:${groups.selectedRoomId || 'new'}:${groups.selectedTopicId || 'legacy'}`"
  584. :disabled="!groups.selectedRoom || groups.availability !== 'available'"
  585. :streaming="activeAgentIds.size > 0"
  586. :sending="groups.isSending"
  587. :activity-text="typingActivity"
  588. :tool-trace-visible="showThinking"
  589. :reference="reference"
  590. :mention-options="mentionOptions"
  591. :attachments-enabled="uploadsEnabled"
  592. placeholder="发消息给群聊,输入 @ 提及 Agent"
  593. @send="send"
  594. @stop="stopActiveTopic"
  595. @tool-trace-toggle="toggleShowThinking"
  596. @clear-reference="quoted = null"
  597. />
  598. </div>
  599. <template #inspector>
  600. <GroupManager
  601. v-if="room && groups.selectedRoom"
  602. :room="room"
  603. :agents="groups.agents"
  604. :host-enabled="groups.hostProtocol"
  605. :host-flow-enabled="groups.hostFlowProtocol"
  606. :available-profiles="availableProfiles"
  607. :busy="managerBusy"
  608. :model-options-by-profile="modelOptionsByProfile"
  609. :model-options-loading="modelOptionsLoading"
  610. :model-options-error="modelOptionsError"
  611. :agent-update-error="agentUpdateError"
  612. :manager-error="managerError"
  613. @update-room="updateRoom"
  614. @add-agent="addAgent"
  615. @load-models="loadAgentModels"
  616. @clear-agent-error="clearAgentError"
  617. @update-agent="updateAgent"
  618. @remove-agent="removeAgent"
  619. @interrupt-agent="groups.interruptAgent($event, groups.selectedRoom.id)"
  620. @archive-room="archiveRoom"
  621. />
  622. </template>
  623. </WorkspaceView>
  624. <FloatingResourceSearch section="groups" label="搜索群聊" :items="roomSidebarItems" @select="selectRoom" />
  625. <CreateGroupDialog :open="createOpen" :profiles="profiles" :host-enabled="groups.hostProtocol" :host-flow-enabled="groups.hostFlowProtocol" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
  626. <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
  627. <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
  628. <Teleport to="body">
  629. <Transition name="group-menu">
  630. <section v-if="roomActionMenu" class="group-room-actions" :style="roomActionMenuStyle" role="menu" aria-label="群聊房间操作" @contextmenu.prevent>
  631. <template v-if="roomActionMenu.topicId">
  632. <template v-if="topicActionRenaming">
  633. <label class="group-topic-rename">话题名称<input v-model="topicActionRenameValue" maxlength="120" autofocus @keydown.enter="renameTopicFromAction" /></label>
  634. <div class="group-topic-rename__actions"><button class="quiet-button" type="button" @click="topicActionRenaming = false">取消</button><button class="solid-button" type="button" @click="renameTopicFromAction">保存</button></div>
  635. </template>
  636. <template v-else>
  637. <button class="group-action-row" role="menuitem" type="button" @click="toggleTopicPinnedFromAction"><AppIcon :name="actionTopic?.pinned ? 'pin-off' : 'pin'" :size="14" />{{ actionTopic?.pinned ? '取消置顶' : '置顶话题' }}</button>
  638. <button class="group-action-row" role="menuitem" type="button" @click="topicActionRenaming = true"><AppIcon name="edit" :size="14" />重命名</button>
  639. <button class="group-action-row danger" role="menuitem" type="button" @click="archiveTopicFromAction"><AppIcon name="archive" :size="14" />归档话题</button>
  640. </template>
  641. </template>
  642. <template v-else>
  643. <button class="group-action-row" role="menuitem" type="button" @click="startTopicFromRoomAction"><AppIcon name="plus" :size="14" />新建话题</button>
  644. <button class="group-action-row danger" role="menuitem" type="button" @click="archiveRoomFromAction"><AppIcon name="archive" :size="14" />归档群聊</button>
  645. </template>
  646. </section>
  647. </Transition>
  648. </Teleport>
  649. <Teleport to="body">
  650. <Transition name="group-menu">
  651. <div v-if="archivedOverlayOpen" class="archived-overlay-backdrop" role="presentation" @click.self="archivedOverlayOpen = false">
  652. <section class="archived-overlay" role="dialog" aria-modal="true" aria-label="已归档群聊和话题">
  653. <header><div><small>群聊归档</small><strong>已归档内容</strong></div><button type="button" aria-label="关闭" @click="archivedOverlayOpen = false"><AppIcon name="close" :size="16" /></button></header>
  654. <section class="archived-section"><h3>群聊</h3><p v-if="!archivedRoomList.length">没有已归档群聊</p><article v-for="archived in archivedRoomList" :key="archived.id"><span>{{ archived.name }}</span><button type="button" @click="restoreArchivedRoom(archived.id)">恢复</button></article></section>
  655. <section class="archived-section"><h3>当前群聊的话题</h3><p v-if="!groups.selectedRoom">请先打开一个群聊查看其已归档话题</p><p v-else-if="!archivedTopicList.length">没有已归档话题</p><article v-for="topic in archivedTopicList" :key="topic.id"><span>{{ topic.title }}</span><button type="button" @click="restoreArchivedTopic(topic.id)">恢复</button></article></section>
  656. </section>
  657. </div>
  658. </Transition>
  659. </Teleport>
  660. </template>
  661. <style scoped>
  662. .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; }
  663. .group-room-actions { position: fixed; z-index: 205; width: 174px; box-sizing: border-box; padding: 6px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-raised); box-shadow: 0 12px 34px rgba(0,0,0,.16); }
  664. .group-action-row { display: flex; width: 100%; min-height: 34px; align-items: center; gap: 8px; padding: 0 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; text-align: left; }.group-action-row:hover, .group-action-row:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-primary); }
  665. .group-action-row.danger:hover, .group-action-row.danger:focus-visible { color: var(--danger); }
  666. .group-topic-rename { display: grid; gap: 6px; padding: 6px 8px; color: var(--text-secondary); font-size: 10px; }.group-topic-rename input { width: 100%; min-height: 30px; box-sizing: border-box; padding: 0 8px; border: 1px solid var(--line); border-radius: 7px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font: inherit; }.group-topic-rename input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.group-topic-rename__actions { display: flex; justify-content: flex-end; gap: 6px; padding: 2px 8px 5px; }.group-topic-rename__actions button { min-height: 28px; padding: 0 8px; font-size: 10px; }
  667. .group-menu-enter-active, .group-menu-leave-active { transition: opacity 100ms ease, transform 120ms var(--ease-out); transform-origin: top right; }.group-menu-enter-from, .group-menu-leave-to { opacity: 0; transform: translateY(-3px) scale(.98); }
  668. .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; }
  669. .sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
  670. .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
  671. .sidebar-primary-action:disabled { cursor: not-allowed; opacity: .35; }
  672. .pinned-topic-list { max-height: 154px; overflow: auto; padding: 0 10px 4px; border-bottom: 1px solid var(--line); }
  673. .pinned-topic-list__heading { display: flex; min-height: 35px; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 11px 5px; }
  674. .pinned-topic-list__heading strong { font-size: 12px; font-weight: 680; }.pinned-topic-list__heading span { overflow: hidden; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
  675. .pinned-topic-list__item { display: flex; width: 100%; min-height: 32px; align-items: center; gap: 7px; padding: 1px 9px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.pinned-topic-list__item:hover, .pinned-topic-list__item.active { background: var(--surface-hover); }.pinned-topic-list__item :deep(.app-icon) { flex: 0 0 auto; color: var(--text-muted); }.pinned-topic-list__item > span { min-width: 0; overflow: hidden; font-size: 10.5px; font-weight: 450; text-overflow: ellipsis; white-space: nowrap; }.pinned-topic-list__item small { margin-left: auto; overflow: hidden; color: var(--text-muted); font-size: 8.5px; text-overflow: ellipsis; white-space: nowrap; }
  676. .archived-overlay-backdrop { position: fixed; z-index: 220; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.36); }.archived-overlay { width: min(460px, 100%); max-height: min(620px, calc(100vh - 40px)); overflow: auto; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); box-shadow: 0 24px 70px rgba(0,0,0,.28); }.archived-overlay header { display: flex; align-items: center; justify-content: space-between; padding: 18px 18px 14px; border-bottom: 1px solid var(--line); }.archived-overlay header small { display: block; color: var(--text-secondary); font-size: 10px; }.archived-overlay header strong { font-size: 15px; }.archived-overlay header button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; }.archived-section { display: grid; gap: 8px; padding: 16px 18px; }.archived-section + .archived-section { border-top: 1px solid var(--line); }.archived-section h3 { margin: 0; font-size: 12px; }.archived-section p { margin: 0; color: var(--text-secondary); font-size: 12px; }.archived-section article { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; }.archived-section article span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.archived-section article button { border: 0; border-radius: 7px; background: var(--surface-hover); color: var(--text-primary); cursor: pointer; padding: 5px 9px; }
  677. .group-header-actions { display: flex; align-items: center; gap: 8px; }.group-host-chip { max-width: 150px; overflow: hidden; padding: 4px 7px; border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--accent); font-size: 9px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }.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.host { box-shadow: 0 0 0 1px var(--accent); }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
  678. .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; }
  679. @media (max-width: 480px) { .group-avatars, .group-host-chip { display: none; } }
  680. @media (prefers-reduced-motion: reduce) { .sidebar-primary-action, .group-menu-enter-active, .group-menu-leave-active { transition: none; } }
  681. </style>