ConversationsView.vue 42 KB

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