ConversationsView.vue 78 KB

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