ConversationsView.vue 80 KB

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