ConversationsView.vue 78 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328
  1. <script setup lang="ts">
  2. import { WORKSPACE_PATCH_CAPABILITY } from '@shared/workspaceMessagePatch'
  3. import { setApiCsrfToken } from '@/api/client'
  4. import { publishServerIdentity } from '@/api/serverIdentity'
  5. import type { ServerIdentity } from '@shared/serverIdentity'
  6. import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue'
  7. import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
  8. import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
  9. import ConversationList from '@/components/workspace/ConversationList.vue'
  10. import BotProfileDialog from '@/components/workspace/BotProfileDialog.vue'
  11. import WorkspaceBotPanel from '@/components/workspace/WorkspaceBotPanel.vue'
  12. import WorkspaceKnowledgePanel from '@/components/workspace/WorkspaceKnowledgePanel.vue'
  13. import type { WorkspaceProject } from '@shared/workspaceKnowledge'
  14. import LocalVmWorkspace from '@/components/workspace/LocalVmWorkspace.vue'
  15. import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
  16. import TaskPlan from '@/components/workspace/TaskPlan.vue'
  17. import type { AgentAssignment } from '@shared/agentTasks'
  18. import type { WorkspaceSendReceipt, WorkspaceTask } from '@shared/workspace'
  19. import { workspaceHasUnread } from '@shared/workspace'
  20. import type { WorkspaceLifecycleAction, WorkspaceLifecyclePreview } from '@shared/workspaceLifecycle'
  21. import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
  22. import type { SidebarItem } from '@/components/app/types'
  23. import AgentAvatar from '@/components/common/AgentAvatar.vue'
  24. import { createUuid } from '@/utils/id'
  25. import MessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
  26. import { WorkspaceTranscriptStore, transcriptKey, type WorkspaceDetail, type WorkspaceSnapshot } from '@/components/workspace/transcriptStore'
  27. import ComposerShell from '@/components/composer/ComposerShell.vue'
  28. import type { ComposerSubmit, ComposerReference } from '@/components/composer/types'
  29. import PreviewModal from '@/components/library/PreviewModal.vue'
  30. import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
  31. import { previewItemFromUrl, mediaItemsFromMessages } from '@/components/library/mediaSequence'
  32. import type { UiLibraryItem } from '@/components/library/types'
  33. import type { UiMessage } from '@/components/messages/types'
  34. import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
  35. import TeamAvatar from '@/components/common/TeamAvatar.vue'
  36. import TeamPresetPicker from '@/components/workspace/TeamPresetPicker.vue'
  37. import { TEAM_PRESETS, type TeamPreset } from '@/components/groups/teamPresets'
  38. import type { WorkspaceMemberRole } from '@shared/workspace'
  39. import AppIcon from '@/components/common/AppIcon.vue'
  40. import { workspaceAgentActivity, workspaceAvatarMembers, workspaceAvatarState, workspaceConversationItem, workspaceMessagesToUi } from '@/components/workspace/viewModels'
  41. import { useAuthStore } from '@/stores/auth'
  42. import { useThemeStore } from '@/stores/theme'
  43. import { apiRequest, ApiError } from '@/api/client'
  44. import { updateProfileIdentity, type ProfileIdentityInput } from '@/api/profiles'
  45. import { encodeAgentAvatar, randomAgentIdentity } from '@shared/agentIdentity'
  46. import type { JsonValue } from '@shared/types'
  47. import {
  48. type WorkspaceAgent as Agent,
  49. type WorkspaceConversation as Conversation,
  50. type WorkspaceMessage as Message,
  51. type WorkspaceRun as Run,
  52. type WorkspaceInteraction as Interaction,
  53. type WorkspaceSource as Source,
  54. type WorkspaceFile,
  55. type WorkspaceEvent,
  56. workspaceHasUnfinishedOutput,
  57. } from '@shared/workspace'
  58. const auth = useAuthStore(),
  59. theme = useThemeStore(),
  60. route = useRoute(),
  61. router = useRouter()
  62. const agents = ref<Agent[]>([]),
  63. conversations = ref<Conversation[]>([]),
  64. messages = shallowRef<Message[]>([]),
  65. sources = ref<Source[]>([])
  66. const active = ref<Conversation>(),
  67. run = ref<Run | null>(null),
  68. interactions = ref<Interaction[]>([]),
  69. context = ref<Record<string, unknown> | null>(null)
  70. const thinking = computed(() =>
  71. (!!run.value && ['queued', 'running', 'waiting', 'uncertain'].includes(run.value.status))
  72. || workspaceHasUnfinishedOutput(messages.value))
  73. const activeTask = ref<WorkspaceTask | null>(null), tasks = ref<WorkspaceTask[]>([]), assignments = ref<AgentAssignment[]>([])
  74. const deliveryMode = ref(false)
  75. const knowledgePanel = ref<InstanceType<typeof WorkspaceKnowledgePanel>>()
  76. const knowledgeRevision = ref(0)
  77. const knowledgeEnabled = ref(false), projects = ref<WorkspaceProject[]>([]), directProjectId = ref(''), requestedRounds = ref<number | undefined>(), coordinatorId = ref('')
  78. const coordinators = computed(() => members.value.filter(a => !a.temporaryGoalId && !a.archived && !a.remoteAgentId))
  79. const canRequestGoal = computed(() => active.value?.kind === 'group' && !active.value.archived && !activeTask.value?.goal
  80. && (active.value.collaborationMode === 'discussion' ? coordinators.value.length > 0 : members.value.some(a => a.id === active.value?.administratorId && !a.temporaryGoalId && !a.archived && !a.remoteAgentId)))
  81. const taskFiles = new Map<string,File[]>()
  82. const taskQuotes = new Map<string,UiMessage | null>()
  83. const composerKey = computed(() => `${auth.user?.id}:${active.value?.id}${activeTask.value ? `:${activeTask.value.id}` : ''}`)
  84. watch(composerKey, () => { deliveryMode.value = false; directProjectId.value = ''; requestedRounds.value = undefined; coordinatorId.value = '' })
  85. async function knowledgeChanged() { await refresh(); projects.value = (await apiRequest<{ projects: WorkspaceProject[] }>('/api/app/workspace/projects')).projects }
  86. const text = ref(''),
  87. error = ref(''),
  88. busy = ref(false),
  89. loading = ref(false),
  90. files = ref<WorkspaceFile[]>([]),
  91. mentions = ref<string[]>([]),
  92. older = ref(true)
  93. const identityBusy = ref(false),
  94. identityError = ref(''),
  95. identityResetVersion = ref(0)
  96. const respondingInteractionId = ref(''), interactionError = ref('')
  97. watch(() => `${route.params.id}:${interactions.value[0]?.id}`, () => { interactionError.value = '' })
  98. const agentActivity = computed(() => workspaceAgentActivity(conversations.value))
  99. const searchItems = computed<SidebarItem[]>(() => [false, true].map(archived => {
  100. const items = conversations.value.filter(c => c.archived === archived).map(c => workspaceConversationItem(c, agents.value, agentActivity.value))
  101. return { id: archived ? 'archived' : 'unarchived', title: archived ? '已归档' : '未归档', children: items, emptyText: archived ? '没有已归档聊天' : '没有未归档聊天' }
  102. }))
  103. const archivedIds = computed(() => new Set(conversations.value.filter(c => c.archived).map(c => c.id)))
  104. const lifecycleId = ref(''), searchActionError = ref(''), lifecycleError = ref(''), lifecycleAction = ref<WorkspaceLifecycleAction>()
  105. const composer = ref<InstanceType<typeof ComposerShell>>()
  106. const timeline = ref<InstanceType<typeof MessageTimeline>>()
  107. const quoted = ref<UiMessage | null>(null)
  108. const preview = ref<UiLibraryItem | null>(null)
  109. const mediaIndex = ref<number | null>(null)
  110. const showThinking = ref(false)
  111. const transcriptStore = new WorkspaceTranscriptStore()
  112. const renderedMessages = new WeakMap<Message, UiMessage>()
  113. const uiMessages = computed(() => messages.value.filter(m => m.visible !== false).map(message => {
  114. let rendered = renderedMessages.get(message)
  115. if (!rendered) { rendered = workspaceMessagesToUi([message])[0]!; renderedMessages.set(message, rendered) }
  116. return rendered
  117. }))
  118. const loadingOlder = ref(false)
  119. const media = computed(() => mediaItemsFromMessages(uiMessages.value))
  120. const lightboxMedia = computed(() => media.value.filter(item => item.kind === 'image' || item.kind === 'video').map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })))
  121. const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
  122. const avatarProfile = computed(() => ({ name: editingId.value || '机器人', displayName: form.name || '机器人', agentName: form.name || '机器人', agentAvatar: form.avatar, isDefault: false, isRunning: true }))
  123. function openPreview(file: {name: string; url?: string; kind?: string}) {
  124. let path = file.url || ''
  125. try { path = decodeURIComponent(new URL(path, window.location.origin).pathname) } catch { /* preserve the supplied URL */ }
  126. const attachment = messages.value.flatMap(message => message.attachments).find(candidate =>
  127. path === candidate.sourcePath || path === `/api/app/files/${candidate.id}/download`
  128. || path === `/api/app/files/${candidate.id}/preview`)
  129. // A Markdown label such as “报告” has no extension. The archived file's name
  130. // determines its viewer; the label is not reliable file-type metadata.
  131. const mime = attachment?.mimeType.toLowerCase().split(';')[0] || ''
  132. const textual = mime.startsWith('text/') || ['application/json', 'application/xml', 'application/yaml', 'application/x-yaml'].includes(mime)
  133. const item = previewItemFromUrl(attachment?.name || file.name, file.url || '', undefined, textual ? 'text' : undefined)
  134. const index = lightboxMedia.value.findIndex(m => m.url === (item.previewUrl || item.downloadUrl))
  135. if (index >= 0) mediaIndex.value = index
  136. else preview.value = item
  137. }
  138. let uploadedSources: File[] = [], uploadedReferences: WorkspaceFile[] = []
  139. async function sendFromComposer(payload: ComposerSubmit) {
  140. const sourceComposer = composer.value, token = sourceComposer?.submissionToken()
  141. const scope = composerKey.value, ownerEpoch = accountEpoch
  142. text.value = quoted.value ? `> ${(quoted.value.author || '我')}: ${quoted.value.content.replace(/\n/g, '\n> ')}\n\n${payload.text}` : payload.text
  143. mentions.value = payload.mentionIds
  144. if (payload.files.length) {
  145. try {
  146. busy.value = true
  147. if (payload.files.length === uploadedSources.length && payload.files.every((f, i) => f === uploadedSources[i])) files.value = uploadedReferences
  148. else {
  149. const data = new FormData()
  150. for (const file of payload.files) data.append('files', file)
  151. const uploaded = await apiRequest<{ files: WorkspaceFile[] }>('/api/app/uploads', {method: 'POST', body: data})
  152. if (scope !== composerKey.value || ownerEpoch !== accountEpoch) return
  153. files.value = uploaded.files
  154. uploadedSources = [...payload.files]
  155. uploadedReferences = [...files.value]
  156. }
  157. } catch (cause) { error.value = cause instanceof Error ? cause.message : '上传失败'; return }
  158. finally { if (ownerEpoch === accountEpoch) busy.value = false }
  159. }
  160. if (scope !== composerKey.value || ownerEpoch !== accountEpoch) return
  161. if (!payload.files.length) files.value = []
  162. if (await send()) {
  163. const cleared = sourceComposer?.clearAfterSend(token)
  164. if (cleared && composer.value === sourceComposer && composerKey.value === scope && accountEpoch === ownerEpoch) {
  165. quoted.value = null; uploadedSources = []; uploadedReferences = []
  166. }
  167. }
  168. }
  169. const dialog = ref<'agent' | 'group' | 'editAgent' | 'editGroup' | null>(null),
  170. editingId = ref(''),
  171. editingConversationId = ref(''),
  172. scroller = ref<HTMLElement>(),
  173. fileInput = ref<HTMLInputElement>(),
  174. dialogElement = ref<HTMLDialogElement>()
  175. const editingConversation = computed(() => conversations.value.find(c => c.id === editingConversationId.value) ?? (active.value?.id === editingConversationId.value ? active.value : undefined))
  176. const editingBot = computed(() => agents.value.find(agent => agent.id === editingId.value))
  177. const isRemoteAgent=computed(()=>dialog.value==='editAgent' && !!agents.value.find(a=>a.id===editingId.value)?.remoteAgentId)
  178. const form = reactive({
  179. name: '',
  180. avatar: '',
  181. instructions: '',
  182. description: '',
  183. job: '',
  184. antiJobs: [] as string[],
  185. voice: '' as ''|'concise'|'casual'|'rigorous'|'custom',
  186. voiceCustom: '',
  187. actBias: '' as ''|'ask_first'|'ask_key_then_act'|'act_now',
  188. execution: 'profile' as 'profile'|'computer',
  189. canManageTeam: true,
  190. canCollaborate: true,
  191. memoryEnabled: true,
  192. approvalPolicy: 'ask' as 'ask'|'allow'|'deny',
  193. source: '',
  194. memberIds: [] as string[],
  195. memberRoles: {} as Record<string, WorkspaceMemberRole>,
  196. administratorId: '',
  197. mode: 'discussion' as 'discussion' | 'host' | 'free',
  198. autoReplyIds: [] as string[],
  199. maxReplyRounds: 3,
  200. })
  201. const selectedPresetId = ref('custom')
  202. const selectedPreset = computed(() => dialog.value === 'group' ? TEAM_PRESETS.find(p => p.id === selectedPresetId.value) : undefined)
  203. function applyPresetRoles(preset: TeamPreset) {
  204. form.memberRoles = Object.fromEntries(preset.roles.flatMap((role, index) => {
  205. const id = form.memberIds[index]
  206. return id ? [[id, { name: role.name, description: role.description }]] : []
  207. }))
  208. form.administratorId = form.memberIds[Math.max(0, preset.roles.findIndex(role => role.host))] ?? ''
  209. }
  210. function choosePreset(preset?: TeamPreset) {
  211. const available = agents.value.filter(a => !a.archived && !a.temporaryGoalId)
  212. if (preset && available.length < preset.roles.length) return
  213. selectedPresetId.value = preset?.id ?? 'custom'
  214. Object.assign(form, { name: preset?.name ?? '', instructions: preset?.instructions ?? '', job: '', antiJobs: [], voice: '', voiceCustom: '', actBias: '', memberIds: preset ? available.slice(0, preset.roles.length).map(a => a.id) : [], memberRoles: {}, administratorId: '', mode: knowledgeEnabled.value ? 'discussion' : 'host', autoReplyIds: [], maxReplyRounds: 3 })
  215. if (preset) applyPresetRoles(preset)
  216. }
  217. function assignPresetRole(index: number, id: string) {
  218. if (!selectedPreset.value) return
  219. const next = [...form.memberIds], previous = next[index], occupied = next.indexOf(id)
  220. next[index] = id
  221. if (occupied >= 0 && occupied !== index && previous) next[occupied] = previous
  222. form.memberIds = next
  223. applyPresetRoles(selectedPreset.value)
  224. }
  225. const answers = reactive<Record<string, string>>({})
  226. let cursor = 0,
  227. disposed = false,
  228. timer: ReturnType<typeof setTimeout> | undefined,
  229. generation = 0,
  230. pendingRequestId: string | undefined,
  231. pendingFingerprint = ''
  232. const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
  233. const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
  234. const shell=ref<InstanceType<typeof WorkspaceShell>>(),vmWorkspace=ref<InstanceType<typeof LocalVmWorkspace>>(),desktopViewer=ref<InstanceType<typeof ComputerPanel>>()
  235. const computerOpen=ref(false),dockView=ref<'computer'|'inspector'>(),twoDesktops=ref(false),desktopAgent=ref<Agent>(),desktopBackend=ref<'desktop'|'cloud'|'vm'>(),desktopHost=ref('')
  236. async function resolveDeviceHost(){try{return (await window.yaoyaoDesktop?.deviceHost?.())?.deviceHost??null}catch{return null}}
  237. const creatingTask=ref(false)
  238. const CREATE_TASK_VALUE='__create_task__'
  239. function toggleDock(view:'computer'|'inspector'){dockView.value=dockView.value===view?undefined:view}
  240. async function openComputer(agent:Agent,backend?:'desktop'|'cloud'|'vm',host?:string){
  241. if(window.yaoyaoDesktop?.openComputer){try{await window.yaoyaoDesktop.openComputer(agent.id,{backend,host})}catch(cause){error.value=cause instanceof Error?cause.message:'无法打开电脑窗口'}return}
  242. desktopAgent.value=agent;desktopBackend.value=backend;desktopHost.value=host??'';computerOpen.value=true
  243. }
  244. async function leaveComputer(){
  245. if(twoDesktops.value&&vmWorkspace.value&&!await vmWorkspace.value.close())return false
  246. if(computerOpen.value&&desktopViewer.value){try{await desktopViewer.value.releaseControl();computerOpen.value=false}catch{return false}}
  247. return true
  248. }
  249. onBeforeRouteUpdate(leaveComputer);onBeforeRouteLeave(leaveComputer)
  250. const computerCandidates=computed(()=>agents.value.filter(agent=>!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===activeTask.value?.goal?.id))))
  251. const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
  252. const activeHeaderAgent = computed(() => active.value?.kind === 'direct' ? agents.value.find(agent => agent.id === active.value?.memberIds[0]) : undefined)
  253. const activeHeaderTitle = computed(() => activeHeaderAgent.value?.name || active.value?.name || 'Bot 模式')
  254. const activeHeaderAvatarName = computed(() => active.value ? activeHeaderAgent.value?.name || active.value.name : '')
  255. const activeHeaderAvatar = computed(() => activeHeaderAgent.value?.avatar || active.value?.avatar || '')
  256. const activeHeaderAvatarMembers = computed(() => active.value?.kind === 'group' ? workspaceAvatarMembers(active.value.memberIds, agents.value, active.value) : [])
  257. const thinkingIdentity = computed(() => {
  258. const streamingAgentId = [...uiMessages.value].reverse().find(message => message.role === 'assistant' && message.status === 'streaming')?.profile
  259. const agentId = activeHeaderAgent.value?.id || run.value?.activeAgentId || streamingAgentId
  260. || active.value?.activeAgentId || Object.entries(active.value?.activeAgentStates ?? {}).find(([, state]) => state === 'running')?.[0]
  261. || active.value?.memberIds[0]
  262. const agent = agents.value.find(agent => agent.id === agentId)
  263. return { name: agent?.name || active.value?.name || '机器人', avatar: agent?.avatar || (active.value?.kind === 'direct' ? active.value.avatar : '') || '' }
  264. })
  265. const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
  266. const body = (v: unknown) => v as JsonValue
  267. async function saveProfileIdentity(input: ProfileIdentityInput) {
  268. const profile = auth.activeProfile
  269. if (!profile) return
  270. identityBusy.value = true
  271. identityError.value = ''
  272. try {
  273. await updateProfileIdentity(profile, input)
  274. await auth.refreshProfiles()
  275. await auth.refreshProfileAvatars()
  276. identityResetVersion.value += 1
  277. } catch (cause) {
  278. identityError.value = cause instanceof Error ? cause.message : '保存机器人身份失败'
  279. } finally {
  280. identityBusy.value = false
  281. }
  282. }
  283. async function refresh() {
  284. const startedAtCursor = transcriptStore.cursor
  285. const owner = auth.user?.id
  286. const [a, c] = await Promise.all([
  287. apiRequest<{ agents: Agent[] }>('/api/app/agents'),
  288. apiRequest<{ conversations: Conversation[]; cursor: number }>('/api/app/conversations'),
  289. ])
  290. if (disposed || auth.user?.id !== owner || transcriptStore.cursor !== startedAtCursor) return
  291. agents.value = a.agents
  292. const localConversations = new Map(conversations.value.map(row => [row.id, row]))
  293. transcriptStore.conversations = c.conversations.map(conversation => {
  294. const local = localConversations.get(conversation.id)
  295. return local && local.unreadVersion === conversation.unreadVersion && [...pendingReads].some(key => key.startsWith(`${conversation.id}:`))
  296. ? { ...conversation, unread: local.unread, unreadCount: local.unreadCount } : conversation
  297. })
  298. transcriptStore.agents = agents.value
  299. conversations.value = transcriptStore.conversations
  300. if (!cursor) { cursor = c.cursor; transcriptStore.cursor = c.cursor }
  301. }
  302. async function load(id = selected.value, append = false, signal?: AbortSignal) {
  303. if (!id) {
  304. generation++
  305. active.value = undefined
  306. messages.value = []
  307. run.value = null
  308. interactions.value = []
  309. context.value = null
  310. activeTask.value = null;tasks.value = [];assignments.value = []
  311. return
  312. }
  313. const own = ++generation
  314. const owner = auth.user?.id, epoch = accountEpoch
  315. const current = () => !disposed && own === generation && !signal?.aborted
  316. && auth.user?.id === owner && accountEpoch === epoch
  317. const previousComposer = composerKey.value
  318. const previousFiles = composer.value?.filesSnapshot() ?? []
  319. const previousQuote = quoted.value
  320. const requestedTask = selectedTask.value || (active.value?.id === id ? activeTask.value?.id : undefined)
  321. const cached = !append ? transcriptStore.get(id, requestedTask) : undefined
  322. const readToken = transcriptStore.beginRead()
  323. if (!append) loading.value = !cached
  324. if (active.value?.id !== id) {
  325. active.value = conversations.value.find(c => c.id === id)
  326. messages.value = cached?.messages ?? [];run.value = null;interactions.value = [];context.value = null
  327. activeTask.value = null;tasks.value = [];assignments.value = [];quoted.value = null;older.value = false
  328. }
  329. try {
  330. const incoming = cached ?? await apiRequest<WorkspaceDetail>(`/api/app/conversations/${id}?limit=50${requestedTask ? `&taskId=${encodeURIComponent(requestedTask)}` : ''}`, { signal })
  331. if (!current()) return
  332. const r = cached ?? transcriptStore.finishRead(readToken, incoming)
  333. if (cached && transcriptStore.staleDetails.has(transcriptKey(id, cached.task?.id))) {
  334. queueMicrotask(() => { if (current() && active.value?.id === id) void load(id, true, signal) })
  335. }
  336. const atBottom = timeline.value?.isFollowingBottom() ?? true
  337. active.value = r.conversation
  338. if (r.task) transcriptStore.selectedTasks.set(id, r.task.id)
  339. activeTask.value = r.task ?? null;tasks.value = r.tasks ?? [];assignments.value = r.assignments ?? []
  340. messages.value = r.messages
  341. run.value = r.run
  342. interactions.value = r.interactions
  343. context.value = r.context
  344. if (previousComposer !== composerKey.value) {
  345. taskFiles.set(previousComposer,previousFiles)
  346. taskQuotes.set(previousComposer,previousQuote)
  347. if (activeTask.value && !requestedTask) {
  348. const legacy = `hermes-yaoyao:composer:group:${auth.user?.id}:${active.value.id}`
  349. const current = `hermes-yaoyao:composer:group:${composerKey.value}`
  350. try {
  351. const draft = localStorage.getItem(legacy)
  352. if (draft && !localStorage.getItem(current)) { localStorage.setItem(current,draft);localStorage.removeItem(legacy) }
  353. } catch { /* Leave the original draft intact when browser storage is unavailable. */ }
  354. }
  355. await nextTick()
  356. if (!current()) return
  357. const saved = taskFiles.get(composerKey.value)
  358. if (saved?.length) await composer.value?.attachFiles(saved)
  359. if (!current()) return
  360. quoted.value = taskQuotes.get(composerKey.value) ?? null
  361. }
  362. if (!append) older.value = r.hasOlder ?? r.messages.length === 50
  363. if (!append || atBottom) {
  364. await nextTick()
  365. if (!current()) return
  366. if (!append) timeline.value?.scrollToBottom('auto')
  367. void markRead().catch(e => { if (current()) error.value = e instanceof Error ? e.message : '同步已读失败' })
  368. }
  369. } catch (e) {
  370. if (current() && requestedTask && e instanceof ApiError && e.status === 404) {
  371. try {
  372. const available = await apiRequest<{tasks:WorkspaceTask[]}>(`/api/app/conversations/${id}/tasks`, { signal })
  373. const fallback = available.tasks[0]
  374. if (current() && fallback && fallback.id !== requestedTask) {
  375. await router.replace({query:{...route.query,taskId:fallback.id}})
  376. return
  377. }
  378. } catch { /* A deleted conversation still reports its original error. */ }
  379. }
  380. if (current()) error.value = e instanceof Error ? e.message : '加载失败'
  381. } finally {
  382. transcriptStore.cancelRead(readToken)
  383. if (own === generation) loading.value = false
  384. }
  385. }
  386. const pendingReads = new Set<string>()
  387. async function markRead() {
  388. const c = active.value
  389. const read = activeTask.value ?? c
  390. if (!c || !read || (!workspaceHasUnread(read) && read.readSeq >= read.lastSeq)) return
  391. const task = activeTask.value, seq = read.lastSeq, version = read.unreadVersion
  392. const key = `${c.id}:${task?.id ?? ''}:${version}:${seq}`
  393. if (pendingReads.has(key)) return
  394. pendingReads.add(key)
  395. const previous = { unread: read.unread, unreadCount: read.unreadCount, readSeq: read.readSeq }
  396. const previousConversation = { unread: c.unread, unreadCount: c.unreadCount }
  397. read.unread = false;read.unreadCount = 0;read.readSeq = seq
  398. if (task) tasks.value = tasks.value.map(t => t.id === task.id ? { ...t, unread: false, unreadCount: 0, readSeq: seq } : t)
  399. const publishUnread = () => {
  400. c.unread = task ? tasks.value.some(workspaceHasUnread) : read.unread
  401. c.unreadCount = c.unread ? 1 : 0
  402. const row = conversations.value.find(row => row.id === c.id)
  403. if (row && row.unreadVersion === c.unreadVersion) { row.unread = c.unread;row.unreadCount = c.unreadCount }
  404. }
  405. publishUnread()
  406. try {
  407. const result = await apiRequest<{conversation: Conversation}>(`/api/app/conversations/${c.id}/read${task ? `?taskId=${task.id}` : ''}`, {
  408. method: 'PUT', body: { seq, ...(version === undefined ? {} : {unreadVersion:version}) },
  409. })
  410. const row = conversations.value.find(row => row.id === c.id)
  411. if (row && result.conversation && (row.unreadVersion ?? 0) <= (result.conversation.unreadVersion ?? 0)) {
  412. row.unread = result.conversation.unread;row.unreadCount = result.conversation.unreadCount;row.unreadVersion = result.conversation.unreadVersion
  413. }
  414. } catch (error) {
  415. const row = conversations.value.find(row => row.id === c.id)
  416. if (row && row.unreadVersion === c.unreadVersion) Object.assign(row, previousConversation)
  417. if (active.value?.id === c.id && activeTask.value?.id === task?.id && (activeTask.value ?? active.value)?.unreadVersion === version) {
  418. Object.assign(activeTask.value ?? active.value!, previous)
  419. if (task) tasks.value = tasks.value.map(t => t.id === task.id ? { ...t, ...previous, unreadCount: previous.unreadCount ?? 0 } : t)
  420. publishUnread()
  421. }
  422. throw error
  423. } finally { pendingReads.delete(key) }
  424. }
  425. async function selectTask(event:Event){
  426. const select=event.currentTarget as HTMLSelectElement,value=select.value
  427. if(value!==CREATE_TASK_VALUE){await router.push({query:{...route.query,taskId:value}});return}
  428. select.value=activeTask.value?.id??''
  429. if(!active.value||active.value.kind!=='group'||creatingTask.value)return
  430. const conversationId=active.value.id
  431. creatingTask.value=true;error.value=''
  432. try{
  433. const result=await apiRequest<{task:WorkspaceTask}>(`/api/app/conversations/${conversationId}/tasks`,{method:'POST',body:{}})
  434. if(active.value?.id!==conversationId)return
  435. tasks.value=[result.task,...tasks.value.filter(task=>task.id!==result.task.id)]
  436. await router.push({query:{...route.query,taskId:result.task.id}})
  437. }catch(cause){error.value=cause instanceof Error?cause.message:'新建话题失败'}finally{creatingTask.value=false}
  438. }
  439. async function stopTask() {
  440. if (!active.value || !activeTask.value) return
  441. try {
  442. await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/stop`,{method:'POST',body:{}})
  443. await load(undefined,true)
  444. } catch(cause) {error.value=cause instanceof Error?cause.message:'停止任务失败'}
  445. }
  446. async function resumeTask() {
  447. if (!active.value || !activeTask.value) return
  448. try {
  449. await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/resume`,{method:'POST',body:{requestId:createUuid()}})
  450. await load(undefined,true)
  451. } catch(cause) {error.value=cause instanceof Error?cause.message:'恢复任务失败'}
  452. }
  453. async function saveGoalCriteria(goalId: string, expectedRevision: number, acceptanceCriteria: string[]) {
  454. const conversationId = active.value?.id
  455. if (!conversationId || activeTask.value?.id !== goalId) throw new Error('话题已切换,请重新打开目标')
  456. const result = await apiRequest<{goal: NonNullable<WorkspaceTask['goal']>}>(`/api/app/conversations/${conversationId}/tasks/${goalId}/plan`, {
  457. method: 'PATCH', body: { requestId: createUuid(), expectedRevision, acceptanceCriteria },
  458. })
  459. if (active.value?.id === conversationId && activeTask.value?.id === goalId) {
  460. activeTask.value.goal = result.goal
  461. await load(conversationId, true)
  462. }
  463. }
  464. function openTaskLink(event: MouseEvent) {
  465. if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
  466. const anchor=(event.target as Element)?.closest?.('a[href]')
  467. if (!anchor) return
  468. try {
  469. const url=new URL(anchor.getAttribute('href') || '',window.location.origin)
  470. if(url.origin!==window.location.origin || !/^\/conversations\/[^/]+$/.test(url.pathname) || !url.searchParams.get('taskId'))return
  471. event.preventDefault();event.stopPropagation()
  472. void router.push(url.pathname+url.search)
  473. } catch { /* Leave other links to their normal handler. */ }
  474. }
  475. let patchEvents = false
  476. let eventSource: EventSource | undefined
  477. let eventFrame: number | undefined
  478. let eventQueue: WorkspaceEvent[] = []
  479. let recoveryAbort: AbortController | undefined
  480. function cancelRecovery() {
  481. recoveryAbort?.abort(); recoveryAbort = undefined
  482. if (timer) clearTimeout(timer)
  483. timer = undefined
  484. }
  485. function presentDetail(detail: WorkspaceDetail) {
  486. const atBottom = timeline.value?.isFollowingBottom() ?? true
  487. active.value = detail.conversation
  488. messages.value = detail.messages
  489. activeTask.value = detail.task ?? null; tasks.value = detail.tasks ?? []
  490. run.value = detail.run; interactions.value = detail.interactions
  491. context.value = detail.context; assignments.value = detail.assignments ?? []
  492. older.value = detail.hasOlder ?? false
  493. if (atBottom) void markRead().catch(() => {})
  494. }
  495. function flushEvents() {
  496. eventFrame = undefined
  497. try { for (const event of eventQueue) {
  498. transcriptStore.apply(event)
  499. if (event.type === 'project.changed') { const project = event.data as WorkspaceProject; projects.value = [...projects.value.filter(p => p.id !== project.id), project] }
  500. if (/^(memory|project|collaboration)\./.test(event.type)) knowledgeRevision.value++
  501. } }
  502. catch { eventQueue = []; eventSource?.close(); void recoverEvents(); return }
  503. eventQueue = []
  504. cursor = transcriptStore.cursor
  505. agents.value = transcriptStore.agents
  506. conversations.value = transcriptStore.conversations
  507. if (selected.value) {
  508. const detail = transcriptStore.get(selected.value, selectedTask.value || activeTask.value?.id)
  509. if (detail) presentDetail(detail)
  510. else if (!conversations.value.some(c => c.id === selected.value)) void router.replace('/conversations')
  511. else if (activeTask.value && !transcriptStore.details.has(transcriptKey(selected.value, activeTask.value.id))) {
  512. void router.replace(`/conversations/${selected.value}`); void load(selected.value, true)
  513. }
  514. }
  515. }
  516. async function hydrateWorkspace(signal: AbortSignal) {
  517. const owner = auth.user?.id, epoch = accountEpoch
  518. const capabilities = await apiRequest<{ features: string[]; csrfToken?: string }>('/api/app/capabilities', { signal })
  519. if (signal.aborted || disposed || accountEpoch !== epoch || auth.user?.id !== owner) return
  520. patchEvents = capabilities.features.includes(WORKSPACE_PATCH_CAPABILITY)
  521. knowledgeEnabled.value = capabilities.features.includes('bot-file-memory-v1')
  522. if (capabilities.csrfToken) setApiCsrfToken(capabilities.csrfToken)
  523. const readToken = transcriptStore.beginRead()
  524. try {
  525. const snapshot = await apiRequest<WorkspaceSnapshot & { serverIdentity?: ServerIdentity; projects?: WorkspaceProject[] }>('/api/app/workspace/snapshot', { signal })
  526. if (signal.aborted || disposed || accountEpoch !== epoch || auth.user?.id !== owner) return
  527. transcriptStore.hydrate(snapshot)
  528. agents.value = snapshot.agents; conversations.value = transcriptStore.conversations; cursor = snapshot.cursor
  529. projects.value = snapshot.projects ?? []
  530. publishServerIdentity(snapshot.serverIdentity)
  531. } finally { transcriptStore.cancelRead(readToken) }
  532. }
  533. function connectEvents() {
  534. eventSource?.close()
  535. if (disposed) return
  536. if (!navigator.onLine) { suspendEvents(); return }
  537. const source = new EventSource(`/api/app/events/stream?after=${cursor}${patchEvents ? '&format=patch-v1' : ''}`)
  538. eventSource = source
  539. source.addEventListener('workspace', event => {
  540. if (disposed || eventSource !== source) return
  541. try {
  542. eventQueue.push(JSON.parse((event as MessageEvent).data))
  543. if (eventFrame === undefined) eventFrame = requestAnimationFrame(flushEvents)
  544. } catch { source.close(); error.value = '消息同步失败,正在恢复'; void recoverEvents() }
  545. })
  546. source.addEventListener('ready', event => {
  547. if (eventSource !== source) return
  548. flushEvents(); error.value = ''
  549. publishServerIdentity(JSON.parse((event as MessageEvent).data).serverIdentity)
  550. })
  551. source.addEventListener('reset', () => { if (!disposed && eventSource === source) { source.close(); void recoverEvents() } })
  552. source.onerror = () => {
  553. if (disposed || eventSource !== source) return
  554. error.value = '连接中断,正在重连'
  555. void apiRequest('/api/app/capabilities').catch(cause => {
  556. if (cause instanceof ApiError && [401, 403].includes(cause.status)) {
  557. source.close()
  558. error.value = cause.message
  559. }
  560. })
  561. }
  562. }
  563. function suspendEvents() {
  564. cancelRecovery()
  565. eventSource?.close(); eventSource = undefined
  566. error.value = '网络已断开,恢复连接后将自动同步'
  567. }
  568. function resumeEvents() {
  569. if (!disposed && auth.user?.id) void recoverEvents()
  570. }
  571. async function recoverEvents(initial = false) {
  572. cancelRecovery()
  573. if (disposed || !auth.user?.id) return
  574. if (!navigator.onLine) { suspendEvents(); return }
  575. const controller = new AbortController(), owner = auth.user.id, epoch = accountEpoch
  576. recoveryAbort = controller
  577. const current = () => !disposed && !controller.signal.aborted && recoveryAbort === controller
  578. && accountEpoch === epoch && auth.user?.id === owner
  579. eventSource?.close(); eventSource = undefined; eventQueue = []
  580. if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
  581. eventFrame = undefined
  582. try {
  583. await hydrateWorkspace(controller.signal)
  584. if (!current()) return
  585. const detail = !initial && selected.value ? transcriptStore.get(selected.value, selectedTask.value || activeTask.value?.id) : undefined
  586. if (detail) {
  587. presentDetail(detail)
  588. if (transcriptStore.staleDetails.has(transcriptKey(detail.conversation.id, detail.task?.id))) await load(detail.conversation.id, true, controller.signal)
  589. } else await load(selected.value, false, controller.signal)
  590. if (current()) connectEvents()
  591. }
  592. catch (cause) {
  593. if (!current()) return
  594. if (!navigator.onLine) { suspendEvents(); return }
  595. error.value = cause instanceof Error ? cause.message : '连接中断'
  596. if (cause instanceof ApiError && [401, 403].includes(cause.status)) return
  597. timer = setTimeout(() => void recoverEvents(), 1500)
  598. } finally { if (recoveryAbort === controller) recoveryAbort = undefined }
  599. }
  600. async function select(id: string) {
  601. error.value = ''
  602. files.value = []
  603. mentions.value = []
  604. text.value = ''
  605. preview.value = null
  606. mediaIndex.value = null
  607. pendingRequestId = undefined
  608. await router.push(`/conversations/${id}`)
  609. }
  610. function openConversationSettings(id: string) {
  611. const conversation = conversations.value.find(c => c.id === id)
  612. if (conversation) void openDialog(conversation.kind === 'direct' ? 'editAgent' : 'editGroup', conversation)
  613. }
  614. async function openDialog(kind: NonNullable<typeof dialog.value>, conversation = active.value) {
  615. error.value = ''
  616. dialog.value = kind
  617. editingId.value = ''
  618. editingConversationId.value = kind === 'editAgent' || kind === 'editGroup' ? conversation?.id ?? '' : ''
  619. selectedPresetId.value = 'custom'
  620. Object.assign(form, {
  621. name: '',
  622. avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', '机器人')) : '',
  623. instructions: '',
  624. description: '',
  625. execution: 'profile' as 'profile'|'computer',
  626. canManageTeam: true,
  627. canCollaborate: true,
  628. memoryEnabled: true,
  629. approvalPolicy: 'ask',
  630. source: '',
  631. memberIds: [],
  632. memberRoles: {},
  633. administratorId: '',
  634. mode: knowledgeEnabled.value ? 'discussion' : 'host',
  635. autoReplyIds: [],
  636. maxReplyRounds: 3,
  637. })
  638. try {
  639. if (kind === 'agent' || kind === 'editAgent') {
  640. const s = await apiRequest<{ sources: Source[]; errors?: string[] }>('/api/app/agents/sources')
  641. if (!s.sources.length && s.errors?.length) throw new Error('基础机器人服务暂不可用,请联系管理员检查 Hermes 连接后重试。')
  642. sources.value = s.sources
  643. form.source = s.sources[0] ? JSON.stringify([s.sources[0].nodeId, s.sources[0].profile]) : ''
  644. }
  645. if (kind === 'editAgent') {
  646. const a = agents.value.find(a => a.id === conversation?.memberIds[0])
  647. if (!a) throw new Error('机器人不存在,请刷新后重试')
  648. Object.assign(form, a)
  649. form.description ??= ''; form.job ??= ''; form.antiJobs ??= []; form.voice ??= ''; form.voiceCustom ??= ''; form.actBias ??= ''
  650. form.source=JSON.stringify([a.nodeId,a.profile])
  651. if(a.remoteAgentId){
  652. const remote=(await apiRequest<{agents:Agent[]}>(`/api/app/nodes/${a.nodeId}/agents`)).agents.find(candidate=>candidate.id===a.remoteAgentId)
  653. if(remote)Object.assign(form,remote)
  654. }
  655. form.approvalPolicy = a.approvalPolicy ?? 'ask'
  656. editingId.value = a.id
  657. }
  658. if (kind === 'editGroup' && conversation) {
  659. editingId.value = conversation.id
  660. Object.assign(form, conversation, {
  661. mode: conversation.collaborationMode ?? conversation.mode,
  662. memberIds: [...conversation.memberIds],
  663. autoReplyIds: [...conversation.autoReplyIds],
  664. memberRoles: Object.fromEntries(Object.entries(conversation.memberRoles ?? {}).map(([id, role]) => [id, { ...role }])),
  665. })
  666. }
  667. await nextTick()
  668. dialogElement.value?.showModal()
  669. } catch (e) {
  670. error.value = String(e)
  671. dialog.value = null
  672. }
  673. }
  674. function closeDialog() {
  675. dialogElement.value?.close()
  676. dialog.value = null
  677. editingConversationId.value = ''
  678. }
  679. function saveBotProfile(draft: Pick<typeof form, 'name'|'avatar'|'instructions'|'source'|'canManageTeam'|'canCollaborate'|'memoryEnabled'|'approvalPolicy'> & Partial<Pick<typeof form, 'description'|'job'|'antiJobs'|'voice'|'voiceCustom'|'actBias'>>) {
  680. Object.assign(form, draft)
  681. form.description ??= ''; form.job ??= ''; form.antiJobs ??= []; form.voice ??= ''; form.voiceCustom ??= ''; form.actBias ??= ''
  682. void save()
  683. }
  684. watch(
  685. () => form.memberIds.slice(),
  686. (ids) => {
  687. if (!ids.includes(form.administratorId)) form.administratorId = ids[0] ?? ''
  688. form.autoReplyIds = form.autoReplyIds.filter((id) => ids.includes(id))
  689. form.memberRoles = Object.fromEntries(Object.entries(form.memberRoles).filter(([id]) => ids.includes(id)))
  690. },
  691. )
  692. async function save() {
  693. busy.value = true
  694. error.value = ''
  695. try {
  696. const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, description: form.description.trim(), canManageTeam: form.canManageTeam, approvalPolicy: form.approvalPolicy, execution:form.execution, ...(knowledgeEnabled.value ? { canCollaborate: form.canCollaborate, memoryEnabled: form.memoryEnabled } : {}) } : {}), instructions: form.instructions }
  697. const personaClears = { job: form.job.trim() ? form.job.trim() : null, antiJobs: form.antiJobs.length ? form.antiJobs : null, voice: form.voice || null, voiceCustom: form.voice === 'custom' && form.voiceCustom.trim() ? form.voiceCustom : null, actBias: form.actBias || null }
  698. const personaCreates = Object.fromEntries(Object.entries(personaClears).filter(([, value]) => value !== null && value !== undefined))
  699. if (isRemoteAgent.value) {
  700. await apiRequest(`/api/app/agents/${editingId.value}`, { method: 'PATCH', body: { approvalPolicy: form.approvalPolicy } })
  701. } else if (dialog.value === 'agent') {
  702. const [nodeId, profile] = JSON.parse(form.source)
  703. const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
  704. method: 'POST',
  705. body: { ...fields, ...personaCreates, nodeId, profile, computer: 'auto', envs: {vm:true,cloud:true,desktop:true}, execution:'profile' },
  706. })
  707. await refresh()
  708. const c = conversations.value.find(
  709. (c) => c.kind === 'direct' && c.memberIds[0] === result.agent.id,
  710. )
  711. if (c) await select(c.id)
  712. } else if (dialog.value === 'editAgent')
  713. await apiRequest(`/api/app/agents/${editingId.value}`, { method: 'PATCH', body: { ...fields, ...personaClears, ...(form.source?{nodeId:JSON.parse(form.source)[0],profile:JSON.parse(form.source)[1]}:{}) } })
  714. else {
  715. const payload = {
  716. ...fields,
  717. administratorId: form.administratorId || form.memberIds[0],
  718. mode: form.mode === 'discussion' ? 'free' : form.mode,
  719. ...(knowledgeEnabled.value ? { collaborationMode: form.mode } : {}),
  720. autoReplyIds: form.autoReplyIds,
  721. maxReplyRounds: form.maxReplyRounds,
  722. memberIds: form.memberIds,
  723. memberRoles: form.memberRoles,
  724. }
  725. const result = await apiRequest<{ conversation: Conversation }>(
  726. dialog.value === 'group'
  727. ? '/api/app/conversations'
  728. : `/api/app/conversations/${editingId.value}`,
  729. { method: dialog.value === 'group' ? 'POST' : 'PATCH', body: body(payload) },
  730. )
  731. await select(result.conversation.id)
  732. }
  733. closeDialog()
  734. await refresh()
  735. await load()
  736. } catch (e) {
  737. error.value = e instanceof Error ? e.message : '保存失败'
  738. } finally {
  739. busy.value = false
  740. }
  741. }
  742. async function upload(event: Event) {
  743. const picked = (event.target as HTMLInputElement).files
  744. if (!picked?.length) return
  745. busy.value = true
  746. error.value = ''
  747. try {
  748. const data = new FormData()
  749. for (const file of picked) data.append('files', file)
  750. const r = await apiRequest<{ files: WorkspaceFile[] }>('/api/app/uploads', {
  751. method: 'POST',
  752. body: data,
  753. })
  754. files.value = [...files.value, ...r.files].slice(0, 8)
  755. } catch (e) {
  756. error.value = String(e)
  757. } finally {
  758. busy.value = false
  759. ;(event.target as HTMLInputElement).value = ''
  760. }
  761. }
  762. let draftRevision = 0, accountEpoch = 0
  763. watch([text, files, mentions], () => { draftRevision++ }, { deep: true, flush: 'sync' })
  764. const sendRefreshes = new Map<string, ReturnType<typeof setTimeout>>()
  765. function reconcileSend(receipt: WorkspaceSendReceipt, scope: string, id: string, owner: string | undefined) {
  766. clearTimeout(sendRefreshes.get(scope))
  767. sendRefreshes.set(scope, setTimeout(() => {
  768. sendRefreshes.delete(scope)
  769. if (disposed || auth.user?.id !== owner || composerKey.value !== scope) return
  770. if (receipt.cursor !== undefined && transcriptStore.cursor >= receipt.cursor && eventSource?.readyState === EventSource.OPEN) return
  771. void load(id, true).catch(() => { error.value = '消息已发送,正在恢复同步' })
  772. }, receipt.cursor === undefined ? 0 : 2000))
  773. }
  774. async function send() {
  775. const c = active.value
  776. if (!c || busy.value || (!text.value.trim() && !files.value.length)) return
  777. busy.value = true
  778. error.value = ''
  779. const owner = auth.user?.id, epoch = accountEpoch, revision = draftRevision, scope = composerKey.value, taskId = activeTask.value?.id
  780. const submittedText = text.value, submittedFiles = files.value, submittedMentions = [...mentions.value]
  781. const mode = deliveryMode.value && !activeTask.value?.goal ? 'goal' : 'chat'
  782. const fingerprint = JSON.stringify([c.id, activeTask.value?.id, text.value, mentions.value, files.value.map(f => f.id), mode, directProjectId.value, requestedRounds.value, coordinatorId.value])
  783. if (!pendingRequestId || pendingFingerprint !== fingerprint) {
  784. pendingRequestId = createUuid()
  785. pendingFingerprint = fingerprint
  786. }
  787. try {
  788. const deviceHost = await resolveDeviceHost()
  789. const receipt = await apiRequest<WorkspaceSendReceipt>(`/api/app/conversations/${c.id}/messages`, {
  790. method: 'POST',
  791. body: {
  792. requestId: pendingRequestId,
  793. mode,
  794. ...(knowledgeEnabled.value ? { ...(c.kind === 'direct' && directProjectId.value ? { projectId: directProjectId.value } : {}), ...(requestedRounds.value && c.collaborationMode === 'discussion' ? { rounds: requestedRounds.value } : {}), ...(deliveryMode.value && c.collaborationMode === 'discussion' ? { coordinatorId: coordinatorId.value || coordinators.value[0]?.id } : {}) } : {}),
  795. ...(activeTask.value ? { taskId: activeTask.value.id } : {}),
  796. content: text.value,
  797. mentionIds: mentions.value,
  798. fileIds: files.value.map((f) => f.id),
  799. deviceHost,
  800. },
  801. })
  802. if (disposed || accountEpoch !== epoch || auth.user?.id !== owner) return true
  803. busy.value = false
  804. if (draftRevision === revision && composerKey.value === scope && text.value === submittedText && files.value === submittedFiles
  805. && JSON.stringify(mentions.value) === JSON.stringify(submittedMentions)) {
  806. text.value = ''; files.value = []; mentions.value = []
  807. pendingRequestId = undefined; pendingFingerprint = ''; deliveryMode.value = false
  808. }
  809. flushEvents()
  810. transcriptStore.acceptReceipt(receipt)
  811. conversations.value = transcriptStore.conversations
  812. const accepted = transcriptStore.get(c.id, taskId)
  813. if (accepted && composerKey.value === scope) presentDetail(accepted)
  814. reconcileSend(receipt, scope, c.id, owner)
  815. return true
  816. } catch (e) {
  817. if (accountEpoch === epoch) error.value = e instanceof Error ? e.message : '发送失败'
  818. } finally {
  819. if (accountEpoch === epoch) busy.value = false
  820. }
  821. }
  822. async function action(operation: 'pin' | 'archive', id = active.value?.id) {
  823. const c = conversations.value.find(c => c.id === id)
  824. if (!c) return
  825. if (operation === 'archive') return changeLifecycle(c.archived ? 'restore' : 'archive', c.id)
  826. try {
  827. await apiRequest(`/api/app/conversations/${c.id}`, {
  828. method: 'PATCH',
  829. body: { pinned: !c.pinned },
  830. })
  831. await refresh()
  832. await load()
  833. } catch (e) {
  834. error.value = String(e)
  835. }
  836. }
  837. async function changeLifecycle(operation: WorkspaceLifecycleAction, id: string, fromSearch = false) {
  838. const conversation = conversations.value.find(c => c.id === id)
  839. if (!conversation || lifecycleId.value) return
  840. const actionError = fromSearch ? searchActionError : lifecycleError
  841. actionError.value = ''
  842. lifecycleId.value = id
  843. lifecycleAction.value = operation
  844. try {
  845. const path = `/api/app/conversations/${id}/lifecycle`
  846. let confirmationToken: string | undefined
  847. if (operation !== 'restore') {
  848. const preview = await apiRequest<WorkspaceLifecyclePreview>(path)
  849. const managed = preview.groups.filter(group => group.administrator)
  850. if (managed.length) throw new Error(`此 Bot 是群聊${managed.map(group => `「${group.name}」`).join('、')}的管理者,请先转交管理权,再归档或删除。`)
  851. const verb = operation === 'delete' ? '永久删除' : '归档'
  852. const target = preview.kind === 'direct' ? 'Bot' : '群聊'
  853. let message = `${verb} ${target}「${preview.name}」${operation === 'delete' ? '及其聊天记录' : ''}?`
  854. if (preview.groups.length) message += `\n此 Bot 已加入群聊:${preview.groups.map(group => `「${group.name}」`).join('、')}。确认后会先从这些群聊移除,再${verb}。`
  855. message += operation === 'delete'
  856. ? `\n此操作无法撤销,${preview.kind === 'group' ? '成员 Bot 和' : ''}文件库中的文件会保留。`
  857. : '\n聊天记录会保留,可在搜索的“已归档”中取消归档。'
  858. if (!confirm(message)) return
  859. confirmationToken = preview.confirmationToken
  860. }
  861. await apiRequest(path, { method: 'POST', body: { action: operation, ...(confirmationToken ? { confirmationToken } : {}) } })
  862. if (operation === 'delete') {
  863. conversations.value = conversations.value.filter(c => c.id !== id)
  864. if (conversation.kind === 'direct') agents.value = agents.value.filter(a => a.id !== conversation.memberIds[0])
  865. if (selected.value === id) await router.replace('/conversations')
  866. }
  867. await refresh()
  868. if (selected.value === id) await load()
  869. } catch (cause) {
  870. actionError.value = cause instanceof Error ? cause.message : '操作失败,请重试'
  871. } finally {
  872. lifecycleId.value = ''
  873. lifecycleAction.value = undefined
  874. }
  875. }
  876. async function stopMember(id: string) {
  877. if (!editingConversation.value) return
  878. try {
  879. await apiRequest(`/api/app/conversations/${editingConversation.value.id}/agents/${id}/stop`, { method: 'POST', body: {} })
  880. await load(); await refresh()
  881. } catch (cause) { error.value = cause instanceof Error ? cause.message : '停止失败' }
  882. }
  883. async function control(action: 'stop' | 'reconcile') {
  884. const targetId = run.value?.id ?? active.value?.activeRunId ?? activeTask.value?.activeRunId
  885. if (!targetId) return
  886. try {
  887. await apiRequest(`/api/app/runs/${targetId}/${action}`, { method: 'POST', body: {} })
  888. await load(undefined, true)
  889. } catch (e) {
  890. error.value = String(e)
  891. }
  892. }
  893. async function respond(i: Interaction, answer: string) {
  894. if (respondingInteractionId.value) return
  895. respondingInteractionId.value = i.id
  896. interactionError.value = ''
  897. try {
  898. await apiRequest(`/api/app/interactions/${i.id}/respond`, { method: 'POST', body: { answer } })
  899. await load(undefined, true)
  900. } catch (e) {
  901. interactionError.value = e instanceof Error ? e.message : '审批答复失败,请重试'
  902. } finally { respondingInteractionId.value = '' }
  903. }
  904. async function loadOlder() {
  905. const c = active.value
  906. if (!c || loadingOlder.value) return
  907. loadingOlder.value = true
  908. const readToken = transcriptStore.beginRead()
  909. const taskId = activeTask.value?.id, own = generation
  910. try {
  911. const r = await apiRequest<{ messages: Message[]; cursor?: number }>(
  912. `/api/app/conversations/${c.id}/messages?limit=50&before=${messages.value[0]?.seq ?? 0}${activeTask.value ? `&taskId=${activeTask.value.id}` : ''}`,
  913. )
  914. if (own !== generation || active.value?.id !== c.id || activeTask.value?.id !== taskId) return
  915. const current = transcriptStore.get(c.id, taskId)
  916. if (!current) return
  917. const detail = transcriptStore.finishRead(readToken, { ...current, messages: r.messages, cursor: r.cursor })
  918. detail.hasOlder = r.messages.length === 50
  919. messages.value = detail.messages; older.value = detail.hasOlder
  920. } catch (e) {
  921. error.value = String(e)
  922. } finally { transcriptStore.cancelRead(readToken); loadingOlder.value = false }
  923. }
  924. function rendered(m: Message) {
  925. return m.content.replace(
  926. /(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g,
  927. (whole, label: string, path: string) => {
  928. const file =
  929. m.attachments.find((f) => f.sourcePath === path) ||
  930. m.attachments.find((f) => path.split('/').at(-1) === f.name)
  931. return file
  932. ? `${label}(/api/app/files/${file.id}/${label.startsWith('!') ? 'preview' : 'download'})`
  933. : whole
  934. },
  935. )
  936. }
  937. watch([selected,selectedTask], () => {
  938. taskFiles.set(composerKey.value, composer.value?.filesSnapshot() ?? [])
  939. taskQuotes.set(composerKey.value, quoted.value)
  940. files.value = [];mentions.value = [];text.value = ''
  941. pendingRequestId = undefined;uploadedSources = [];uploadedReferences = []
  942. void load()
  943. })
  944. watch(() => auth.user?.id, (owner, previous) => {
  945. if (owner === previous) return
  946. knowledgePanel.value?.close(); knowledgeEnabled.value = false; projects.value = []; knowledgeRevision.value++
  947. accountEpoch++; busy.value = false
  948. generation++
  949. cancelRecovery()
  950. text.value = ''; files.value = []; mentions.value = []; quoted.value = null
  951. pendingRequestId = undefined; pendingFingerprint = ''; uploadedSources = []; uploadedReferences = []
  952. eventSource?.close(); eventSource = undefined; eventQueue = []
  953. if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
  954. eventFrame = undefined; cursor = 0
  955. transcriptStore.hydrate({ agents: [], conversations: [], details: [], cursor: 0 })
  956. transcriptStore.selectedTasks.clear()
  957. agents.value = []; conversations.value = []; messages.value = []; active.value = undefined
  958. tasks.value = []; activeTask.value = null; run.value = null; interactions.value = []; context.value = null
  959. taskFiles.clear(); taskQuotes.clear()
  960. if (owner) void recoverEvents()
  961. })
  962. onMounted(async () => {
  963. window.addEventListener('offline', suspendEvents)
  964. window.addEventListener('online', resumeEvents)
  965. void auth.refreshProfileAvatars().catch(() => undefined)
  966. await recoverEvents(true)
  967. })
  968. onBeforeUnmount(() => {
  969. window.removeEventListener('offline', suspendEvents)
  970. window.removeEventListener('online', resumeEvents)
  971. for (const timer of sendRefreshes.values()) clearTimeout(timer)
  972. sendRefreshes.clear()
  973. disposed = true
  974. eventSource?.close()
  975. if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
  976. generation++
  977. cancelRecovery()
  978. })
  979. </script>
  980. <template>
  981. <WorkspaceShell ref="shell" :server-name="auth.serverIdentity?.displayName"
  982. :user-name="auth.user?.username"
  983. :user-avatar="auth.user?.avatar"
  984. :profiles="auth.profiles"
  985. :active-profile="auth.activeProfile"
  986. :is-admin="auth.user?.role === 'admin'"
  987. :upstream-ready="auth.upstreamReady"
  988. :upstream-error="auth.upstreamError"
  989. :theme="theme.resolvedTheme"
  990. :theme-preference="theme.theme"
  991. :identity-busy="identityBusy"
  992. :identity-error="identityError"
  993. :identity-reset-version="identityResetVersion"
  994. sidebar-title="聊天"
  995. sidebar-context-title="聊天列表"
  996. @logout="auth.logout"
  997. @select-profile="auth.selectProfile"
  998. @toggle-theme="theme.toggle"
  999. @set-theme="theme.setTheme"
  1000. @save-identity="saveProfileIdentity"
  1001. @create-agent="openDialog('agent')"
  1002. @create-group="openDialog('group')"
  1003. @bot-settings-changed="knowledgeChanged"
  1004. >
  1005. <template #sidebar
  1006. ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @settings="openConversationSettings" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
  1007. /></template>
  1008. <template #mobile-sidebar
  1009. ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @settings="openConversationSettings" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
  1010. /></template>
  1011. <div v-show="!twoDesktops" class="conversation-with-computer">
  1012. <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
  1013. <MessageTimeline ref="timeline" :identity="`${selected}:${activeTask?.id ?? ''}`" :loading-older="loadingOlder" :messages="uiMessages" :title="activeHeaderTitle"
  1014. :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
  1015. :header-avatar-name="activeHeaderAvatarName" :header-avatar="activeHeaderAvatar" :header-avatar-kind="active?.kind === 'group' ? 'team' : 'agent'"
  1016. :header-avatar-members="activeHeaderAvatarMembers" :header-avatar-state="activeHeaderAgent ? workspaceAvatarState(active, activeHeaderAgent.id) : 'idle'" :header-avatar-activity-key="active?.lastSeq"
  1017. :loading="loading" :has-older="older && !!active"
  1018. :show-tools="showThinking" :allow-branch="false" :thinking="thinking" :thinking-identity="thinkingIdentity"
  1019. :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"
  1020. :agent-states="Object.fromEntries(members.map(a => [a.id, workspaceAvatarState(active, a.id)]))"
  1021. :mention-names="members.map(a => a.name)"
  1022. :empty-title="active ? '开始一段新对话' : '还没有聊天'"
  1023. :empty-description="active ? '从下方输入框发送消息。' : '创建机器人,或选择成员新建群聊。'"
  1024. :interaction="interactions[0] ? { id: interactions[0].id, kind: interactions[0].kind, prompt: interactions[0].message, options: interactions[0].choices } : null"
  1025. :interaction-busy="!!respondingInteractionId" :interaction-error="interactionError"
  1026. :approval-agent-name="agents.find(agent => agent.id === interactions[0]?.agentId)?.name"
  1027. @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
  1028. @approval-choice="interactions[0] && respond(interactions[0], $event)"
  1029. @clarify="interactions[0] && respond(interactions[0], $event)">
  1030. <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>
  1031. <template #header-actions>
  1032. <div v-if="active" class="header-actions">
  1033. <button v-if="knowledgeEnabled" type="button" class="icon-button" aria-label="记忆与协作" title="记忆与协作" @click="knowledgePanel?.open(active.kind === 'group' ? 'collaboration' : 'agent', active.memberIds[0], active.projectId)"><AppIcon name="users" /></button>
  1034. <button v-if="active.kind==='direct'" type="button" class="icon-button" aria-label="电脑与定时任务" title="电脑与定时任务" :aria-pressed="dockView==='computer'" @click="toggleDock('computer')"><AppIcon name="monitor"/></button>
  1035. <span v-if="active.kind === 'group' && tasks.length" class="task-picker-shell">
  1036. <select class="task-picker" aria-label="当前话题" :aria-busy="creatingTask" :disabled="creatingTask" :value="activeTask?.id" @change="selectTask">
  1037. <option v-for="task in tasks" :key="task.id" :value="task.id">{{ !task.messageCount && task.titleSource === 'automatic' ? '新话题' : task.title }}</option>
  1038. <option :value="CREATE_TASK_VALUE">新建话题</option>
  1039. </select>
  1040. <AppIcon name="chevron-down" :size="14"/>
  1041. </span>
  1042. <button class="icon-button" :aria-label="active.kind === 'direct' ? '编辑资料' : '群聊设置'" :title="active.kind === 'direct' ? '编辑资料' : '群聊设置'" @click="openDialog(active.kind === 'direct' ? 'editAgent' : 'editGroup')"><AppIcon :name="active.kind === 'direct' ? 'edit' : 'settings'" /></button>
  1043. <button type="button" class="icon-button" aria-label="Inspector" title="Inspector" :aria-pressed="dockView==='inspector'" @click="toggleDock('inspector')"><AppIcon name="bug"/></button>
  1044. </div>
  1045. </template>
  1046. </MessageTimeline>
  1047. <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用机器人</p>
  1048. <p v-if="active?.collaborationWaiting" class="task-queue-status" role="status"><button @click="knowledgePanel?.open('collaboration', active.memberIds[0])">等待同伴回复 · 查看协作</button></p>
  1049. <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
  1050. <ComposerShell v-if="active" :key="composerKey" ref="composer" mode="group" :draft-key="composerKey"
  1051. v-model:delivery-mode="deliveryMode" :delivery-available="active.kind === 'group' && !activeTask?.goal"
  1052. :delivery-disabled="!canRequestGoal || busy || loading || !!activeTask?.activeRunId"
  1053. :delivery-disabled-reason="!canRequestGoal ? '负责人需要是本机上未归档的 Bot' : ''"
  1054. :disabled="active.archived || loading || active.id !== selected" :sending="busy" stop-while-running :streaming="!!active.activeRunId"
  1055. :tool-trace-visible="showThinking" :reference="reference" :context-used="Number(context?.usedTokens || 0)" :context-limit="Number(context?.limitTokens || 0)"
  1056. :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
  1057. @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event">
  1058. <template #before-input>
  1059. <div v-if="knowledgeEnabled" class="knowledge-shortcuts composer-knowledge">
  1060. <label v-if="active.kind === 'direct' && projects.some(p => p.memberIds.includes(active!.memberIds[0]!))">当前项目<select v-model="directProjectId"><option value="">不关联项目</option><option v-for="project in projects.filter(p => !p.archived && p.memberIds.includes(active!.memberIds[0]!))" :key="project.id" :value="project.id">{{ project.name }}</option></select></label>
  1061. <label v-if="active.collaborationMode === 'discussion' && !deliveryMode">讨论轮数<select v-model="requestedRounds"><option :value="undefined">按消息要求</option><option v-for="n in 12" :key="n" :value="n">{{ n }} 轮</option></select></label>
  1062. <label v-if="deliveryMode && active.collaborationMode === 'discussion'">交付负责人<select v-model="coordinatorId"><option value="">{{ coordinators[0]?.name ?? '请先授权负责人' }}</option><option v-for="agent in coordinators" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select></label>
  1063. </div>
  1064. <TaskPlan :key="activeTask?.id" :goal="activeTask?.goal" :assignments="assignments" :agents="agents" :save-criteria="saveGoalCriteria" @stop="stopTask" @resume="resumeTask" />
  1065. </template>
  1066. </ComposerShell>
  1067. </section>
  1068. <WorkspaceBotPanel v-if="dockView&&active" :conversation-id="active.id" :task-id="activeTask?.id" :mode="dockView" :active="!twoDesktops" :agents="computerCandidates" :is-admin="auth.user?.role === 'admin'" @close="dockView=undefined" @changed="refresh()" @settings="shell?.openLocalVm()" @desktop="openComputer" @workspace="desktopAgent=$event;twoDesktops=true" />
  1069. </div>
  1070. <ComputerPanel ref="desktopViewer" v-if="computerOpen&&desktopAgent" :agents="[desktopAgent]" :backend="desktopBackend" :host="desktopHost" auto-take @changed="load()" @close="computerOpen=false" />
  1071. <LocalVmWorkspace ref="vmWorkspace" v-if="twoDesktops&&desktopAgent" :agents="agents.filter(a=>a.execution==='computer'&&!a.archived&&!a.computerEnvironmentId)" :primary="desktopAgent.id" @close="twoDesktops=false" />
  1072. <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @open="searchActionError = ''" @select="select">
  1073. <template #item-actions="{ item }">
  1074. <div v-if="archivedIds.has(item.id)" class="search-lifecycle-actions">
  1075. <button type="button" class="search-restore" :aria-label="`取消归档:${item.title}`" :disabled="!!lifecycleId" @click="changeLifecycle('restore', item.id, true)">{{ lifecycleId === item.id && lifecycleAction === 'restore' ? '恢复中…' : '取消归档' }}</button>
  1076. <button type="button" class="search-delete" :aria-label="`删除聊天:${item.title}`" :disabled="!!lifecycleId" :aria-busy="lifecycleId === item.id && lifecycleAction === 'delete'" @click="changeLifecycle('delete', item.id, true)">
  1077. <AppIcon name="trash" :size="15" />{{ lifecycleId === item.id && lifecycleAction === 'delete' ? '删除中…' : '删除' }}
  1078. </button>
  1079. </div>
  1080. </template>
  1081. <template #footer><p v-if="searchActionError" class="search-delete-error" role="alert">{{ searchActionError }}</p></template>
  1082. </FloatingResourceSearch>
  1083. <Teleport to="body"><div v-if="lifecycleError" class="lifecycle-error" role="alert"><span>{{ lifecycleError }}</span><button type="button" aria-label="关闭聊天操作提示" @click="lifecycleError = ''"><AppIcon name="close" :size="16" /></button></div></Teleport>
  1084. <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
  1085. <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
  1086. <BotProfileDialog v-if="dialog === 'editAgent' && editingBot" :key="editingBot.id" :agent="editingBot" :draft="form" :sources="sources" :profiles="auth.profiles" :agents="agents" :conversations="conversations" :conversation-id="editingConversationId" :is-admin="auth.user?.role === 'admin'" :knowledge-enabled="knowledgeEnabled" :busy="busy" :error="error" @close="closeDialog" @save="saveBotProfile" @changed="knowledgeChanged" />
  1087. <Teleport to="body"><dialog v-if="dialog && dialog !== 'editAgent'" ref="dialogElement" class="editor" aria-labelledby="conversation-editor-title" @cancel.prevent="closeDialog">
  1088. <form @submit.prevent="save">
  1089. <header>
  1090. <h2 id="conversation-editor-title">
  1091. {{ dialog === 'agent' ? '创建机器人' : dialog === 'group' ? '创建群聊' : '群聊设置' }}
  1092. </h2>
  1093. <button type="button" class="icon-button" aria-label="关闭" @click="closeDialog"><AppIcon name="close" :size="18" /></button>
  1094. </header>
  1095. <div class="editor-content">
  1096. <p v-if="error" class="error" role="alert">{{ error }}</p>
  1097. <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
  1098. <details v-if="dialog === 'group'" class="group-options">
  1099. <summary>从团队模板选择(可选)</summary>
  1100. <TeamPresetPicker :selected="selectedPresetId" :available="agents.filter(a => !a.archived && !a.temporaryGoalId).length" @select="choosePreset" />
  1101. </details>
  1102. <p v-if="isRemoteAgent">引用的机器人配置由远端管理,请在远端修改名称、头像和角色规则。</p>
  1103. <AgentIdentityPanel v-if="isAgentDialog && !isRemoteAgent" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
  1104. <div v-else-if="dialog === 'editGroup'" class="team-avatar-settings">
  1105. <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? editingConversation : undefined)" :size="64" />
  1106. <small>群聊头像由成员头像自动组合,随成员头像更新。</small>
  1107. </div>
  1108. <label
  1109. v-if="isAgentDialog&&!isRemoteAgent"
  1110. >基础机器人<select v-model="form.source" required>
  1111. <option
  1112. v-for="s in sources"
  1113. :key="`${s.nodeId}:${s.profile}`"
  1114. :value="JSON.stringify([s.nodeId, s.profile])"
  1115. >
  1116. {{ s.name }} · {{ s.nodeId === 'local' ? '当前服务' : s.nodeId }}
  1117. </option></select
  1118. ><small v-if="!sources.length"
  1119. >{{ auth.isBotOnly ? '暂无可用的已分配机器人,请联系管理员分配或检查连接。' : '没有可用的基础机器人,请检查 Web 的 Hermes 连接。' }}</small
  1120. ></label
  1121. ><label v-if="isAgentDialog"
  1122. >角色提示词与规则<textarea
  1123. v-model="form.instructions"
  1124. :readonly="isRemoteAgent"
  1125. rows="6"
  1126. maxlength="24000"
  1127. :placeholder="
  1128. isAgentDialog
  1129. ? '例如:你是代码审查员。重点检查正确性、边界情况和测试证据。'
  1130. : '所有成员共同遵守的协作规则'
  1131. "
  1132. />
  1133. </label>
  1134. <template v-if="knowledgeEnabled && isAgentDialog && !isRemoteAgent">
  1135. <label class="team-management-permission"><span><input v-model="form.memoryEnabled" type="checkbox" />自动记录长期事实</span></label>
  1136. <button v-if="editingId" type="button" @click="knowledgePanel?.open('agent', editingId)">查看此 Bot 的记忆</button>
  1137. </template>
  1138. <fieldset v-if="selectedPreset" class="preset-role-mapping">
  1139. <legend>角色分配</legend>
  1140. <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
  1141. {{ role.name }}{{ role.host ? ' · 管理员' : '' }}
  1142. <select :value="form.memberIds[index]" :aria-label="`${role.name}对应的机器人`" @change="assignPresetRole(index, ($event.target as HTMLSelectElement).value)">
  1143. <option v-for="a in agents.filter(a => !a.archived && !a.temporaryGoalId)" :key="a.id" :value="a.id">{{ a.name }}</option>
  1144. </select>
  1145. <small>{{ role.description }}</small>
  1146. </label>
  1147. <small>角色分工仅在本群生效,聊天中保留机器人的名称和头像。</small>
  1148. </fieldset>
  1149. <fieldset v-else-if="!isAgentDialog">
  1150. <legend>选择成员</legend>
  1151. <label
  1152. v-for="a in agents.filter((a) =>
  1153. !a.temporaryGoalId && (!a.archived || (dialog === 'editGroup' && editingConversation?.memberIds.includes(a.id))),
  1154. )"
  1155. :key="a.id"
  1156. ><input
  1157. v-model="form.memberIds"
  1158. type="checkbox"
  1159. :value="a.id"
  1160. :disabled="busy || (form.mode !== 'discussion' && dialog === 'editGroup' && (a.id === editingConversation?.administratorId || a.id === form.administratorId)) || (!form.memberIds.includes(a.id) && form.memberIds.length >= 8)"
  1161. />{{ a.name }}</label
  1162. ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived && !a.temporaryGoalId).length < 2"
  1163. >至少需要两个机器人才能创建群聊。</small
  1164. >
  1165. <small v-if="dialog === 'editGroup'">可增减成员,最多 8 位。当前管理员不能移除;如需移除,请先更换管理员并保存。</small>
  1166. <button v-for="a in agents.filter(a => editingConversation?.activeAgentStates?.[a.id])" :key="`stop:${a.id}`" type="button" @click="stopMember(a.id)">停止 {{ a.name }}</button>
  1167. </fieldset>
  1168. <fieldset v-if="dialog === 'editGroup' && Object.keys(form.memberRoles).length">
  1169. <legend>角色分工</legend>
  1170. <p v-for="(role, id) in form.memberRoles" :key="id">{{ agents.find(a => a.id === id)?.name }} · {{ role.name }}<br /><small>{{ role.description }}</small></p>
  1171. </fieldset>
  1172. <template v-if="!isAgentDialog"
  1173. ><label v-if="form.mode !== 'discussion'"
  1174. >负责人<select v-model="form.administratorId" required>
  1175. <option
  1176. v-for="a in agents.filter((a) => form.memberIds.includes(a.id))"
  1177. :key="a.id"
  1178. :value="a.id"
  1179. >
  1180. {{ a.name }}
  1181. </option>
  1182. </select></label
  1183. >
  1184. <label>群规则(可选)<textarea v-model="form.instructions" rows="3" maxlength="24000" placeholder="例如:回答简洁,重要结论注明来源" /></label>
  1185. <button v-if="knowledgeEnabled" type="button" @click="knowledgePanel?.open('projects', form.memberIds[0], editingConversation?.projectId)">关联项目与共享记忆</button>
  1186. <p v-if="dialog === 'group'" class="group-help">创建后即可聊天。需要交付具体结果时,再开启交付目标。</p>
  1187. <details class="group-options">
  1188. <summary>高级协作设置</summary>
  1189. <label
  1190. >协作方式<select v-model="form.mode">
  1191. <option v-if="knowledgeEnabled" value="discussion">平等讨论</option>
  1192. <option value="host">管理员协调</option>
  1193. <option value="free">自由协作</option>
  1194. </select></label
  1195. >
  1196. <fieldset v-if="form.mode === 'free'">
  1197. <legend>未 @ 时自动回复</legend>
  1198. <label v-for="a in agents.filter((a) => form.memberIds.includes(a.id))" :key="a.id"
  1199. ><input v-model="form.autoReplyIds" type="checkbox" :value="a.id" />{{
  1200. a.name
  1201. }}</label
  1202. >
  1203. </fieldset>
  1204. <label v-if="form.mode !== 'discussion'"
  1205. ><input type="checkbox" :checked="form.maxReplyRounds === -1" @change="form.maxReplyRounds = ($event.target as HTMLInputElement).checked ? -1 : 3" />不限制自动协作轮数</label>
  1206. <label v-if="form.mode !== 'discussion' && form.maxReplyRounds !== -1">自动协作轮数<input
  1207. v-model.number="form.maxReplyRounds"
  1208. type="number"
  1209. min="1"
  1210. max="100"
  1211. required /></label
  1212. >
  1213. </details>
  1214. </template>
  1215. </div>
  1216. <footer>
  1217. <button class="quiet-button" type="button" @click="closeDialog">取消</button
  1218. ><button v-if="!isRemoteAgent" class="solid-button" :disabled="busy || (!isAgentDialog && (form.memberIds.length < (dialog === 'group' ? 2 : 1) || form.memberIds.length > 8))">
  1219. {{ busy ? '保存中…' : '保存' }}
  1220. </button>
  1221. </footer>
  1222. </form>
  1223. </dialog></Teleport>
  1224. <WorkspaceKnowledgePanel v-if="knowledgeEnabled" ref="knowledgePanel" :agents="agents" :conversations="conversations" :conversation-id="active?.id" :revision="knowledgeRevision" @changed="knowledgeChanged" />
  1225. </WorkspaceShell>
  1226. </template>
  1227. <style scoped>
  1228. .knowledge-shortcuts{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 12px}.knowledge-shortcuts button,.knowledge-shortcuts select{min-height:44px;padding:7px 12px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;font-size:13px;cursor:pointer}.knowledge-shortcuts button:hover{background:var(--surface-hover)}.knowledge-shortcuts button:focus-visible,.knowledge-shortcuts select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.knowledge-shortcuts label{display:flex;align-items:center;gap:8px;font-size:13px}.composer-knowledge{max-width:760px;margin:auto}
  1229. .conversation-with-computer{display:flex;height:100%;min-width:0;min-height:0}.conversation-with-computer>.workspace-chat{flex:1;min-width:0}
  1230. .workspace-chat{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column}.new-actions{display:grid;gap:2px}.new-actions button{display:flex;align-items:center;min-height:40px;padding:0 11px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);text-align:left;font-size:12px;font-weight:610;cursor:pointer}.new-actions button:hover{background:var(--surface-hover)}.header-actions{display:flex;gap:4px;margin-left:auto}.error{display:flex;align-items:center;justify-content:space-between;margin:0 18px 9px;color:var(--danger);font-size:12px}.team-avatar-settings{display:grid;gap:12px}.team-avatar-options{display:flex;gap:10px}.team-avatar-options button{padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);cursor:pointer}.team-avatar-options button[aria-pressed=true]{border-color:var(--accent)}.nodes-overlay{position:fixed;inset:0;z-index:1000;background:var(--scrim);display:grid;place-items:center}.nodes-overlay>div{padding:20px;max-height:85vh;overflow:auto;width:min(520px,90vw);background:var(--surface-raised);border:1px solid var(--line);border-radius:16px}
  1231. .editor {
  1232. border: 1px solid var(--line);
  1233. border-radius: 18px;
  1234. width: min(520px, calc(100vw - 48px));
  1235. max-height: 85vh;
  1236. overflow: hidden;
  1237. background: var(--surface);
  1238. color: var(--text-primary);
  1239. padding: 25px;
  1240. }
  1241. .editor::backdrop {
  1242. background: #0006;
  1243. }
  1244. .editor form{display:flex;flex-direction:column;max-height:calc(85dvh - 32px);min-height:0}
  1245. .editor-content{min-height:0;overflow:auto;padding:0 4px;scrollbar-gutter:stable}
  1246. .editor header,.editor footer{flex-shrink:0}
  1247. .editor header{min-height:44px}
  1248. .editor .remote-picker,.editor footer button{min-height:44px;padding:8px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text-primary);font:inherit;font-size:13px;cursor:pointer}
  1249. .editor .remote-picker:hover,.editor footer .quiet-button:hover{background:var(--surface-hover)}
  1250. .editor footer .solid-button{background:var(--text-primary);color:var(--surface);border-color:var(--text-primary)}
  1251. .editor footer button:disabled{opacity:.5;cursor:default}
  1252. .editor .remote-picker:focus-visible,.editor footer button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  1253. .editor header,
  1254. .editor footer {
  1255. display: flex;
  1256. align-items: center;
  1257. justify-content: space-between;
  1258. gap: 12px;
  1259. }
  1260. .editor h2 {
  1261. font-size: 20px;
  1262. margin: 12px 0;
  1263. }
  1264. .editor label {
  1265. display: flex;
  1266. flex-direction: column;
  1267. gap: 7px;
  1268. margin: 15px 0;
  1269. font-size: 13px;
  1270. }
  1271. .task-picker-shell{position:relative;display:inline-flex;align-items:center;max-width:210px;min-width:0}.task-picker-shell>.app-icon{position:absolute;right:11px;pointer-events:none;color:var(--text-muted)}.task-picker{width:100%;min-width:0;max-width:210px;min-height:38px;appearance:none;border:1px solid var(--line);border-radius:999px;background:var(--surface);color:var(--text-primary);padding:6px 34px 6px 14px;font:inherit;font-size:12px;font-weight:520;text-overflow:ellipsis;cursor:pointer;box-shadow:0 1px 2px color-mix(in srgb,var(--text-primary) 5%,transparent);transition:border-color 140ms ease,background-color 140ms ease,box-shadow 140ms ease}.task-picker:hover{border-color:var(--line-strong);background:var(--surface-hover)}.task-picker:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.task-picker:disabled{cursor:wait;opacity:.55}
  1272. .task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
  1273. .group-options{margin:12px 0;border-top:1px solid var(--line)}.group-options summary{min-height:44px;align-content:center;cursor:pointer;font-size:13px;color:var(--text-secondary)}.group-options summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.group-help{font-size:13px;line-height:1.6;color:var(--text-secondary)}
  1274. @media(max-width:720px){.task-picker-shell,.task-picker{max-width:118px}.task-picker{min-height:44px;padding-left:12px}}
  1275. .team-management-permission > span {
  1276. display: flex;
  1277. align-items: center;
  1278. gap: 8px;
  1279. min-height: 44px;
  1280. }
  1281. .team-management-permission small {
  1282. color: var(--text-muted);
  1283. line-height: 1.6;
  1284. }
  1285. .editor input:not([type='checkbox']),
  1286. .editor textarea,
  1287. .editor select,
  1288. .interaction input {
  1289. padding: 10px;
  1290. border: 1px solid var(--line);
  1291. border-radius: 8px;
  1292. background: var(--surface-soft);
  1293. color: inherit;
  1294. font: inherit;
  1295. }
  1296. .editor fieldset {
  1297. border: 1px solid var(--line);
  1298. border-radius: 10px;
  1299. }
  1300. .editor fieldset label {
  1301. flex-direction: row;
  1302. align-items: center;
  1303. }
  1304. .editor footer {
  1305. justify-content: flex-end;
  1306. margin-top: 12px;
  1307. }
  1308. .status {
  1309. margin-top: 8px;
  1310. }
  1311. .workspace-list-back{display:none}@media(max-width:900px){.workspace-list-back{display:grid}}
  1312. .search-delete{display:inline-flex;flex:0 0 auto;align-items:center;justify-content:center;gap:5px;min-width:64px;min-height:44px;margin-right:4px;padding:6px 9px;border:0;border-radius:8px;background:transparent;color:var(--danger);font:12px var(--font-ui);cursor:pointer}
  1313. .search-delete:hover:not(:disabled){background:var(--surface-hover)}
  1314. .search-delete:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  1315. .search-delete:disabled{opacity:.5;cursor:wait}
  1316. .search-delete-error{margin:0;padding:10px 16px 14px;color:var(--danger);font-size:12px;line-height:1.5}
  1317. .search-lifecycle-actions{display:flex;flex:0 0 auto;align-items:center;gap:2px}
  1318. .search-restore{min-height:44px;padding:6px 8px;border:0;border-radius:8px;background:transparent;color:var(--text-secondary);font:12px var(--font-ui);cursor:pointer;white-space:nowrap}
  1319. .search-restore:hover:not(:disabled){background:var(--surface-hover)}
  1320. .search-restore:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  1321. .search-restore:disabled{opacity:.5;cursor:wait}
  1322. .lifecycle-error{position:fixed;z-index:220;left:50%;bottom:max(24px,env(safe-area-inset-bottom));transform:translateX(-50%);display:flex;align-items:flex-start;gap:12px;width:max-content;max-width:min(520px,calc(100vw - 32px));padding:14px 16px;border:1px solid var(--line);border-radius:12px;background:var(--surface-raised);box-shadow:var(--shadow-float);color:var(--danger);font-size:13px;line-height:1.6}
  1323. .lifecycle-error button{display:grid;place-items:center;flex:0 0 28px;width:28px;height:28px;border:0;border-radius:6px;background:transparent;color:var(--text-secondary);cursor:pointer}
  1324. .lifecycle-error button:hover{background:var(--surface-hover)}
  1325. .lifecycle-error button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  1326. </style>