ConversationsView.vue 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995
  1. <script setup lang="ts">
  2. import { publishServerIdentity } from '@/api/serverIdentity'
  3. import type { ServerIdentity } from '@shared/serverIdentity'
  4. import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
  5. import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
  6. import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
  7. import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
  8. import ConversationList from '@/components/workspace/ConversationList.vue'
  9. import WorkspaceBotPanel from '@/components/workspace/WorkspaceBotPanel.vue'
  10. import LocalVmWorkspace from '@/components/workspace/LocalVmWorkspace.vue'
  11. import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
  12. import TaskPlan from '@/components/workspace/TaskPlan.vue'
  13. import type { AgentAssignment } from '@shared/agentTasks'
  14. import type { WorkspaceTask } from '@shared/workspace'
  15. import type { WorkspaceLifecycleAction, WorkspaceLifecyclePreview } from '@shared/workspaceLifecycle'
  16. import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
  17. import type { SidebarItem } from '@/components/app/types'
  18. import AgentAvatar from '@/components/common/AgentAvatar.vue'
  19. import { createUuid } from '@/utils/id'
  20. import MessageTimeline from '@/components/messages/MessageTimeline.vue'
  21. import ComposerShell from '@/components/composer/ComposerShell.vue'
  22. import type { ComposerSubmit, ComposerReference } from '@/components/composer/types'
  23. import PreviewModal from '@/components/library/PreviewModal.vue'
  24. import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
  25. import { previewItemFromUrl, mediaItemsFromMessages } from '@/components/library/mediaSequence'
  26. import type { UiLibraryItem } from '@/components/library/types'
  27. import type { UiMessage } from '@/components/messages/types'
  28. import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
  29. import TeamAvatar from '@/components/common/TeamAvatar.vue'
  30. import TeamPresetPicker from '@/components/workspace/TeamPresetPicker.vue'
  31. import { TEAM_PRESETS, type TeamPreset } from '@/components/groups/teamPresets'
  32. import type { WorkspaceMemberRole } from '@shared/workspace'
  33. import AppIcon from '@/components/common/AppIcon.vue'
  34. import { workspaceAvatarMembers, workspaceAvatarState, workspaceConversationItem, workspaceMessagesToUi } from '@/components/workspace/viewModels'
  35. import { useAuthStore } from '@/stores/auth'
  36. import { useThemeStore } from '@/stores/theme'
  37. import { apiRequest, ApiError } from '@/api/client'
  38. import { updateProfileIdentity, type ProfileIdentityInput } from '@/api/profiles'
  39. import { encodeAgentAvatar, randomAgentIdentity } from '@shared/agentIdentity'
  40. import type { JsonValue } from '@shared/types'
  41. import type {
  42. WorkspaceAgent as Agent,
  43. WorkspaceConversation as Conversation,
  44. WorkspaceMessage as Message,
  45. WorkspaceRun as Run,
  46. WorkspaceInteraction as Interaction,
  47. WorkspaceSource as Source,
  48. WorkspaceFile,
  49. WorkspaceEvent,
  50. } from '@shared/workspace'
  51. const auth = useAuthStore(),
  52. theme = useThemeStore(),
  53. route = useRoute(),
  54. router = useRouter()
  55. const agents = ref<Agent[]>([]),
  56. conversations = ref<Conversation[]>([]),
  57. messages = ref<Message[]>([]),
  58. sources = ref<Source[]>([])
  59. const active = ref<Conversation>(),
  60. run = ref<Run | null>(null),
  61. interactions = ref<Interaction[]>([]),
  62. context = ref<Record<string, unknown> | null>(null)
  63. const activeTask = ref<WorkspaceTask | null>(null), tasks = ref<WorkspaceTask[]>([]), assignments = ref<AgentAssignment[]>([])
  64. const taskFiles = new Map<string,File[]>()
  65. const taskQuotes = new Map<string,UiMessage | null>()
  66. const composerKey = computed(() => `${auth.user?.id}:${active.value?.id}${activeTask.value ? `:${activeTask.value.id}` : ''}`)
  67. const text = ref(''),
  68. error = ref(''),
  69. busy = ref(false),
  70. loading = ref(false),
  71. files = ref<WorkspaceFile[]>([]),
  72. mentions = ref<string[]>([]),
  73. older = ref(true)
  74. const identityBusy = ref(false),
  75. identityError = ref(''),
  76. identityResetVersion = ref(0)
  77. const searchItems = computed<SidebarItem[]>(() => [false, true].map(archived => {
  78. const items = conversations.value.filter(c => c.archived === archived).map(c => workspaceConversationItem(c, agents.value))
  79. return { id: archived ? 'archived' : 'unarchived', title: archived ? '已归档' : '未归档', children: items, emptyText: archived ? '没有已归档聊天' : '没有未归档聊天' }
  80. }))
  81. const archivedIds = computed(() => new Set(conversations.value.filter(c => c.archived).map(c => c.id)))
  82. const lifecycleId = ref(''), searchActionError = ref(''), lifecycleError = ref(''), lifecycleAction = ref<WorkspaceLifecycleAction>()
  83. const composer = ref<InstanceType<typeof ComposerShell>>()
  84. const timeline = ref<InstanceType<typeof MessageTimeline>>()
  85. const quoted = ref<UiMessage | null>(null)
  86. const preview = ref<UiLibraryItem | null>(null)
  87. const mediaIndex = ref<number | null>(null)
  88. const showThinking = ref(true)
  89. const uiMessages = computed(() => workspaceMessagesToUi(messages.value))
  90. const media = computed(() => mediaItemsFromMessages(uiMessages.value))
  91. 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' })))
  92. const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
  93. const avatarProfile = computed(() => ({ name: editingId.value || '机器人', displayName: form.name || '机器人', agentName: form.name || '机器人', agentAvatar: form.avatar, isDefault: false, isRunning: true }))
  94. function openPreview(file: {name: string; url?: string; kind?: string}) {
  95. let path = file.url || ''
  96. try { path = decodeURIComponent(new URL(path, window.location.origin).pathname) } catch { /* preserve the supplied URL */ }
  97. const attachment = messages.value.flatMap(message => message.attachments).find(candidate =>
  98. path === candidate.sourcePath || path === `/api/app/files/${candidate.id}/download`
  99. || path === `/api/app/files/${candidate.id}/preview`)
  100. // A Markdown label such as “报告” has no extension. The archived file's name
  101. // determines its viewer; the label is not reliable file-type metadata.
  102. const mime = attachment?.mimeType.toLowerCase().split(';')[0] || ''
  103. const textual = mime.startsWith('text/') || ['application/json', 'application/xml', 'application/yaml', 'application/x-yaml'].includes(mime)
  104. const item = previewItemFromUrl(attachment?.name || file.name, file.url || '', undefined, textual ? 'text' : undefined)
  105. const index = lightboxMedia.value.findIndex(m => m.url === (item.previewUrl || item.downloadUrl))
  106. if (index >= 0) mediaIndex.value = index
  107. else preview.value = item
  108. }
  109. let uploadedSources: File[] = [], uploadedReferences: WorkspaceFile[] = []
  110. async function sendFromComposer(payload: ComposerSubmit) {
  111. text.value = quoted.value ? `> ${(quoted.value.author || '我')}: ${quoted.value.content.replace(/\n/g, '\n> ')}\n\n${payload.text}` : payload.text
  112. mentions.value = payload.mentionIds
  113. if (payload.files.length) {
  114. try {
  115. busy.value = true
  116. if (payload.files.length === uploadedSources.length && payload.files.every((f, i) => f === uploadedSources[i])) files.value = uploadedReferences
  117. else {
  118. const data = new FormData()
  119. for (const file of payload.files) data.append('files', file)
  120. files.value = (await apiRequest<{ files: WorkspaceFile[] }>('/api/app/uploads', {method: 'POST', body: data})).files
  121. uploadedSources = [...payload.files]
  122. uploadedReferences = [...files.value]
  123. }
  124. } catch (cause) { error.value = cause instanceof Error ? cause.message : '上传失败'; return }
  125. finally { busy.value = false }
  126. }
  127. if (!payload.files.length) files.value = []
  128. if (await send()) { composer.value?.clearAfterSend(); quoted.value = null; uploadedSources = []; uploadedReferences = [] }
  129. }
  130. const dialog = ref<'agent' | 'group' | 'editAgent' | 'editGroup' | null>(null),
  131. editingId = ref(''),
  132. scroller = ref<HTMLElement>(),
  133. fileInput = ref<HTMLInputElement>(),
  134. dialogElement = ref<HTMLDialogElement>()
  135. const remotePickerOpen=ref(false)
  136. const isRemoteAgent=computed(()=>dialog.value==='editAgent' && !!agents.value.find(a=>a.id===editingId.value)?.remoteAgentId)
  137. async function remoteAdded(agent:Agent){
  138. remotePickerOpen.value=false;await refresh()
  139. if(dialog.value==='group'||dialog.value==='editGroup'){
  140. if(!form.memberIds.includes(agent.id))form.memberIds.push(agent.id)
  141. }else{
  142. const conversation=conversations.value.find(c=>c.kind==='direct'&&c.memberIds[0]===agent.id)
  143. if(conversation)await select(conversation.id)
  144. }
  145. }
  146. const form = reactive({
  147. name: '',
  148. avatar: '',
  149. instructions: '',
  150. execution: 'profile' as 'profile'|'computer',
  151. canManageTeam: false,
  152. source: '',
  153. memberIds: [] as string[],
  154. memberRoles: {} as Record<string, WorkspaceMemberRole>,
  155. administratorId: '',
  156. mode: 'host' as 'host' | 'free',
  157. autoReplyIds: [] as string[],
  158. maxReplyRounds: 3,
  159. })
  160. const selectedPresetId = ref('custom')
  161. const selectedPreset = computed(() => dialog.value === 'group' ? TEAM_PRESETS.find(p => p.id === selectedPresetId.value) : undefined)
  162. function applyPresetRoles(preset: TeamPreset) {
  163. form.memberRoles = Object.fromEntries(preset.roles.flatMap((role, index) => {
  164. const id = form.memberIds[index]
  165. return id ? [[id, { name: role.name, description: role.description }]] : []
  166. }))
  167. form.administratorId = form.memberIds[Math.max(0, preset.roles.findIndex(role => role.host))] ?? ''
  168. }
  169. function choosePreset(preset?: TeamPreset) {
  170. const available = agents.value.filter(a => !a.archived && !a.temporaryGoalId)
  171. if (preset && available.length < preset.roles.length) return
  172. selectedPresetId.value = preset?.id ?? 'custom'
  173. Object.assign(form, { name: preset?.name ?? '', instructions: preset?.instructions ?? '', memberIds: preset ? available.slice(0, preset.roles.length).map(a => a.id) : [], memberRoles: {}, administratorId: '', mode: 'host', autoReplyIds: [], maxReplyRounds: 3 })
  174. if (preset) applyPresetRoles(preset)
  175. }
  176. function assignPresetRole(index: number, id: string) {
  177. if (!selectedPreset.value) return
  178. const next = [...form.memberIds], previous = next[index], occupied = next.indexOf(id)
  179. next[index] = id
  180. if (occupied >= 0 && occupied !== index && previous) next[occupied] = previous
  181. form.memberIds = next
  182. applyPresetRoles(selectedPreset.value)
  183. }
  184. const answers = reactive<Record<string, string>>({})
  185. let cursor = 0,
  186. disposed = false,
  187. timer: ReturnType<typeof setTimeout> | undefined,
  188. generation = 0,
  189. pendingRequestId: string | undefined,
  190. pendingFingerprint = ''
  191. const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
  192. const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
  193. const shell=ref<InstanceType<typeof WorkspaceShell>>(),vmWorkspace=ref<InstanceType<typeof LocalVmWorkspace>>(),desktopViewer=ref<InstanceType<typeof ComputerPanel>>()
  194. const computerOpen=ref(false),dockView=ref<'computer'|'inspector'>(),twoDesktops=ref(false),desktopAgent=ref<Agent>()
  195. const creatingTask=ref(false)
  196. const CREATE_TASK_VALUE='__create_task__'
  197. function toggleDock(view:'computer'|'inspector'){dockView.value=dockView.value===view?undefined:view}
  198. async function openComputer(agent:Agent){
  199. if(window.yaoyaoDesktop?.openComputer){try{await window.yaoyaoDesktop.openComputer(agent.id)}catch(cause){error.value=cause instanceof Error?cause.message:'无法打开电脑窗口'}return}
  200. desktopAgent.value=agent;computerOpen.value=true
  201. }
  202. async function leaveComputer(){
  203. if(twoDesktops.value&&vmWorkspace.value&&!await vmWorkspace.value.close())return false
  204. if(computerOpen.value&&desktopViewer.value){try{await desktopViewer.value.releaseControl();computerOpen.value=false}catch{return false}}
  205. return true
  206. }
  207. onBeforeRouteUpdate(leaveComputer);onBeforeRouteLeave(leaveComputer)
  208. const computerCandidates=computed(()=>agents.value.filter(agent=>!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===activeTask.value?.goal?.id))))
  209. const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
  210. const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
  211. const body = (v: unknown) => v as JsonValue
  212. async function saveProfileIdentity(input: ProfileIdentityInput) {
  213. const profile = auth.activeProfile
  214. if (!profile) return
  215. identityBusy.value = true
  216. identityError.value = ''
  217. try {
  218. await updateProfileIdentity(profile, input)
  219. await auth.refreshProfiles()
  220. await auth.refreshProfileAvatars()
  221. identityResetVersion.value += 1
  222. } catch (cause) {
  223. identityError.value = cause instanceof Error ? cause.message : '保存机器人身份失败'
  224. } finally {
  225. identityBusy.value = false
  226. }
  227. }
  228. async function refresh() {
  229. const [a, c] = await Promise.all([
  230. apiRequest<{ agents: Agent[] }>('/api/app/agents'),
  231. apiRequest<{ conversations: Conversation[]; cursor: number }>('/api/app/conversations'),
  232. ])
  233. if (disposed) return
  234. agents.value = a.agents
  235. conversations.value = c.conversations
  236. if (!cursor) cursor = c.cursor
  237. }
  238. async function load(id = selected.value, append = false) {
  239. if (!id) {
  240. generation++
  241. active.value = undefined
  242. messages.value = []
  243. run.value = null
  244. interactions.value = []
  245. context.value = null
  246. activeTask.value = null;tasks.value = [];assignments.value = []
  247. return
  248. }
  249. const own = ++generation
  250. const previousComposer = composerKey.value
  251. const requestedTask = selectedTask.value || (active.value?.id === id ? activeTask.value?.id : undefined)
  252. if (!append) loading.value = true
  253. try {
  254. const r = await apiRequest<{
  255. conversation: Conversation
  256. messages: Message[]
  257. run: Run | null
  258. interactions: Interaction[]
  259. context: Record<string, unknown> | null
  260. hiddenMessageIds?: string[]
  261. task?: WorkspaceTask | null
  262. tasks?: WorkspaceTask[]
  263. assignments?: AgentAssignment[]
  264. }>(`/api/app/conversations/${id}${requestedTask ? `?taskId=${encodeURIComponent(requestedTask)}` : ''}`)
  265. if (disposed || own !== generation) return
  266. const atBottom = timeline.value?.isFollowingBottom() ?? true
  267. active.value = r.conversation
  268. activeTask.value = r.task ?? null;tasks.value = r.tasks ?? [];assignments.value = r.assignments ?? []
  269. messages.value = append
  270. ? [...new Map([...messages.value, ...r.messages].map((m) => [m.id, m])).values()].sort(
  271. (a, b) => a.seq - b.seq,
  272. )
  273. : r.messages
  274. messages.value = messages.value.filter(m => m.visible !== false && !r.hiddenMessageIds?.includes(m.id))
  275. run.value = r.run
  276. interactions.value = r.interactions
  277. context.value = r.context
  278. if (previousComposer !== composerKey.value) {
  279. taskFiles.set(previousComposer,composer.value?.filesSnapshot() ?? [])
  280. taskQuotes.set(previousComposer,quoted.value)
  281. if (activeTask.value && !requestedTask) {
  282. const legacy = `hermes-yaoyao:composer:group:${auth.user?.id}:${active.value.id}`
  283. const current = `hermes-yaoyao:composer:group:${composerKey.value}`
  284. try {
  285. const draft = localStorage.getItem(legacy)
  286. if (draft && !localStorage.getItem(current)) { localStorage.setItem(current,draft);localStorage.removeItem(legacy) }
  287. } catch { /* Leave the original draft intact when browser storage is unavailable. */ }
  288. }
  289. await nextTick()
  290. const saved = taskFiles.get(composerKey.value)
  291. if (saved?.length) await composer.value?.attachFiles(saved)
  292. quoted.value = taskQuotes.get(composerKey.value) ?? null
  293. }
  294. if (!append) older.value = r.messages.length === 100
  295. if (!append || atBottom) {
  296. await nextTick()
  297. if (!append) timeline.value?.scrollToBottom('auto')
  298. await markRead()
  299. }
  300. } catch (e) {
  301. if (own === generation && requestedTask && e instanceof ApiError && e.status === 404) {
  302. try {
  303. const current = await apiRequest<{tasks:WorkspaceTask[]}>(`/api/app/conversations/${id}/tasks`)
  304. const fallback = current.tasks[0]
  305. if (own === generation && fallback && fallback.id !== requestedTask) {
  306. await router.replace({query:{...route.query,taskId:fallback.id}})
  307. return
  308. }
  309. } catch { /* A deleted conversation still reports its original error. */ }
  310. }
  311. if (own === generation) error.value = e instanceof Error ? e.message : '加载失败'
  312. } finally {
  313. if (own === generation) loading.value = false
  314. }
  315. }
  316. async function markRead() {
  317. const c = active.value
  318. const read = activeTask.value ?? c
  319. if (!c || !read || read.readSeq >= read.lastSeq) return
  320. await apiRequest(`/api/app/conversations/${c.id}/read${activeTask.value ? `?taskId=${activeTask.value.id}` : ''}`, {
  321. method: 'PUT',
  322. body: { seq: read.lastSeq },
  323. })
  324. read.readSeq = read.lastSeq
  325. }
  326. async function selectTask(event:Event){
  327. const select=event.currentTarget as HTMLSelectElement,value=select.value
  328. if(value!==CREATE_TASK_VALUE){await router.push({query:{...route.query,taskId:value}});return}
  329. select.value=activeTask.value?.id??''
  330. if(!active.value||active.value.kind!=='group'||creatingTask.value)return
  331. const conversationId=active.value.id
  332. creatingTask.value=true;error.value=''
  333. try{
  334. const result=await apiRequest<{task:WorkspaceTask}>(`/api/app/conversations/${conversationId}/tasks`,{method:'POST',body:{}})
  335. if(active.value?.id!==conversationId)return
  336. tasks.value=[result.task,...tasks.value.filter(task=>task.id!==result.task.id)]
  337. await router.push({query:{...route.query,taskId:result.task.id}})
  338. }catch(cause){error.value=cause instanceof Error?cause.message:'新建任务失败'}finally{creatingTask.value=false}
  339. }
  340. async function stopTask() {
  341. if (!active.value || !activeTask.value) return
  342. try {
  343. await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/stop`,{method:'POST',body:{}})
  344. await load(undefined,true)
  345. } catch(cause) {error.value=cause instanceof Error?cause.message:'停止任务失败'}
  346. }
  347. async function resumeTask() {
  348. if (!active.value || !activeTask.value) return
  349. try {
  350. await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/resume`,{method:'POST',body:{requestId:createUuid()}})
  351. await load(undefined,true)
  352. } catch(cause) {error.value=cause instanceof Error?cause.message:'恢复任务失败'}
  353. }
  354. function openTaskLink(event: MouseEvent) {
  355. if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
  356. const anchor=(event.target as Element)?.closest?.('a[href]')
  357. if (!anchor) return
  358. try {
  359. const url=new URL(anchor.getAttribute('href') || '',window.location.origin)
  360. if(url.origin!==window.location.origin || !/^\/conversations\/[^/]+$/.test(url.pathname) || !url.searchParams.get('taskId'))return
  361. event.preventDefault();event.stopPropagation()
  362. void router.push(url.pathname+url.search)
  363. } catch { /* Leave other links to their normal handler. */ }
  364. }
  365. async function poll() {
  366. try {
  367. const r = await apiRequest<{ events: WorkspaceEvent[]; cursor: number; serverIdentity?: ServerIdentity }>(
  368. `/api/app/events?after=${cursor}`,
  369. )
  370. if (disposed) return
  371. publishServerIdentity(r.serverIdentity)
  372. cursor = r.cursor
  373. if (r.events.length) {
  374. await refresh()
  375. if (r.events.some((e) => e.conversationId === selected.value || e.type === 'agent.changed'))
  376. await load(selected.value, true)
  377. }
  378. } catch (e) {
  379. if (!disposed) error.value = e instanceof Error ? e.message : '连接中断,正在重连'
  380. } finally {
  381. if (!disposed) timer = setTimeout(() => void poll(), 1200)
  382. }
  383. }
  384. async function select(id: string) {
  385. error.value = ''
  386. files.value = []
  387. mentions.value = []
  388. text.value = ''
  389. preview.value = null
  390. mediaIndex.value = null
  391. pendingRequestId = undefined
  392. await router.push(`/conversations/${id}`)
  393. }
  394. async function openDialog(kind: NonNullable<typeof dialog.value>) {
  395. error.value = ''
  396. dialog.value = kind
  397. editingId.value = ''
  398. selectedPresetId.value = 'custom'
  399. Object.assign(form, {
  400. name: '',
  401. avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', '机器人')) : '',
  402. instructions: '',
  403. execution: 'profile' as 'profile'|'computer',
  404. canManageTeam: false,
  405. source: '',
  406. memberIds: [],
  407. memberRoles: {},
  408. administratorId: '',
  409. mode: 'host',
  410. autoReplyIds: [],
  411. maxReplyRounds: 3,
  412. })
  413. try {
  414. if (kind === 'agent' || kind === 'editAgent') {
  415. const s = await apiRequest<{ sources: Source[] }>('/api/app/agents/sources')
  416. sources.value = s.sources
  417. form.source = s.sources[0] ? JSON.stringify([s.sources[0].nodeId, s.sources[0].profile]) : ''
  418. }
  419. if (kind === 'editAgent') {
  420. const a = members.value[0]
  421. if (!a) return
  422. editingId.value = a.id
  423. Object.assign(form, a)
  424. form.source=JSON.stringify([a.nodeId,a.profile])
  425. if(a.remoteAgentId){
  426. const remote=(await apiRequest<{agents:Agent[]}>(`/api/app/nodes/${a.nodeId}/agents`)).agents.find(candidate=>candidate.id===a.remoteAgentId)
  427. if(remote)Object.assign(form,remote)
  428. }
  429. }
  430. if (kind === 'editGroup' && active.value) {
  431. editingId.value = active.value.id
  432. Object.assign(form, active.value, {
  433. memberIds: [...active.value.memberIds],
  434. autoReplyIds: [...active.value.autoReplyIds],
  435. memberRoles: Object.fromEntries(Object.entries(active.value.memberRoles ?? {}).map(([id, role]) => [id, { ...role }])),
  436. })
  437. }
  438. await nextTick()
  439. dialogElement.value?.showModal()
  440. } catch (e) {
  441. error.value = String(e)
  442. dialog.value = null
  443. }
  444. }
  445. function closeDialog() {
  446. dialogElement.value?.close()
  447. dialog.value = null
  448. }
  449. watch(
  450. () => form.memberIds.slice(),
  451. (ids) => {
  452. if (!ids.includes(form.administratorId)) form.administratorId = ids[0] ?? ''
  453. form.autoReplyIds = form.autoReplyIds.filter((id) => ids.includes(id))
  454. form.memberRoles = Object.fromEntries(Object.entries(form.memberRoles).filter(([id]) => ids.includes(id)))
  455. },
  456. )
  457. async function save() {
  458. if(isRemoteAgent.value)return
  459. busy.value = true
  460. error.value = ''
  461. try {
  462. const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, canManageTeam: form.canManageTeam, execution:form.execution } : {}), instructions: form.instructions }
  463. if (dialog.value === 'agent') {
  464. const [nodeId, profile] = JSON.parse(form.source)
  465. const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
  466. method: 'POST',
  467. body: { ...fields, nodeId, profile, computer: 'auto' },
  468. })
  469. await refresh()
  470. const c = conversations.value.find(
  471. (c) => c.kind === 'direct' && c.memberIds[0] === result.agent.id,
  472. )
  473. if (c) await select(c.id)
  474. } else if (dialog.value === 'editAgent')
  475. 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]}:{}) } })
  476. else {
  477. const payload = {
  478. ...fields,
  479. administratorId: form.administratorId,
  480. mode: form.mode,
  481. autoReplyIds: form.autoReplyIds,
  482. maxReplyRounds: form.maxReplyRounds,
  483. memberIds: form.memberIds,
  484. memberRoles: form.memberRoles,
  485. }
  486. const result = await apiRequest<{ conversation: Conversation }>(
  487. dialog.value === 'group'
  488. ? '/api/app/conversations'
  489. : `/api/app/conversations/${editingId.value}`,
  490. { method: dialog.value === 'group' ? 'POST' : 'PATCH', body: body(payload) },
  491. )
  492. await select(result.conversation.id)
  493. }
  494. closeDialog()
  495. await refresh()
  496. await load()
  497. } catch (e) {
  498. error.value = e instanceof Error ? e.message : '保存失败'
  499. } finally {
  500. busy.value = false
  501. }
  502. }
  503. async function upload(event: Event) {
  504. const picked = (event.target as HTMLInputElement).files
  505. if (!picked?.length) return
  506. busy.value = true
  507. error.value = ''
  508. try {
  509. const data = new FormData()
  510. for (const file of picked) data.append('files', file)
  511. const r = await apiRequest<{ files: WorkspaceFile[] }>('/api/app/uploads', {
  512. method: 'POST',
  513. body: data,
  514. })
  515. files.value = [...files.value, ...r.files].slice(0, 8)
  516. } catch (e) {
  517. error.value = String(e)
  518. } finally {
  519. busy.value = false
  520. ;(event.target as HTMLInputElement).value = ''
  521. }
  522. }
  523. async function send() {
  524. const c = active.value
  525. if (!c || busy.value || (!text.value.trim() && !files.value.length)) return
  526. busy.value = true
  527. error.value = ''
  528. const fingerprint = JSON.stringify([c.id, activeTask.value?.id, text.value, mentions.value, files.value.map(f => f.id)])
  529. if (!pendingRequestId || pendingFingerprint !== fingerprint) {
  530. pendingRequestId = createUuid()
  531. pendingFingerprint = fingerprint
  532. }
  533. try {
  534. await apiRequest(`/api/app/conversations/${c.id}/messages`, {
  535. method: 'POST',
  536. body: {
  537. requestId: pendingRequestId,
  538. ...(activeTask.value ? { taskId: activeTask.value.id } : {}),
  539. content: text.value,
  540. mentionIds: mentions.value,
  541. fileIds: files.value.map((f) => f.id),
  542. },
  543. })
  544. text.value = ''
  545. files.value = []
  546. mentions.value = []
  547. pendingRequestId = undefined
  548. pendingFingerprint = ''
  549. await load(c.id, true)
  550. await refresh()
  551. return true
  552. } catch (e) {
  553. error.value = e instanceof Error ? e.message : '发送失败'
  554. } finally {
  555. busy.value = false
  556. }
  557. }
  558. async function action(operation: 'pin' | 'archive', id = active.value?.id) {
  559. const c = conversations.value.find(c => c.id === id)
  560. if (!c) return
  561. if (operation === 'archive') return changeLifecycle(c.archived ? 'restore' : 'archive', c.id)
  562. try {
  563. await apiRequest(`/api/app/conversations/${c.id}`, {
  564. method: 'PATCH',
  565. body: { pinned: !c.pinned },
  566. })
  567. await refresh()
  568. await load()
  569. } catch (e) {
  570. error.value = String(e)
  571. }
  572. }
  573. async function changeLifecycle(operation: WorkspaceLifecycleAction, id: string, fromSearch = false) {
  574. const conversation = conversations.value.find(c => c.id === id)
  575. if (!conversation || lifecycleId.value) return
  576. const actionError = fromSearch ? searchActionError : lifecycleError
  577. actionError.value = ''
  578. lifecycleId.value = id
  579. lifecycleAction.value = operation
  580. try {
  581. const path = `/api/app/conversations/${id}/lifecycle`
  582. let confirmationToken: string | undefined
  583. if (operation !== 'restore') {
  584. const preview = await apiRequest<WorkspaceLifecyclePreview>(path)
  585. const managed = preview.groups.filter(group => group.administrator)
  586. if (managed.length) throw new Error(`此 Bot 是群聊${managed.map(group => `「${group.name}」`).join('、')}的管理者,请先转交管理权,再归档或删除。`)
  587. const verb = operation === 'delete' ? '永久删除' : '归档'
  588. const target = preview.kind === 'direct' ? 'Bot' : '群聊'
  589. let message = `${verb} ${target}「${preview.name}」${operation === 'delete' ? '及其聊天记录' : ''}?`
  590. if (preview.groups.length) message += `\n此 Bot 已加入群聊:${preview.groups.map(group => `「${group.name}」`).join('、')}。确认后会先从这些群聊移除,再${verb}。`
  591. message += operation === 'delete'
  592. ? `\n此操作无法撤销,${preview.kind === 'group' ? '成员 Bot 和' : ''}文件库中的文件会保留。`
  593. : '\n聊天记录会保留,可在搜索的“已归档”中取消归档。'
  594. if (!confirm(message)) return
  595. confirmationToken = preview.confirmationToken
  596. }
  597. await apiRequest(path, { method: 'POST', body: { action: operation, ...(confirmationToken ? { confirmationToken } : {}) } })
  598. if (operation === 'delete') {
  599. conversations.value = conversations.value.filter(c => c.id !== id)
  600. if (conversation.kind === 'direct') agents.value = agents.value.filter(a => a.id !== conversation.memberIds[0])
  601. if (selected.value === id) await router.replace('/conversations')
  602. }
  603. await refresh()
  604. if (selected.value === id) await load()
  605. } catch (cause) {
  606. actionError.value = cause instanceof Error ? cause.message : '操作失败,请重试'
  607. } finally {
  608. lifecycleId.value = ''
  609. lifecycleAction.value = undefined
  610. }
  611. }
  612. async function stopMember(id: string) {
  613. if (!active.value) return
  614. try {
  615. await apiRequest(`/api/app/conversations/${active.value.id}/agents/${id}/stop`, { method: 'POST', body: {} })
  616. await load(); await refresh()
  617. } catch (cause) { error.value = cause instanceof Error ? cause.message : '停止失败' }
  618. }
  619. async function control(action: 'stop' | 'reconcile') {
  620. if (!run.value) return
  621. try {
  622. await apiRequest(`/api/app/runs/${run.value.id}/${action}`, { method: 'POST', body: {} })
  623. await load(undefined, true)
  624. } catch (e) {
  625. error.value = String(e)
  626. }
  627. }
  628. async function respond(i: Interaction, answer: string) {
  629. try {
  630. await apiRequest(`/api/app/interactions/${i.id}/respond`, { method: 'POST', body: { answer } })
  631. await load(undefined, true)
  632. } catch (e) {
  633. error.value = String(e)
  634. }
  635. }
  636. async function loadOlder() {
  637. const c = active.value
  638. if (!c) return
  639. const taskId = activeTask.value?.id, own = generation
  640. try {
  641. const r = await apiRequest<{ messages: Message[] }>(
  642. `/api/app/conversations/${c.id}/messages?before=${messages.value[0]?.seq ?? 0}${activeTask.value ? `&taskId=${activeTask.value.id}` : ''}`,
  643. )
  644. if (own !== generation || active.value?.id !== c.id || activeTask.value?.id !== taskId) return
  645. messages.value = [...r.messages, ...messages.value]
  646. older.value = r.messages.length === 100
  647. } catch (e) {
  648. error.value = String(e)
  649. }
  650. }
  651. function rendered(m: Message) {
  652. return m.content.replace(
  653. /(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g,
  654. (whole, label: string, path: string) => {
  655. const file =
  656. m.attachments.find((f) => f.sourcePath === path) ||
  657. m.attachments.find((f) => path.split('/').at(-1) === f.name)
  658. return file
  659. ? `${label}(/api/app/files/${file.id}/${label.startsWith('!') ? 'preview' : 'download'})`
  660. : whole
  661. },
  662. )
  663. }
  664. watch([selected,selectedTask], () => {
  665. taskFiles.set(composerKey.value, composer.value?.filesSnapshot() ?? [])
  666. taskQuotes.set(composerKey.value, quoted.value)
  667. files.value = [];mentions.value = [];text.value = ''
  668. pendingRequestId = undefined;uploadedSources = [];uploadedReferences = []
  669. void load()
  670. })
  671. watch(selected,()=>{dockView.value=undefined})
  672. onMounted(async () => {
  673. void auth.refreshProfileAvatars().catch(() => undefined)
  674. try {
  675. await apiRequest('/api/app/capabilities')
  676. await refresh()
  677. await load()
  678. if (!disposed) void poll()
  679. } catch (e) {
  680. error.value = String(e)
  681. }
  682. })
  683. onBeforeUnmount(() => {
  684. disposed = true
  685. generation++
  686. if (timer) clearTimeout(timer)
  687. })
  688. </script>
  689. <template>
  690. <WorkspaceShell ref="shell" :server-name="auth.serverIdentity?.displayName"
  691. :user-name="auth.user?.username"
  692. :user-avatar="auth.user?.avatar"
  693. :profiles="auth.profiles"
  694. :active-profile="auth.activeProfile"
  695. :is-admin="auth.user?.role === 'admin'"
  696. :upstream-ready="auth.upstreamReady"
  697. :upstream-error="auth.upstreamError"
  698. :theme="theme.resolvedTheme"
  699. :theme-preference="theme.theme"
  700. :identity-busy="identityBusy"
  701. :identity-error="identityError"
  702. :identity-reset-version="identityResetVersion"
  703. sidebar-title="聊天"
  704. sidebar-context-title="聊天列表"
  705. @logout="auth.logout"
  706. @select-profile="auth.selectProfile"
  707. @toggle-theme="theme.toggle"
  708. @set-theme="theme.setTheme"
  709. @save-identity="saveProfileIdentity"
  710. @create-agent="openDialog('agent')"
  711. @create-group="openDialog('group')"
  712. @create-remote-agent="remotePickerOpen = true"
  713. >
  714. <template #sidebar
  715. ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
  716. /></template>
  717. <template #mobile-sidebar
  718. ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
  719. /></template>
  720. <div v-show="!twoDesktops" class="conversation-with-computer">
  721. <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
  722. <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
  723. :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
  724. :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
  725. :show-tools="showThinking" :allow-branch="false" :thinking="!!run && ['running','waiting','uncertain'].includes(run.status)"
  726. :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"
  727. :agent-states="Object.fromEntries(members.map(a => [a.id, workspaceAvatarState(active, a.id)]))"
  728. :mention-names="members.map(a => a.name)"
  729. :empty-title="active ? '开始一段新对话' : '还没有聊天'"
  730. :empty-description="active ? '从下方输入框发送消息。' : '创建机器人,或选择成员新建群聊。'"
  731. :interaction="interactions[0] ? { id: interactions[0].id, kind: interactions[0].kind, prompt: interactions[0].message, options: interactions[0].choices } : null"
  732. @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
  733. @approve="interactions[0] && respond(interactions[0], $event ? 'once' : 'deny')"
  734. @clarify="interactions[0] && respond(interactions[0], $event)">
  735. <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>
  736. <template #header-actions>
  737. <div v-if="active" class="header-actions">
  738. <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>
  739. <span v-if="active.kind === 'group' && tasks.length" class="task-picker-shell">
  740. <select class="task-picker" aria-label="当前任务" :aria-busy="creatingTask" :disabled="creatingTask" :value="activeTask?.id" @change="selectTask">
  741. <option v-for="task in tasks" :key="task.id" :value="task.id">{{ task.title }}</option>
  742. <option :value="CREATE_TASK_VALUE">新建任务</option>
  743. </select>
  744. <AppIcon name="chevron-down" :size="14"/>
  745. </span>
  746. <button class="icon-button" aria-label="聊天设置" title="聊天设置" @click="openDialog(active.kind === 'direct' ? 'editAgent' : 'editGroup')"><AppIcon name="settings" /></button>
  747. <button type="button" class="icon-button" aria-label="Inspector" title="Inspector" :aria-pressed="dockView==='inspector'" @click="toggleDock('inspector')"><AppIcon name="bug"/></button>
  748. </div>
  749. </template>
  750. </MessageTimeline>
  751. <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用机器人</p>
  752. <TaskPlan :goal="activeTask?.goal" :assignments="assignments" :agents="agents" @stop="stopTask" @resume="resumeTask" />
  753. <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
  754. <ComposerShell v-if="active" :key="composerKey" ref="composer" mode="group" :draft-key="composerKey"
  755. :disabled="active.archived || loading || active.id !== selected" :sending="busy" stop-while-running :streaming="!!active.activeRunId"
  756. :tool-trace-visible="showThinking" :reference="reference" :context-used="Number(context?.usedTokens || 0)" :context-limit="Number(context?.limitTokens || 0)"
  757. :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
  758. @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
  759. </section>
  760. <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" />
  761. </div>
  762. <ComputerPanel ref="desktopViewer" v-if="computerOpen&&desktopAgent" :agents="[desktopAgent]" auto-take @changed="load()" @close="computerOpen=false" />
  763. <LocalVmWorkspace ref="vmWorkspace" v-if="twoDesktops&&desktopAgent" :agents="agents.filter(a=>a.execution==='computer'&&!a.archived&&!a.computerEnvironmentId)" :primary="desktopAgent.id" @close="twoDesktops=false" />
  764. <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @open="searchActionError = ''" @select="select">
  765. <template #item-actions="{ item }">
  766. <div v-if="archivedIds.has(item.id)" class="search-lifecycle-actions">
  767. <button type="button" class="search-restore" :aria-label="`取消归档:${item.title}`" :disabled="!!lifecycleId" @click="changeLifecycle('restore', item.id, true)">{{ lifecycleId === item.id && lifecycleAction === 'restore' ? '恢复中…' : '取消归档' }}</button>
  768. <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)">
  769. <AppIcon name="trash" :size="15" />{{ lifecycleId === item.id && lifecycleAction === 'delete' ? '删除中…' : '删除' }}
  770. </button>
  771. </div>
  772. </template>
  773. <template #footer><p v-if="searchActionError" class="search-delete-error" role="alert">{{ searchActionError }}</p></template>
  774. </FloatingResourceSearch>
  775. <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>
  776. <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
  777. <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
  778. <RemoteAgentPicker v-if="remotePickerOpen && !auth.isBotOnly" @close="remotePickerOpen = false" @added="remoteAdded" />
  779. <Teleport to="body"><dialog v-if="dialog" ref="dialogElement" class="editor" @cancel.prevent="closeDialog">
  780. <form @submit.prevent="save">
  781. <header>
  782. <h2>
  783. {{ dialog === 'agent' ? '创建机器人' : dialog === 'group' ? '创建群聊' : '编辑资料' }}
  784. </h2>
  785. <button type="button" aria-label="关闭" @click="closeDialog">×</button>
  786. </header>
  787. <p v-if="error" class="error" role="alert">{{ error }}</p>
  788. <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived && !a.temporaryGoalId).length" @select="choosePreset" />
  789. <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
  790. <p v-if="isRemoteAgent">引用的机器人配置由远端管理,请在远端修改名称、头像和角色规则。</p>
  791. <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" />
  792. <div v-else-if="!isAgentDialog" class="team-avatar-settings">
  793. <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? active : undefined)" :size="64" />
  794. <small>群聊头像由成员头像自动组合,随成员头像更新。</small>
  795. </div>
  796. <label
  797. v-if="isAgentDialog&&!isRemoteAgent"
  798. >基础机器人<select v-model="form.source" required>
  799. <option
  800. v-for="s in sources"
  801. :key="`${s.nodeId}:${s.profile}`"
  802. :value="JSON.stringify([s.nodeId, s.profile])"
  803. >
  804. {{ s.name }} · {{ s.nodeId === 'local' ? '当前服务' : s.nodeId }}
  805. </option></select
  806. ><small v-if="!sources.length"
  807. >{{ auth.isBotOnly ? '暂无可用的已分配机器人,请联系管理员分配或检查连接。' : '没有可用的基础机器人,请检查 Web 的 Hermes 连接。' }}</small
  808. ></label
  809. ><label
  810. >{{ isAgentDialog ? '角色提示词与规则' : '群规则'
  811. }}<textarea
  812. v-model="form.instructions"
  813. :readonly="isRemoteAgent"
  814. rows="6"
  815. maxlength="24000"
  816. :placeholder="
  817. isAgentDialog
  818. ? '例如:你是代码审查员。重点检查正确性、边界情况和测试证据。'
  819. : '所有成员共同遵守的协作规则'
  820. "
  821. />
  822. </label>
  823. <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
  824. <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
  825. <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础机器人,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
  826. </label>
  827. <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程机器人</button>
  828. <fieldset v-if="selectedPreset" class="preset-role-mapping">
  829. <legend>角色分配</legend>
  830. <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
  831. {{ role.name }}{{ role.host ? ' · 管理员' : '' }}
  832. <select :value="form.memberIds[index]" :aria-label="`${role.name}对应的机器人`" @change="assignPresetRole(index, ($event.target as HTMLSelectElement).value)">
  833. <option v-for="a in agents.filter(a => !a.archived && !a.temporaryGoalId)" :key="a.id" :value="a.id">{{ a.name }}</option>
  834. </select>
  835. <small>{{ role.description }}</small>
  836. </label>
  837. <small>角色分工仅在本群生效,聊天中保留机器人的名称和头像。</small>
  838. </fieldset>
  839. <fieldset v-else-if="!isAgentDialog">
  840. <legend>选择成员</legend>
  841. <label
  842. v-for="a in agents.filter((a) =>
  843. !a.temporaryGoalId && (!a.archived || (dialog === 'editGroup' && active?.memberIds.includes(a.id))),
  844. )"
  845. :key="a.id"
  846. ><input
  847. v-model="form.memberIds"
  848. type="checkbox"
  849. :value="a.id"
  850. :disabled="busy || (dialog === 'editGroup' && (a.id === active?.administratorId || a.id === form.administratorId)) || (!form.memberIds.includes(a.id) && form.memberIds.length >= 8)"
  851. />{{ a.name }}</label
  852. ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived && !a.temporaryGoalId).length < 2"
  853. >至少需要两个机器人才能创建群聊。</small
  854. >
  855. <small v-if="dialog === 'editGroup'">可增减成员,最多 8 位。当前管理员不能移除;如需移除,请先更换管理员并保存。</small>
  856. <button v-for="a in agents.filter(a => active?.activeAgentStates?.[a.id])" :key="`stop:${a.id}`" type="button" @click="stopMember(a.id)">停止 {{ a.name }}</button>
  857. </fieldset>
  858. <fieldset v-if="dialog === 'editGroup' && Object.keys(form.memberRoles).length">
  859. <legend>角色分工</legend>
  860. <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>
  861. </fieldset>
  862. <template v-if="!isAgentDialog"
  863. ><label
  864. >管理员<select v-model="form.administratorId" required>
  865. <option
  866. v-for="a in agents.filter((a) => form.memberIds.includes(a.id))"
  867. :key="a.id"
  868. :value="a.id"
  869. >
  870. {{ a.name }}
  871. </option>
  872. </select></label
  873. ><label
  874. >协作方式<select v-model="form.mode">
  875. <option value="host">管理员协调</option>
  876. <option value="free">自由协作</option>
  877. </select></label
  878. >
  879. <fieldset v-if="form.mode === 'free'">
  880. <legend>未 @ 时自动回复</legend>
  881. <label v-for="a in agents.filter((a) => form.memberIds.includes(a.id))" :key="a.id"
  882. ><input v-model="form.autoReplyIds" type="checkbox" :value="a.id" />{{
  883. a.name
  884. }}</label
  885. >
  886. </fieldset>
  887. <label
  888. ><input type="checkbox" :checked="form.maxReplyRounds === -1" @change="form.maxReplyRounds = ($event.target as HTMLInputElement).checked ? -1 : 3" />不限制自动协作轮数</label>
  889. <label v-if="form.maxReplyRounds !== -1">自动协作轮数<input
  890. v-model.number="form.maxReplyRounds"
  891. type="number"
  892. min="1"
  893. max="100"
  894. required /></label
  895. ></template>
  896. <footer>
  897. <button class="quiet-button" type="button" @click="closeDialog">取消</button
  898. ><button v-if="!isRemoteAgent" class="solid-button" :disabled="busy || (!isAgentDialog && (form.memberIds.length < (dialog === 'group' ? 2 : 1) || form.memberIds.length > 8))">
  899. {{ busy ? '保存中…' : '保存' }}
  900. </button>
  901. </footer>
  902. </form>
  903. </dialog></Teleport>
  904. </WorkspaceShell>
  905. </template>
  906. <style scoped>
  907. .conversation-with-computer{display:flex;height:100%;min-width:0;min-height:0}.conversation-with-computer>.workspace-chat{flex:1;min-width:0}
  908. .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}
  909. .editor {
  910. border: 1px solid var(--line);
  911. border-radius: 18px;
  912. width: min(520px, calc(100vw - 48px));
  913. max-height: 85vh;
  914. overflow: auto;
  915. background: var(--surface);
  916. color: var(--text-primary);
  917. padding: 25px;
  918. }
  919. .editor::backdrop {
  920. background: #0006;
  921. }
  922. .editor header,
  923. .editor footer {
  924. display: flex;
  925. align-items: center;
  926. justify-content: space-between;
  927. gap: 12px;
  928. }
  929. .editor h2 {
  930. font-size: 20px;
  931. }
  932. .editor label {
  933. display: flex;
  934. flex-direction: column;
  935. gap: 7px;
  936. margin: 15px 0;
  937. font-size: 13px;
  938. }
  939. .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}
  940. .task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
  941. @media(max-width:720px){.task-picker-shell,.task-picker{max-width:118px}.task-picker{min-height:44px;padding-left:12px}}
  942. .team-management-permission > span {
  943. display: flex;
  944. align-items: center;
  945. gap: 8px;
  946. min-height: 44px;
  947. }
  948. .team-management-permission small {
  949. color: var(--text-muted);
  950. line-height: 1.6;
  951. }
  952. .editor input:not([type='checkbox']),
  953. .editor textarea,
  954. .editor select,
  955. .interaction input {
  956. padding: 10px;
  957. border: 1px solid var(--line);
  958. border-radius: 8px;
  959. background: var(--surface-soft);
  960. color: inherit;
  961. font: inherit;
  962. }
  963. .editor fieldset {
  964. border: 1px solid var(--line);
  965. border-radius: 10px;
  966. }
  967. .editor fieldset label {
  968. flex-direction: row;
  969. align-items: center;
  970. }
  971. .editor footer {
  972. justify-content: flex-end;
  973. margin-top: 25px;
  974. }
  975. .status {
  976. margin-top: 8px;
  977. }
  978. .workspace-list-back{display:none}@media(max-width:900px){.workspace-list-back{display:grid}}
  979. .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}
  980. .search-delete:hover:not(:disabled){background:var(--surface-hover)}
  981. .search-delete:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  982. .search-delete:disabled{opacity:.5;cursor:wait}
  983. .search-delete-error{margin:0;padding:10px 16px 14px;color:var(--danger);font-size:12px;line-height:1.5}
  984. .search-lifecycle-actions{display:flex;flex:0 0 auto;align-items:center;gap:2px}
  985. .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}
  986. .search-restore:hover:not(:disabled){background:var(--surface-hover)}
  987. .search-restore:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  988. .search-restore:disabled{opacity:.5;cursor:wait}
  989. .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}
  990. .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}
  991. .lifecycle-error button:hover{background:var(--surface-hover)}
  992. .lifecycle-error button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  993. </style>