ConversationsView.vue 79 KB

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