ConversationsView.vue 75 KB

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