ConversationsView.vue 49 KB

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