| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909 |
- <script setup lang="ts">
- import { publishServerIdentity } from '@/api/serverIdentity'
- import type { ServerIdentity } from '@shared/serverIdentity'
- import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
- import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
- import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
- import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
- import ConversationList from '@/components/workspace/ConversationList.vue'
- import LocalVmChatPanel from '@/components/workspace/LocalVmChatPanel.vue'
- import LocalVmWorkspace from '@/components/workspace/LocalVmWorkspace.vue'
- import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
- import TaskPlan from '@/components/workspace/TaskPlan.vue'
- import type { AgentAssignment } from '@shared/agentTasks'
- import type { WorkspaceTask } from '@shared/workspace'
- import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
- import type { SidebarItem } from '@/components/app/types'
- import AgentAvatar from '@/components/common/AgentAvatar.vue'
- import { createUuid } from '@/utils/id'
- import MessageTimeline from '@/components/messages/MessageTimeline.vue'
- import ComposerShell from '@/components/composer/ComposerShell.vue'
- import type { ComposerSubmit, ComposerReference } from '@/components/composer/types'
- import PreviewModal from '@/components/library/PreviewModal.vue'
- import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
- import { previewItemFromUrl, mediaItemsFromMessages } from '@/components/library/mediaSequence'
- import type { UiLibraryItem } from '@/components/library/types'
- import type { UiMessage } from '@/components/messages/types'
- import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
- import TeamAvatar from '@/components/common/TeamAvatar.vue'
- import TeamPresetPicker from '@/components/workspace/TeamPresetPicker.vue'
- import { TEAM_PRESETS, type TeamPreset } from '@/components/groups/teamPresets'
- import type { WorkspaceMemberRole } from '@shared/workspace'
- import AppIcon from '@/components/common/AppIcon.vue'
- import { workspaceAvatarMembers, workspaceAvatarState, workspaceConversationItem, workspaceMessagesToUi } from '@/components/workspace/viewModels'
- import { useAuthStore } from '@/stores/auth'
- import { useThemeStore } from '@/stores/theme'
- import { apiRequest, ApiError } from '@/api/client'
- import { updateProfileIdentity, type ProfileIdentityInput } from '@/api/profiles'
- import { encodeAgentAvatar, randomAgentIdentity } from '@shared/agentIdentity'
- import type { JsonValue } from '@shared/types'
- import type {
- WorkspaceAgent as Agent,
- WorkspaceConversation as Conversation,
- WorkspaceMessage as Message,
- WorkspaceRun as Run,
- WorkspaceInteraction as Interaction,
- WorkspaceSource as Source,
- WorkspaceFile,
- WorkspaceEvent,
- } from '@shared/workspace'
- const auth = useAuthStore(),
- theme = useThemeStore(),
- route = useRoute(),
- router = useRouter()
- const agents = ref<Agent[]>([]),
- conversations = ref<Conversation[]>([]),
- messages = ref<Message[]>([]),
- sources = ref<Source[]>([])
- const active = ref<Conversation>(),
- run = ref<Run | null>(null),
- interactions = ref<Interaction[]>([]),
- context = ref<Record<string, unknown> | null>(null)
- const activeTask = ref<WorkspaceTask | null>(null), tasks = ref<WorkspaceTask[]>([]), assignments = ref<AgentAssignment[]>([])
- const taskFiles = new Map<string,File[]>()
- const taskQuotes = new Map<string,UiMessage | null>()
- const composerKey = computed(() => `${auth.user?.id}:${active.value?.id}${activeTask.value ? `:${activeTask.value.id}` : ''}`)
- const text = ref(''),
- error = ref(''),
- busy = ref(false),
- loading = ref(false),
- files = ref<WorkspaceFile[]>([]),
- mentions = ref<string[]>([]),
- older = ref(true)
- const identityBusy = ref(false),
- identityError = ref(''),
- identityResetVersion = ref(0)
- const searchItems = computed<SidebarItem[]>(() => [false, true].map(archived => {
- const items = conversations.value.filter(c => c.archived === archived).map(c => workspaceConversationItem(c, agents.value))
- return { id: archived ? 'archived' : 'unarchived', title: archived ? '已归档' : '未归档', children: items, emptyText: archived ? '没有已归档聊天' : '没有未归档聊天' }
- }))
- const composer = ref<InstanceType<typeof ComposerShell>>()
- const timeline = ref<InstanceType<typeof MessageTimeline>>()
- const quoted = ref<UiMessage | null>(null)
- const preview = ref<UiLibraryItem | null>(null)
- const mediaIndex = ref<number | null>(null)
- const showThinking = ref(true)
- const uiMessages = computed(() => workspaceMessagesToUi(messages.value))
- const media = computed(() => mediaItemsFromMessages(uiMessages.value))
- 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' })))
- const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
- const avatarProfile = computed(() => ({ name: editingId.value || 'Agent', displayName: form.name || 'Agent', agentName: form.name || 'Agent', agentAvatar: form.avatar, isDefault: false, isRunning: true }))
- function openPreview(file: {name: string; url?: string; kind?: string}) {
- let path = file.url || ''
- try { path = decodeURIComponent(new URL(path, window.location.origin).pathname) } catch { /* preserve the supplied URL */ }
- const attachment = messages.value.flatMap(message => message.attachments).find(candidate =>
- path === candidate.sourcePath || path === `/api/app/files/${candidate.id}/download`
- || path === `/api/app/files/${candidate.id}/preview`)
- // A Markdown label such as “报告” has no extension. The archived file's name
- // determines its viewer; the label is not reliable file-type metadata.
- const mime = attachment?.mimeType.toLowerCase().split(';')[0] || ''
- const textual = mime.startsWith('text/') || ['application/json', 'application/xml', 'application/yaml', 'application/x-yaml'].includes(mime)
- const item = previewItemFromUrl(attachment?.name || file.name, file.url || '', undefined, textual ? 'text' : undefined)
- const index = lightboxMedia.value.findIndex(m => m.url === (item.previewUrl || item.downloadUrl))
- if (index >= 0) mediaIndex.value = index
- else preview.value = item
- }
- let uploadedSources: File[] = [], uploadedReferences: WorkspaceFile[] = []
- async function sendFromComposer(payload: ComposerSubmit) {
- text.value = quoted.value ? `> ${(quoted.value.author || '我')}: ${quoted.value.content.replace(/\n/g, '\n> ')}\n\n${payload.text}` : payload.text
- mentions.value = payload.mentionIds
- if (payload.files.length) {
- try {
- busy.value = true
- if (payload.files.length === uploadedSources.length && payload.files.every((f, i) => f === uploadedSources[i])) files.value = uploadedReferences
- else {
- const data = new FormData()
- for (const file of payload.files) data.append('files', file)
- files.value = (await apiRequest<{ files: WorkspaceFile[] }>('/api/app/uploads', {method: 'POST', body: data})).files
- uploadedSources = [...payload.files]
- uploadedReferences = [...files.value]
- }
- } catch (cause) { error.value = cause instanceof Error ? cause.message : '上传失败'; return }
- finally { busy.value = false }
- }
- if (!payload.files.length) files.value = []
- if (await send()) { composer.value?.clearAfterSend(); quoted.value = null; uploadedSources = []; uploadedReferences = [] }
- }
- const dialog = ref<'agent' | 'group' | 'editAgent' | 'editGroup' | null>(null),
- editingId = ref(''),
- scroller = ref<HTMLElement>(),
- fileInput = ref<HTMLInputElement>(),
- dialogElement = ref<HTMLDialogElement>()
- const remotePickerOpen=ref(false)
- const isRemoteAgent=computed(()=>dialog.value==='editAgent' && !!agents.value.find(a=>a.id===editingId.value)?.remoteAgentId)
- async function remoteAdded(agent:Agent){
- remotePickerOpen.value=false;await refresh()
- if(dialog.value==='group'||dialog.value==='editGroup'){
- if(!form.memberIds.includes(agent.id))form.memberIds.push(agent.id)
- }else{
- const conversation=conversations.value.find(c=>c.kind==='direct'&&c.memberIds[0]===agent.id)
- if(conversation)await select(conversation.id)
- }
- }
- const form = reactive({
- name: '',
- avatar: '',
- instructions: '',
- execution: 'profile' as 'profile'|'computer',
- canManageTeam: false,
- source: '',
- memberIds: [] as string[],
- memberRoles: {} as Record<string, WorkspaceMemberRole>,
- administratorId: '',
- mode: 'host' as 'host' | 'free',
- autoReplyIds: [] as string[],
- maxReplyRounds: 3,
- })
- const selectedPresetId = ref('custom')
- const selectedPreset = computed(() => dialog.value === 'group' ? TEAM_PRESETS.find(p => p.id === selectedPresetId.value) : undefined)
- function applyPresetRoles(preset: TeamPreset) {
- form.memberRoles = Object.fromEntries(preset.roles.flatMap((role, index) => {
- const id = form.memberIds[index]
- return id ? [[id, { name: role.name, description: role.description }]] : []
- }))
- form.administratorId = form.memberIds[Math.max(0, preset.roles.findIndex(role => role.host))] ?? ''
- }
- function choosePreset(preset?: TeamPreset) {
- const available = agents.value.filter(a => !a.archived && !a.temporaryGoalId)
- if (preset && available.length < preset.roles.length) return
- selectedPresetId.value = preset?.id ?? 'custom'
- 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 })
- if (preset) applyPresetRoles(preset)
- }
- function assignPresetRole(index: number, id: string) {
- if (!selectedPreset.value) return
- const next = [...form.memberIds], previous = next[index], occupied = next.indexOf(id)
- next[index] = id
- if (occupied >= 0 && occupied !== index && previous) next[occupied] = previous
- form.memberIds = next
- applyPresetRoles(selectedPreset.value)
- }
- const answers = reactive<Record<string, string>>({})
- let cursor = 0,
- disposed = false,
- timer: ReturnType<typeof setTimeout> | undefined,
- generation = 0,
- pendingRequestId: string | undefined,
- pendingFingerprint = ''
- const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
- const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
- const shell=ref<InstanceType<typeof WorkspaceShell>>(),vmWorkspace=ref<InstanceType<typeof LocalVmWorkspace>>(),desktopViewer=ref<InstanceType<typeof ComputerPanel>>()
- const computerOpen=ref(false),computerDockOpen=ref(false),twoDesktops=ref(false),desktopAgent=ref<Agent>()
- async function leaveComputer(){
- if(twoDesktops.value&&vmWorkspace.value&&!await vmWorkspace.value.close())return false
- if(computerOpen.value&&desktopViewer.value){try{await desktopViewer.value.releaseControl();computerOpen.value=false}catch{return false}}
- return true
- }
- onBeforeRouteUpdate(leaveComputer);onBeforeRouteLeave(leaveComputer)
- const computerCandidates=computed(()=>agents.value.filter(agent=>!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===activeTask.value?.goal?.id))))
- const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
- const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
- const body = (v: unknown) => v as JsonValue
- async function saveProfileIdentity(input: ProfileIdentityInput) {
- const profile = auth.activeProfile
- if (!profile) return
- identityBusy.value = true
- identityError.value = ''
- try {
- await updateProfileIdentity(profile, input)
- await auth.refreshProfiles()
- await auth.refreshProfileAvatars()
- identityResetVersion.value += 1
- } catch (cause) {
- identityError.value = cause instanceof Error ? cause.message : '保存 Agent 身份失败'
- } finally {
- identityBusy.value = false
- }
- }
- async function refresh() {
- const [a, c] = await Promise.all([
- apiRequest<{ agents: Agent[] }>('/api/app/agents'),
- apiRequest<{ conversations: Conversation[]; cursor: number }>('/api/app/conversations'),
- ])
- if (disposed) return
- agents.value = a.agents
- conversations.value = c.conversations
- if (!cursor) cursor = c.cursor
- }
- async function load(id = selected.value, append = false) {
- if (!id) {
- generation++
- active.value = undefined
- messages.value = []
- run.value = null
- interactions.value = []
- context.value = null
- activeTask.value = null;tasks.value = [];assignments.value = []
- return
- }
- const own = ++generation
- const previousComposer = composerKey.value
- const requestedTask = selectedTask.value || (active.value?.id === id ? activeTask.value?.id : undefined)
- if (!append) loading.value = true
- try {
- const r = await apiRequest<{
- conversation: Conversation
- messages: Message[]
- run: Run | null
- interactions: Interaction[]
- context: Record<string, unknown> | null
- hiddenMessageIds?: string[]
- task?: WorkspaceTask | null
- tasks?: WorkspaceTask[]
- assignments?: AgentAssignment[]
- }>(`/api/app/conversations/${id}${requestedTask ? `?taskId=${encodeURIComponent(requestedTask)}` : ''}`)
- if (disposed || own !== generation) return
- const atBottom = timeline.value?.isFollowingBottom() ?? true
- active.value = r.conversation
- activeTask.value = r.task ?? null;tasks.value = r.tasks ?? [];assignments.value = r.assignments ?? []
- messages.value = append
- ? [...new Map([...messages.value, ...r.messages].map((m) => [m.id, m])).values()].sort(
- (a, b) => a.seq - b.seq,
- )
- : r.messages
- messages.value = messages.value.filter(m => m.visible !== false && !r.hiddenMessageIds?.includes(m.id))
- run.value = r.run
- interactions.value = r.interactions
- context.value = r.context
- if (previousComposer !== composerKey.value) {
- taskFiles.set(previousComposer,composer.value?.filesSnapshot() ?? [])
- taskQuotes.set(previousComposer,quoted.value)
- if (activeTask.value && !requestedTask) {
- const legacy = `hermes-yaoyao:composer:group:${auth.user?.id}:${active.value.id}`
- const current = `hermes-yaoyao:composer:group:${composerKey.value}`
- try {
- const draft = localStorage.getItem(legacy)
- if (draft && !localStorage.getItem(current)) { localStorage.setItem(current,draft);localStorage.removeItem(legacy) }
- } catch { /* Leave the original draft intact when browser storage is unavailable. */ }
- }
- await nextTick()
- const saved = taskFiles.get(composerKey.value)
- if (saved?.length) await composer.value?.attachFiles(saved)
- quoted.value = taskQuotes.get(composerKey.value) ?? null
- }
- if (!append) older.value = r.messages.length === 100
- if (!append || atBottom) {
- await nextTick()
- if (!append) timeline.value?.scrollToBottom('auto')
- await markRead()
- }
- } catch (e) {
- if (own === generation && requestedTask && e instanceof ApiError && e.status === 404) {
- try {
- const current = await apiRequest<{tasks:WorkspaceTask[]}>(`/api/app/conversations/${id}/tasks`)
- const fallback = current.tasks[0]
- if (own === generation && fallback && fallback.id !== requestedTask) {
- await router.replace({query:{...route.query,taskId:fallback.id}})
- return
- }
- } catch { /* A deleted conversation still reports its original error. */ }
- }
- if (own === generation) error.value = e instanceof Error ? e.message : '加载失败'
- } finally {
- if (own === generation) loading.value = false
- }
- }
- async function markRead() {
- const c = active.value
- const read = activeTask.value ?? c
- if (!c || !read || read.readSeq >= read.lastSeq) return
- await apiRequest(`/api/app/conversations/${c.id}/read${activeTask.value ? `?taskId=${activeTask.value.id}` : ''}`, {
- method: 'PUT',
- body: { seq: read.lastSeq },
- })
- read.readSeq = read.lastSeq
- }
- async function stopTask() {
- if (!active.value || !activeTask.value) return
- try {
- await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/stop`,{method:'POST',body:{}})
- await load(undefined,true)
- } catch(cause) {error.value=cause instanceof Error?cause.message:'停止任务失败'}
- }
- async function resumeTask() {
- if (!active.value || !activeTask.value) return
- try {
- await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/resume`,{method:'POST',body:{requestId:createUuid()}})
- await load(undefined,true)
- } catch(cause) {error.value=cause instanceof Error?cause.message:'恢复任务失败'}
- }
- function openTaskLink(event: MouseEvent) {
- if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
- const anchor=(event.target as Element)?.closest?.('a[href]')
- if (!anchor) return
- try {
- const url=new URL(anchor.getAttribute('href') || '',window.location.origin)
- if(url.origin!==window.location.origin || !/^\/conversations\/[^/]+$/.test(url.pathname) || !url.searchParams.get('taskId'))return
- event.preventDefault();event.stopPropagation()
- void router.push(url.pathname+url.search)
- } catch { /* Leave other links to their normal handler. */ }
- }
- async function poll() {
- try {
- const r = await apiRequest<{ events: WorkspaceEvent[]; cursor: number; serverIdentity?: ServerIdentity }>(
- `/api/app/events?after=${cursor}`,
- )
- if (disposed) return
- publishServerIdentity(r.serverIdentity)
- cursor = r.cursor
- if (r.events.length) {
- await refresh()
- if (r.events.some((e) => e.conversationId === selected.value || e.type === 'agent.changed'))
- await load(selected.value, true)
- }
- } catch (e) {
- if (!disposed) error.value = e instanceof Error ? e.message : '连接中断,正在重连'
- } finally {
- if (!disposed) timer = setTimeout(() => void poll(), 1200)
- }
- }
- async function select(id: string) {
- error.value = ''
- files.value = []
- mentions.value = []
- text.value = ''
- preview.value = null
- mediaIndex.value = null
- pendingRequestId = undefined
- await router.push(`/conversations/${id}`)
- }
- async function openDialog(kind: NonNullable<typeof dialog.value>) {
- error.value = ''
- dialog.value = kind
- editingId.value = ''
- selectedPresetId.value = 'custom'
- Object.assign(form, {
- name: '',
- avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', 'Agent')) : '',
- instructions: '',
- execution: 'profile' as 'profile'|'computer',
- canManageTeam: false,
- source: '',
- memberIds: [],
- memberRoles: {},
- administratorId: '',
- mode: 'host',
- autoReplyIds: [],
- maxReplyRounds: 3,
- })
- try {
- if (kind === 'agent') {
- const s = await apiRequest<{ sources: Source[] }>('/api/app/agents/sources')
- sources.value = s.sources
- form.source = s.sources[0] ? JSON.stringify([s.sources[0].nodeId, s.sources[0].profile]) : ''
- }
- if (kind === 'editAgent') {
- const a = members.value[0]
- if (!a) return
- editingId.value = a.id
- Object.assign(form, a)
- if(a.remoteAgentId){
- const remote=(await apiRequest<{agents:Agent[]}>(`/api/app/nodes/${a.nodeId}/agents`)).agents.find(candidate=>candidate.id===a.remoteAgentId)
- if(remote)Object.assign(form,remote)
- }
- }
- if (kind === 'editGroup' && active.value) {
- editingId.value = active.value.id
- Object.assign(form, active.value, {
- memberIds: [...active.value.memberIds],
- autoReplyIds: [...active.value.autoReplyIds],
- memberRoles: Object.fromEntries(Object.entries(active.value.memberRoles ?? {}).map(([id, role]) => [id, { ...role }])),
- })
- }
- await nextTick()
- dialogElement.value?.showModal()
- } catch (e) {
- error.value = String(e)
- dialog.value = null
- }
- }
- function closeDialog() {
- dialogElement.value?.close()
- dialog.value = null
- }
- watch(
- () => form.memberIds.slice(),
- (ids) => {
- if (!ids.includes(form.administratorId)) form.administratorId = ids[0] ?? ''
- form.autoReplyIds = form.autoReplyIds.filter((id) => ids.includes(id))
- form.memberRoles = Object.fromEntries(Object.entries(form.memberRoles).filter(([id]) => ids.includes(id)))
- },
- )
- async function save() {
- if(isRemoteAgent.value)return
- busy.value = true
- error.value = ''
- try {
- const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, canManageTeam: form.canManageTeam, execution:form.execution } : {}), instructions: form.instructions }
- if (dialog.value === 'agent') {
- const [nodeId, profile] = JSON.parse(form.source)
- const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
- method: 'POST',
- body: { ...fields, nodeId, profile },
- })
- await refresh()
- const c = conversations.value.find(
- (c) => c.kind === 'direct' && c.memberIds[0] === result.agent.id,
- )
- if (c) await select(c.id)
- } else if (dialog.value === 'editAgent')
- await apiRequest(`/api/app/agents/${editingId.value}`, { method: 'PATCH', body: fields })
- else {
- const payload = {
- ...fields,
- administratorId: form.administratorId,
- mode: form.mode,
- autoReplyIds: form.autoReplyIds,
- maxReplyRounds: form.maxReplyRounds,
- memberIds: form.memberIds,
- memberRoles: form.memberRoles,
- }
- const result = await apiRequest<{ conversation: Conversation }>(
- dialog.value === 'group'
- ? '/api/app/conversations'
- : `/api/app/conversations/${editingId.value}`,
- { method: dialog.value === 'group' ? 'POST' : 'PATCH', body: body(payload) },
- )
- await select(result.conversation.id)
- }
- closeDialog()
- await refresh()
- await load()
- } catch (e) {
- error.value = e instanceof Error ? e.message : '保存失败'
- } finally {
- busy.value = false
- }
- }
- async function upload(event: Event) {
- const picked = (event.target as HTMLInputElement).files
- if (!picked?.length) return
- busy.value = true
- error.value = ''
- try {
- const data = new FormData()
- for (const file of picked) data.append('files', file)
- const r = await apiRequest<{ files: WorkspaceFile[] }>('/api/app/uploads', {
- method: 'POST',
- body: data,
- })
- files.value = [...files.value, ...r.files].slice(0, 8)
- } catch (e) {
- error.value = String(e)
- } finally {
- busy.value = false
- ;(event.target as HTMLInputElement).value = ''
- }
- }
- async function send() {
- const c = active.value
- if (!c || busy.value || (!text.value.trim() && !files.value.length)) return
- busy.value = true
- error.value = ''
- const fingerprint = JSON.stringify([c.id, activeTask.value?.id, text.value, mentions.value, files.value.map(f => f.id)])
- if (!pendingRequestId || pendingFingerprint !== fingerprint) {
- pendingRequestId = createUuid()
- pendingFingerprint = fingerprint
- }
- try {
- await apiRequest(`/api/app/conversations/${c.id}/messages`, {
- method: 'POST',
- body: {
- requestId: pendingRequestId,
- ...(activeTask.value ? { taskId: activeTask.value.id } : {}),
- content: text.value,
- mentionIds: mentions.value,
- fileIds: files.value.map((f) => f.id),
- },
- })
- text.value = ''
- files.value = []
- mentions.value = []
- pendingRequestId = undefined
- pendingFingerprint = ''
- await load(c.id, true)
- await refresh()
- return true
- } catch (e) {
- error.value = e instanceof Error ? e.message : '发送失败'
- } finally {
- busy.value = false
- }
- }
- async function action(operation: 'pin' | 'archive', id = active.value?.id) {
- const c = conversations.value.find(c => c.id === id)
- if (!c) return
- try {
- if (operation === 'pin')
- await apiRequest(`/api/app/conversations/${c.id}`, {
- method: 'PATCH',
- body: { pinned: !c.pinned },
- })
- else
- await apiRequest(
- c.kind === 'direct'
- ? `/api/app/agents/${c.memberIds[0]}`
- : `/api/app/conversations/${c.id}`,
- { method: 'PATCH', body: { archived: !c.archived } },
- )
- await refresh()
- await load()
- } catch (e) {
- error.value = String(e)
- }
- }
- async function stopMember(id: string) {
- if (!active.value) return
- try {
- await apiRequest(`/api/app/conversations/${active.value.id}/agents/${id}/stop`, { method: 'POST', body: {} })
- await load(); await refresh()
- } catch (cause) { error.value = cause instanceof Error ? cause.message : '停止失败' }
- }
- async function control(action: 'stop' | 'reconcile') {
- if (!run.value) return
- try {
- await apiRequest(`/api/app/runs/${run.value.id}/${action}`, { method: 'POST', body: {} })
- await load(undefined, true)
- } catch (e) {
- error.value = String(e)
- }
- }
- async function respond(i: Interaction, answer: string) {
- try {
- await apiRequest(`/api/app/interactions/${i.id}/respond`, { method: 'POST', body: { answer } })
- await load(undefined, true)
- } catch (e) {
- error.value = String(e)
- }
- }
- async function loadOlder() {
- const c = active.value
- if (!c) return
- const taskId = activeTask.value?.id, own = generation
- try {
- const r = await apiRequest<{ messages: Message[] }>(
- `/api/app/conversations/${c.id}/messages?before=${messages.value[0]?.seq ?? 0}${activeTask.value ? `&taskId=${activeTask.value.id}` : ''}`,
- )
- if (own !== generation || active.value?.id !== c.id || activeTask.value?.id !== taskId) return
- messages.value = [...r.messages, ...messages.value]
- older.value = r.messages.length === 100
- } catch (e) {
- error.value = String(e)
- }
- }
- function rendered(m: Message) {
- return m.content.replace(
- /(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g,
- (whole, label: string, path: string) => {
- const file =
- m.attachments.find((f) => f.sourcePath === path) ||
- m.attachments.find((f) => path.split('/').at(-1) === f.name)
- return file
- ? `${label}(/api/app/files/${file.id}/${label.startsWith('!') ? 'preview' : 'download'})`
- : whole
- },
- )
- }
- watch([selected,selectedTask], () => {
- taskFiles.set(composerKey.value, composer.value?.filesSnapshot() ?? [])
- taskQuotes.set(composerKey.value, quoted.value)
- files.value = [];mentions.value = [];text.value = ''
- pendingRequestId = undefined;uploadedSources = [];uploadedReferences = []
- void load()
- })
- onMounted(async () => {
- void auth.refreshProfileAvatars().catch(() => undefined)
- try {
- await apiRequest('/api/app/capabilities')
- await refresh()
- await load()
- if (!disposed) void poll()
- } catch (e) {
- error.value = String(e)
- }
- })
- onBeforeUnmount(() => {
- disposed = true
- generation++
- if (timer) clearTimeout(timer)
- })
- </script>
- <template>
- <WorkspaceShell ref="shell" :server-name="auth.serverIdentity?.displayName"
- :user-name="auth.user?.username"
- :user-avatar="auth.user?.avatar"
- :profiles="auth.profiles"
- :active-profile="auth.activeProfile"
- :is-admin="auth.user?.role === 'admin'"
- :upstream-ready="auth.upstreamReady"
- :upstream-error="auth.upstreamError"
- :theme="theme.resolvedTheme"
- :theme-preference="theme.theme"
- :identity-busy="identityBusy"
- :identity-error="identityError"
- :identity-reset-version="identityResetVersion"
- sidebar-title="聊天"
- sidebar-context-title="聊天列表"
- @logout="auth.logout"
- @select-profile="auth.selectProfile"
- @toggle-theme="theme.toggle"
- @set-theme="theme.setTheme"
- @save-identity="saveProfileIdentity"
- @create-agent="openDialog('agent')"
- @create-group="openDialog('group')"
- @create-remote-agent="remotePickerOpen = true"
- >
- <template #sidebar
- ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
- /></template>
- <template #mobile-sidebar
- ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
- /></template>
- <div v-show="!twoDesktops" class="conversation-with-computer">
- <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
- <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
- :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
- :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
- :show-tools="showThinking" :allow-branch="false" :thinking="!!run && ['running','waiting','uncertain'].includes(run.status)"
- :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"
- :agent-states="Object.fromEntries(members.map(a => [a.id, workspaceAvatarState(active, a.id)]))"
- :mention-names="members.map(a => a.name)"
- :empty-title="active ? '开始一段新对话' : '还没有聊天'"
- :empty-description="active ? '从下方输入框发送消息。' : '创建 Agent,或选择成员新建群聊。'"
- :interaction="interactions[0] ? { id: interactions[0].id, kind: interactions[0].kind, prompt: interactions[0].message, options: interactions[0].choices } : null"
- @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
- @approve="interactions[0] && respond(interactions[0], $event ? 'once' : 'deny')"
- @clarify="interactions[0] && respond(interactions[0], $event)">
- <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>
- <template #header-actions>
- <div v-if="active" class="header-actions">
- <button type="button" class="icon-button" aria-label="电脑" title="电脑" @click="computerDockOpen=!computerDockOpen"><AppIcon name="monitor"/></button>
- <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}})">
- <option v-for="task in tasks" :key="task.id" :value="task.id">{{ task.title }}</option>
- </select>
- <button class="icon-button" :aria-label="active.pinned ? '取消置顶' : '置顶聊天'" :title="active.pinned ? '取消置顶' : '置顶聊天'" @click="action('pin')"><AppIcon :name="active.pinned ? 'pin-off' : 'pin'" /></button>
- <button class="icon-button" aria-label="聊天设置" title="聊天设置" @click="openDialog(active.kind === 'direct' ? 'editAgent' : 'editGroup')"><AppIcon name="settings" /></button>
- <button class="icon-button" :aria-label="active.archived ? '恢复聊天' : '归档聊天'" :title="active.archived ? '恢复聊天' : '归档聊天'" @click="action('archive')"><AppIcon name="archive" /></button>
- </div>
- </template>
- </MessageTimeline>
- <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用 Agent</p>
- <TaskPlan :goal="activeTask?.goal" :assignments="assignments" :agents="agents" @stop="stopTask" @resume="resumeTask" />
- <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
- <ComposerShell v-if="active" :key="composerKey" ref="composer" mode="group" :draft-key="composerKey"
- :disabled="active.archived || loading || active.id !== selected" :sending="busy" stop-while-running :streaming="!!active.activeRunId"
- :tool-trace-visible="showThinking" :reference="reference" :context-used="Number(context?.usedTokens || 0)" :context-limit="Number(context?.limitTokens || 0)"
- :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
- @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
- </section>
- <LocalVmChatPanel v-if="computerDockOpen" :active="!twoDesktops" :agents="computerCandidates" :is-admin="auth.user?.role === 'admin'" @close="computerDockOpen=false" @changed="refresh()" @settings="shell?.openLocalVm()" @desktop="desktopAgent=$event;computerOpen=true" @workspace="desktopAgent=$event;twoDesktops=true" />
- </div>
- <ComputerPanel ref="desktopViewer" v-if="computerOpen&&desktopAgent" :agents="[desktopAgent]" auto-take @changed="load()" @close="computerOpen=false" />
- <LocalVmWorkspace ref="vmWorkspace" v-if="twoDesktops&&desktopAgent" :agents="agents.filter(a=>a.execution==='computer'&&!a.archived&&!a.computerEnvironmentId)" :primary="desktopAgent.id" @close="twoDesktops=false" />
- <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @select="select" />
- <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
- <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
- <RemoteAgentPicker v-if="remotePickerOpen && !auth.isBotOnly" @close="remotePickerOpen = false" @added="remoteAdded" />
- <Teleport to="body"><dialog v-if="dialog" ref="dialogElement" class="editor" @cancel.prevent="closeDialog">
- <form @submit.prevent="save">
- <header>
- <h2>
- {{ dialog === 'agent' ? '创建 Agent' : dialog === 'group' ? '创建群聊' : '编辑资料' }}
- </h2>
- <button type="button" aria-label="关闭" @click="closeDialog">×</button>
- </header>
- <p v-if="error" class="error" role="alert">{{ error }}</p>
- <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived && !a.temporaryGoalId).length" @select="choosePreset" />
- <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
- <p v-if="isRemoteAgent">引用的 Agent 配置由远端管理,请在远端修改名称、头像和角色规则。</p>
- <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" />
- <div v-else-if="!isAgentDialog" class="team-avatar-settings">
- <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? active : undefined)" :size="64" />
- <small>群聊头像由成员头像自动组合,随成员头像更新。</small>
- </div>
- <label
- v-if="dialog === 'agent'"
- >基础 Agent<select v-model="form.source" required>
- <option
- v-for="s in sources"
- :key="`${s.nodeId}:${s.profile}`"
- :value="JSON.stringify([s.nodeId, s.profile])"
- >
- {{ s.name }} · {{ s.nodeId === 'local' ? '当前服务' : s.nodeId }}
- </option></select
- ><small v-if="!sources.length"
- >{{ auth.isBotOnly ? '暂无可用的已分配 Agent,请联系管理员分配或检查连接。' : '没有可用的基础 Agent,请检查 Web 的 Hermes 连接。' }}</small
- ></label
- ><label
- >{{ isAgentDialog ? '角色提示词与规则' : '群规则'
- }}<textarea
- v-model="form.instructions"
- :readonly="isRemoteAgent"
- rows="6"
- maxlength="24000"
- :placeholder="
- isAgentDialog
- ? '例如:你是代码审查员。重点检查正确性、边界情况和测试证据。'
- : '所有成员共同遵守的协作规则'
- "
- />
- </label>
- <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
- <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
- <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础 Agent,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
- </label>
- <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程 Agent</button>
- <fieldset v-if="selectedPreset" class="preset-role-mapping">
- <legend>角色分配</legend>
- <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
- {{ role.name }}{{ role.host ? ' · 管理员' : '' }}
- <select :value="form.memberIds[index]" :aria-label="`${role.name}对应的 Agent`" @change="assignPresetRole(index, ($event.target as HTMLSelectElement).value)">
- <option v-for="a in agents.filter(a => !a.archived && !a.temporaryGoalId)" :key="a.id" :value="a.id">{{ a.name }}</option>
- </select>
- <small>{{ role.description }}</small>
- </label>
- <small>角色分工仅在本群生效,聊天中保留 Agent 的名称和头像。</small>
- </fieldset>
- <fieldset v-else-if="!isAgentDialog">
- <legend>选择成员</legend>
- <label
- v-for="a in agents.filter((a) =>
- !a.temporaryGoalId && (!a.archived || (dialog === 'editGroup' && active?.memberIds.includes(a.id))),
- )"
- :key="a.id"
- ><input
- v-model="form.memberIds"
- type="checkbox"
- :value="a.id"
- :disabled="busy || (dialog === 'editGroup' && (a.id === active?.administratorId || a.id === form.administratorId)) || (!form.memberIds.includes(a.id) && form.memberIds.length >= 8)"
- />{{ a.name }}</label
- ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived && !a.temporaryGoalId).length < 2"
- >至少需要两个 Agent 才能创建群聊。</small
- >
- <small v-if="dialog === 'editGroup'">可增减成员,最多 8 位。当前管理员不能移除;如需移除,请先更换管理员并保存。</small>
- <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>
- </fieldset>
- <fieldset v-if="dialog === 'editGroup' && Object.keys(form.memberRoles).length">
- <legend>角色分工</legend>
- <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>
- </fieldset>
- <template v-if="!isAgentDialog"
- ><label
- >管理员<select v-model="form.administratorId" required>
- <option
- v-for="a in agents.filter((a) => form.memberIds.includes(a.id))"
- :key="a.id"
- :value="a.id"
- >
- {{ a.name }}
- </option>
- </select></label
- ><label
- >协作方式<select v-model="form.mode">
- <option value="host">管理员协调</option>
- <option value="free">自由协作</option>
- </select></label
- >
- <fieldset v-if="form.mode === 'free'">
- <legend>未 @ 时自动回复</legend>
- <label v-for="a in agents.filter((a) => form.memberIds.includes(a.id))" :key="a.id"
- ><input v-model="form.autoReplyIds" type="checkbox" :value="a.id" />{{
- a.name
- }}</label
- >
- </fieldset>
- <label
- ><input type="checkbox" :checked="form.maxReplyRounds === -1" @change="form.maxReplyRounds = ($event.target as HTMLInputElement).checked ? -1 : 3" />不限制自动协作轮数</label>
- <label v-if="form.maxReplyRounds !== -1">自动协作轮数<input
- v-model.number="form.maxReplyRounds"
- type="number"
- min="1"
- max="100"
- required /></label
- ></template>
- <footer>
- <button class="quiet-button" type="button" @click="closeDialog">取消</button
- ><button v-if="!isRemoteAgent" class="solid-button" :disabled="busy || (!isAgentDialog && (form.memberIds.length < (dialog === 'group' ? 2 : 1) || form.memberIds.length > 8))">
- {{ busy ? '保存中…' : '保存' }}
- </button>
- </footer>
- </form>
- </dialog></Teleport>
- </WorkspaceShell>
- </template>
- <style scoped>
- .conversation-with-computer{display:flex;height:100%;min-width:0;min-height:0}.conversation-with-computer>.workspace-chat{flex:1;min-width:0}
- .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}
- .editor {
- border: 1px solid var(--line);
- border-radius: 18px;
- width: min(520px, calc(100vw - 48px));
- max-height: 85vh;
- overflow: auto;
- background: var(--surface);
- color: var(--text-primary);
- padding: 25px;
- }
- .editor::backdrop {
- background: #0006;
- }
- .editor header,
- .editor footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- }
- .editor h2 {
- font-size: 20px;
- }
- .editor label {
- display: flex;
- flex-direction: column;
- gap: 7px;
- margin: 15px 0;
- font-size: 13px;
- }
- .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 }
- .task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
- @media(max-width:720px){.task-picker{max-width:100px}}
- .team-management-permission > span {
- display: flex;
- align-items: center;
- gap: 8px;
- min-height: 44px;
- }
- .team-management-permission small {
- color: var(--text-muted);
- line-height: 1.6;
- }
- .editor input:not([type='checkbox']),
- .editor textarea,
- .editor select,
- .interaction input {
- padding: 10px;
- border: 1px solid var(--line);
- border-radius: 8px;
- background: var(--surface-soft);
- color: inherit;
- font: inherit;
- }
- .editor fieldset {
- border: 1px solid var(--line);
- border-radius: 10px;
- }
- .editor fieldset label {
- flex-direction: row;
- align-items: center;
- }
- .editor footer {
- justify-content: flex-end;
- margin-top: 25px;
- }
- .status {
- margin-top: 8px;
- }
- .workspace-list-back{display:none}@media(max-width:900px){.workspace-list-back{display:grid}}
- </style>
|