ConversationsView.vue 43 KB

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