فهرست منبع

统一聊天归档删除与退群处理并修正升级状态展示

LSamien 1 ماه پیش
والد
کامیت
bfb31fee80

+ 10 - 5
src/client/components/app/SystemUpdatePanel.vue

@@ -29,6 +29,11 @@ const terminal = computed(() => job.value && ['succeeded', 'failed', 'rolled_bac
 const jobRunning = computed(() => Boolean(job.value && !terminal.value))
 const locked = computed(() => operationStarting.value || (jobRunning.value && !trackingTimedOut.value))
 const canApply = computed(() => Boolean(status.value?.supported && status.value.updateAvailable && status.value.latest && !jobRunning.value && !busy.value))
+const displayedJob = computed(() => {
+  const current = status.value?.current, value = job.value
+  if (value?.state === 'succeeded' && value.target && current && (value.target.webVersion !== current.webVersion || value.target.releaseVersion !== current.releaseVersion)) return undefined
+  return value
+})
 
 function stopPolling() { pollToken += 1 }
 function reloadPage() { window.location.reload() }
@@ -39,7 +44,7 @@ async function refresh(checkRemote = false): Promise<SystemUpdateStatus | undefi
   try {
     status.value = checkRemote ? await checkSystemUpdate() : await systemUpdateStatus()
     if (checkRemote) remoteChecked.value = true
-    if (status.value.job) job.value = status.value.job
+    job.value = status.value.job
     return status.value
   } catch (cause) {
     if (checkRemote) remoteChecked.value = false
@@ -160,13 +165,13 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
     <p v-if="status?.installationMode === 'source'" class="mode-note">首次升级会把运行服务迁移到可回滚的版本目录;Git 工作区不会被覆盖。</p>
     <p v-if="status && !status.supported" class="mode-note">{{ status.unsupportedReason }}</p>
 
-    <section v-if="job" class="update-progress" :class="`update-progress--${job.state}`" aria-live="polite">
-      <span class="progress-icon"><AppIcon :name="job.state === 'succeeded' || job.state === 'rolled_back' ? 'check' : job.state === 'failed' ? 'alert' : 'refresh'" :size="18" /></span>
-      <span><strong>{{ job.message }}</strong><small v-if="job.error">{{ job.error }}</small></span>
+    <section v-if="displayedJob" class="update-progress" :class="`update-progress--${displayedJob.state}`" aria-live="polite">
+      <span class="progress-icon"><AppIcon :name="displayedJob.state === 'succeeded' || displayedJob.state === 'rolled_back' ? 'check' : displayedJob.state === 'failed' ? 'alert' : 'refresh'" :size="18" /></span>
+      <span><strong>{{ displayedJob.message }}</strong><small v-if="displayedJob.error">{{ displayedJob.error }}</small></span>
     </section>
 
     <footer>
-      <button v-if="job?.state === 'succeeded' || job?.state === 'rolled_back'" class="quiet-button" type="button" @click="reloadPage"><AppIcon name="refresh" :size="16" />刷新页面</button>
+      <button v-if="displayedJob?.state === 'succeeded' || displayedJob?.state === 'rolled_back'" class="quiet-button" type="button" @click="reloadPage"><AppIcon name="refresh" :size="16" />刷新页面</button>
       <button v-else-if="status?.canRollback && !jobRunning" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
       <button class="quiet-button" type="button" :disabled="checking || busy || operationStarting || jobRunning" @click="refresh(true)"><AppIcon name="refresh" :size="16" />{{ checking ? '检查中…' : '检查更新' }}</button>
       <button class="solid-button" type="button" :disabled="!canApply || !!error || !remoteChecked" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ jobRunning ? '升级中…' : error ? '暂无法检查' : !remoteChecked ? '待检查更新' : status?.updateAvailable ? '升级 Web' : '已是最新版本' }}</button>

+ 7 - 4
src/client/components/workspace/ConversationList.vue

@@ -4,7 +4,7 @@ import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import { workspaceConversationItem } from './viewModels'
 import type { WorkspaceAgent, WorkspaceConversation } from '@shared/workspace'
 const props = withDefaults(defineProps<{ conversations: WorkspaceConversation[]; agents?: WorkspaceAgent[]; selected?: string }>(), { agents: () => [] })
-const emit = defineEmits<{ select: [id: string]; pin: [id: string]; archive: [id: string] }>()
+const emit = defineEmits<{ select: [id: string]; pin: [id: string]; archive: [id: string]; delete: [id: string] }>()
 const menuId = ref('')
 const menuPosition = ref({ left: '8px', top: '8px' })
 const menuConversation = computed(() => props.conversations.find(c => c.id === menuId.value))
@@ -14,11 +14,12 @@ const rows = computed(() => props.conversations.filter(c => !c.archived).map(c =
 })))
 function openMenu(id: string, event: MouseEvent) {
   menuId.value = id
-  menuPosition.value = { left: `${Math.max(8, Math.min(event.clientX, window.innerWidth - 170))}px`, top: `${Math.max(8, Math.min(event.clientY, window.innerHeight - 100))}px` }
+  menuPosition.value = { left: `${Math.max(8, Math.min(event.clientX, window.innerWidth - 170))}px`, top: `${Math.max(8, Math.min(event.clientY, window.innerHeight - 140))}px` }
 }
-function action(kind: 'pin' | 'archive') {
+function action(kind: 'pin' | 'archive' | 'delete') {
   if (kind === 'pin') emit('pin', menuId.value)
-  else emit('archive', menuId.value)
+  else if (kind === 'archive') emit('archive', menuId.value)
+  else emit('delete', menuId.value)
   menuId.value = ''
 }
 </script>
@@ -32,6 +33,7 @@ function action(kind: 'pin' | 'archive') {
         <section class="conversation-actions" :style="menuPosition" role="menu" aria-label="聊天操作">
           <button role="menuitem" @click="action('pin')">{{ menuConversation.pinned ? '取消置顶' : '置顶聊天' }}</button>
           <button role="menuitem" @click="action('archive')">{{ menuConversation.archived ? '恢复聊天' : '归档聊天' }}</button>
+          <button class="danger" role="menuitem" @click="action('delete')">删除聊天</button>
         </section>
       </div>
     </Teleport>
@@ -46,4 +48,5 @@ function action(kind: 'pin' | 'archive') {
 .conversation-list :deep(.sidebar-item__row--secondary){font-size:13px;line-height:19px;margin-top:4px}
 .conversation-list :deep(.sidebar-item__row small){font-size:10px;color:var(--text-muted)}
 .conversation-menu-dismiss{position:fixed;inset:0;z-index:200}.conversation-actions{position:absolute;display:grid;min-width:155px;padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-raised);box-shadow:var(--shadow-float)}.conversation-actions button{padding:9px 12px;border:0;border-radius:7px;background:transparent;color:var(--text-primary);text-align:left;cursor:pointer;font-size:12px}.conversation-actions button:hover{background:var(--surface-hover)}
+.conversation-actions button.danger{color:var(--danger)}
 </style>

+ 51 - 32
src/client/views/ConversationsView.vue

@@ -12,6 +12,7 @@ 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 type { WorkspaceLifecycleAction, WorkspaceLifecyclePreview } from '@shared/workspaceLifecycle'
 import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
 import type { SidebarItem } from '@/components/app/types'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
@@ -78,7 +79,7 @@ const searchItems = computed<SidebarItem[]>(() => [false, true].map(archived =>
   return { id: archived ? 'archived' : 'unarchived', title: archived ? '已归档' : '未归档', children: items, emptyText: archived ? '没有已归档聊天' : '没有未归档聊天' }
 }))
 const archivedIds = computed(() => new Set(conversations.value.filter(c => c.archived).map(c => c.id)))
-const deletingId = ref(''), deleteError = ref('')
+const lifecycleId = ref(''), searchActionError = ref(''), lifecycleAction = ref<WorkspaceLifecycleAction>()
 const composer = ref<InstanceType<typeof ComposerShell>>()
 const timeline = ref<InstanceType<typeof MessageTimeline>>()
 const quoted = ref<UiMessage | null>(null)
@@ -536,45 +537,55 @@ async function send() {
 async function action(operation: 'pin' | 'archive', id = active.value?.id) {
   const c = conversations.value.find(c => c.id === id)
   if (!c) return
+  if (operation === 'archive') return changeLifecycle(c.archived ? 'restore' : 'archive', c.id)
   try {
-    if (operation === 'pin')
-      await apiRequest(`/api/app/conversations/${c.id}`, {
+    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 deleteArchivedConversation(id: string) {
+async function changeLifecycle(operation: WorkspaceLifecycleAction, id: string, fromSearch = false) {
   const conversation = conversations.value.find(c => c.id === id)
-  if (!conversation?.archived || deletingId.value) return
-  deleteError.value = ''
-  const direct = conversation.kind === 'direct'
-  const message = direct
-    ? `永久删除 Bot「${conversation.name}」及其聊天记录?此操作无法撤销,文件库中的文件会保留。`
-    : `永久删除群聊「${conversation.name}」及其聊天记录?此操作无法撤销,成员 Bot 和文件库中的文件会保留。`
-  if (!confirm(message)) return
-  deletingId.value = id
+  if (!conversation || lifecycleId.value) return
+  const actionError = fromSearch ? searchActionError : error
+  actionError.value = ''
+  lifecycleId.value = id
+  lifecycleAction.value = operation
   try {
-    await apiRequest(direct ? `/api/app/agents/${conversation.memberIds[0]}` : `/api/app/conversations/${id}`, { method: 'DELETE' })
-    conversations.value = conversations.value.filter(c => c.id !== id)
-    if (direct) agents.value = agents.value.filter(a => a.id !== conversation.memberIds[0])
-    if (selected.value === id) await router.replace('/conversations')
+    const path = `/api/app/conversations/${id}/lifecycle`
+    let confirmationToken: string | undefined
+    if (operation !== 'restore') {
+      const preview = await apiRequest<WorkspaceLifecyclePreview>(path)
+      const managed = preview.groups.filter(group => group.administrator)
+      if (managed.length) throw new Error(`此 Bot 是群聊${managed.map(group => `「${group.name}」`).join('、')}的管理者,请先转交管理权,再归档或删除。`)
+      const verb = operation === 'delete' ? '永久删除' : '归档'
+      const target = preview.kind === 'direct' ? 'Bot' : '群聊'
+      let message = `${verb} ${target}「${preview.name}」${operation === 'delete' ? '及其聊天记录' : ''}?`
+      if (preview.groups.length) message += `\n此 Bot 已加入群聊:${preview.groups.map(group => `「${group.name}」`).join('、')}。确认后会先从这些群聊移除,再${verb}。`
+      message += operation === 'delete'
+        ? `\n此操作无法撤销,${preview.kind === 'group' ? '成员 Bot 和' : ''}文件库中的文件会保留。`
+        : '\n聊天记录会保留,可在搜索的“已归档”中取消归档。'
+      if (!confirm(message)) return
+      confirmationToken = preview.confirmationToken
+    }
+    await apiRequest(path, { method: 'POST', body: { action: operation, ...(confirmationToken ? { confirmationToken } : {}) } })
+    if (operation === 'delete') {
+      conversations.value = conversations.value.filter(c => c.id !== id)
+      if (conversation.kind === 'direct') agents.value = agents.value.filter(a => a.id !== conversation.memberIds[0])
+      if (selected.value === id) await router.replace('/conversations')
+    }
     await refresh()
+    if (selected.value === id) await load()
   } catch (cause) {
-    deleteError.value = cause instanceof Error ? cause.message : '删除失败,请重试'
+    actionError.value = cause instanceof Error ? cause.message : '操作失败,请重试'
   } finally {
-    deletingId.value = ''
+    lifecycleId.value = ''
+    lifecycleAction.value = undefined
   }
 }
 async function stopMember(id: string) {
@@ -680,10 +691,10 @@ onBeforeUnmount(() => {
     @create-remote-agent="remotePickerOpen = true"
   >
     <template #sidebar
-      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
     /></template>
     <template #mobile-sidebar
-      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
     /></template>
     <div v-show="!twoDesktops" class="conversation-with-computer">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
@@ -724,13 +735,16 @@ onBeforeUnmount(() => {
     </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 @open="deleteError = ''" @select="select">
+    <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @open="searchActionError = ''" @select="select">
       <template #item-actions="{ item }">
-        <button v-if="archivedIds.has(item.id)" type="button" class="search-delete" :aria-label="`删除聊天:${item.title}`" :disabled="!!deletingId" :aria-busy="deletingId === item.id" @click="deleteArchivedConversation(item.id)">
-          <AppIcon name="trash" :size="15" />{{ deletingId === item.id ? '删除中…' : '删除' }}
-        </button>
+        <div v-if="archivedIds.has(item.id)" class="search-lifecycle-actions">
+          <button type="button" class="search-restore" :aria-label="`取消归档:${item.title}`" :disabled="!!lifecycleId" @click="changeLifecycle('restore', item.id, true)">{{ lifecycleId === item.id && lifecycleAction === 'restore' ? '恢复中…' : '取消归档' }}</button>
+          <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)">
+            <AppIcon name="trash" :size="15" />{{ lifecycleId === item.id && lifecycleAction === 'delete' ? '删除中…' : '删除' }}
+          </button>
+        </div>
       </template>
-      <template #footer><p v-if="deleteError" class="search-delete-error" role="alert">{{ deleteError }}</p></template>
+      <template #footer><p v-if="searchActionError" class="search-delete-error" role="alert">{{ searchActionError }}</p></template>
     </FloatingResourceSearch>
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
@@ -940,4 +954,9 @@ onBeforeUnmount(() => {
 .search-delete:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
 .search-delete:disabled{opacity:.5;cursor:wait}
 .search-delete-error{margin:0;padding:10px 16px 14px;color:var(--danger);font-size:12px;line-height:1.5}
+.search-lifecycle-actions{display:flex;flex:0 0 auto;align-items:center;gap:2px}
+.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}
+.search-restore:hover:not(:disabled){background:var(--surface-hover)}
+.search-restore:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
+.search-restore:disabled{opacity:.5;cursor:wait}
 </style>

+ 1 - 1
src/server/updateManager.ts

@@ -281,7 +281,7 @@ export class SystemUpdateManager {
       supported: !this.desktopOwned && this.platform === 'darwin',
       unsupportedReason: this.desktopOwned ? 'App 内置服务随 App 一起更新,请使用夭夭菜单中的 App 更新与回退。' : this.platform === 'darwin' ? undefined : '容器和非 macOS 环境请通过替换部署镜像升级',
       canRollback: !this.desktopOwned && existsSync(join(this.updateHome, 'last-success.json')),
-      job: publicJob(job),
+      job: job?.state === 'succeeded' && job.target && (job.target.webVersion !== current.webVersion || job.target.releaseVersion !== current.releaseVersion) ? undefined : publicJob(job),
     }
   }
 

+ 17 - 1
src/server/workspaceRoutes.ts

@@ -114,7 +114,13 @@ export function workspaceRouter(
     if ((input.canManageTeam === true && agent.canManageTeam !== true) || input.execution==='computer') await runtime.teamTools.requireAvailable(user, {...agent,...(input.execution?{execution:input.execution}:{})})
     if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
     nodes.requireSource(user, agent)
-    ctx.body = { agent: store.agentSummary(store.updateAgent(user, ctx.params.id, input)) }
+    ctx.body = { agent: store.agentSummary(store.atomic(() => {
+      if (input.archived === true && !agent.temporaryGoalId) {
+        const direct = store.list<WorkspaceConversation>(user, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === agent.id)
+        if (direct) store.changeConversationLifecycle(user, direct.id, 'archive')
+      }
+      return store.updateAgent(user, ctx.params.id, input)
+    })) }
   })
   router.delete('/api/app/agents/:id', (ctx) => {
     store.deleteAgent(owner(ctx), ctx.params.id)
@@ -124,6 +130,16 @@ export function workspaceRouter(
     store.deleteConversation(owner(ctx), ctx.params.id)
     ctx.body = { ok: true }
   })
+  router.get('/api/app/conversations/:id/lifecycle', ctx => {
+    ctx.body = store.conversationLifecycle(owner(ctx), ctx.params.id)
+  })
+  router.post('/api/app/conversations/:id/lifecycle', ctx => {
+    const input = parse(z.object({ action: z.enum(['archive', 'restore', 'delete']), confirmationToken: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(), body(ctx))
+    if (input.action !== 'restore' && !input.confirmationToken) throw new HttpError(409, '请先确认聊天操作', 'lifecycle_confirmation_required')
+    store.changeConversationLifecycle(owner(ctx), ctx.params.id, input.action, input.confirmationToken)
+    runtime.wake()
+    ctx.body = { ok: true }
+  })
   router.get('/api/app/conversations', (ctx) => {
     const user = owner(ctx)
     ctx.body = {

+ 59 - 5
src/server/workspaceStore.ts

@@ -8,6 +8,7 @@ import { HttpError } from './errors.js'
 import { isVisibleMessageFile } from '../shared/messageFiles.js'
 import type { StoredWorkspaceFile } from './workspaceAssets.js'
 import { notificationPlainText } from './notificationText.js'
+import type { WorkspaceLifecycleAction, WorkspaceLifecyclePreview } from '../shared/workspaceLifecycle.js'
 import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar, normalizeAvatar, randomAgentIdentity, MAX_AVATAR_DESCRIPTOR_LENGTH } from '../shared/agentIdentity.js'
 import type {
   WorkspaceAgent as Agent,
@@ -374,6 +375,63 @@ export class WorkspaceStore {
       return next
     })
   }
+  conversationLifecycle(owner: string, id: string): WorkspaceLifecyclePreview {
+    const conversation = this.require<Conversation>(owner, 'conversation', id)
+    const agent = conversation.kind === 'direct' ? this.require<Agent>(owner, 'agent', conversation.memberIds[0]!) : undefined
+    const groups = agent ? this.list<Conversation>(owner, 'conversation')
+      .filter(group => group.kind === 'group' && group.memberIds.includes(agent.id))
+      .sort((a, b) => a.id.localeCompare(b.id)) : []
+    return {
+      name: conversation.name,
+      kind: conversation.kind,
+      groups: groups.map(group => ({ id: group.id, name: group.name, administrator: group.administratorId === agent?.id })),
+      confirmationToken: createHash('sha256').update(JSON.stringify([
+        conversation.id, conversation.name, conversation.archived, agent?.revision,
+        groups.map(group => [group.id, group.name, group.administratorId, group.memberIds]),
+      ])).digest('hex'),
+    }
+  }
+  private requireConversationIdle(owner: string, id: string): void {
+    if (['run', 'turn'].some(kind => this.list<{ conversationId: string; status: string }>(owner, kind).some(run => run.conversationId === id && !['complete', 'failed', 'interrupted', 'skipped'].includes(run.status))))
+      throw new HttpError(409, '聊天仍有任务未结束,请停止任务后重试', 'conversation_running')
+    if (this.list<{ conversationId: string; origin?: { conversationId: string }; status: string }>(owner, 'goal').some(goal =>
+      (goal.conversationId === id || goal.origin?.conversationId === id) && !['complete', 'blocked', 'cancelled'].includes(goal.status)))
+      throw new HttpError(409, '聊天仍有关联任务未结束,请停止任务后重试', 'conversation_running')
+  }
+  changeConversationLifecycle(owner: string, id: string, action: WorkspaceLifecycleAction, confirmationToken?: string): void {
+    this.atomic(() => {
+      const conversation = this.require<Conversation>(owner, 'conversation', id)
+      const agent = conversation.kind === 'direct' ? this.require<Agent>(owner, 'agent', conversation.memberIds[0]!) : undefined
+      if (action === 'restore') {
+        if (agent) this.updateAgent(owner, agent.id, { archived: false })
+        else this.updateConversation(owner, id, { archived: false })
+        return
+      }
+      const preview = this.conversationLifecycle(owner, id)
+      const managed = preview.groups.filter(group => group.administrator)
+      if (managed.length) throw new HttpError(409, `此 Bot 是群聊${managed.map(group => `「${group.name}」`).join('、')}的管理者,请先转交管理权,再归档或删除`, 'agent_group_administrator')
+      if (confirmationToken !== undefined && confirmationToken !== preview.confirmationToken)
+        throw new HttpError(409, '聊天或群聊成员已变化,请重新确认后操作', 'lifecycle_confirmation_stale')
+      if (preview.groups.length && !confirmationToken)
+        throw new HttpError(409, '此 Bot 仍是群聊成员,请确认退出群聊后再归档或删除', 'agent_in_group')
+      if (agent?.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
+      if (agent?.computerEnvironmentId) throw new HttpError(409, '请先解除电脑共享,再归档或删除 Bot', 'shared_computer_active')
+      if (agent && this.list<{ agentId: string; status: string }>(owner, 'turn').some(turn => turn.agentId === agent.id && !['complete', 'failed', 'interrupted', 'skipped'].includes(turn.status)))
+        throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
+      for (const conversationId of [id, ...preview.groups.map(group => group.id)]) this.requireConversationIdle(owner, conversationId)
+      for (const group of preview.groups) {
+        const current = this.require<Conversation>(owner, 'conversation', group.id)
+        this.updateConversation(owner, group.id, { memberIds: current.memberIds.filter(memberId => memberId !== agent!.id) })
+      }
+      if (agent) {
+        this.updateAgent(owner, agent.id, { archived: true })
+        if (action === 'delete') this.deleteAgent(owner, agent.id)
+      } else {
+        this.updateConversation(owner, id, { archived: true })
+        if (action === 'delete') this.deleteConversation(owner, id)
+      }
+    })
+  }
   deleteAgent(owner: string, id: string): void {
     this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
@@ -397,11 +455,7 @@ export class WorkspaceStore {
       if (conversation.kind === 'direct' && !deletingAgent)
         throw new HttpError(400, '请删除对应的 Bot', 'delete_agent_instead')
       if (!conversation.archived) throw new HttpError(409, '请先归档聊天,再删除', 'conversation_not_archived')
-      if (['run', 'turn'].some(kind => this.list<{ conversationId: string; status: string }>(owner, kind).some(run => run.conversationId === id && !['complete', 'failed', 'interrupted'].includes(run.status))))
-        throw new HttpError(409, '聊天仍有任务未结束,请停止任务后重试', 'conversation_running')
-      if (this.list<{ conversationId: string; origin?: { conversationId: string }; status: string }>(owner, 'goal').some(goal =>
-        (goal.conversationId === id || goal.origin?.conversationId === id) && !['complete', 'blocked', 'cancelled'].includes(goal.status)))
-        throw new HttpError(409, '聊天仍有关联任务未结束,请停止任务后重试', 'conversation_running')
+      this.requireConversationIdle(owner, id)
       const taskIds = new Set(this.list<Task>(owner, 'conversation-task').filter(task => task.conversationId === id).map(task => task.id))
       for (const taskId of taskIds) {
         this.remove(owner, 'context', taskId)

+ 8 - 0
src/shared/workspaceLifecycle.ts

@@ -0,0 +1,8 @@
+export type WorkspaceLifecycleAction = 'archive' | 'restore' | 'delete'
+
+export interface WorkspaceLifecyclePreview {
+  name: string
+  kind: 'direct' | 'group'
+  groups: Array<{ id: string; name: string; administrator: boolean }>
+  confirmationToken: string
+}

+ 43 - 8
tests/client/conversationSearchDelete.test.ts

@@ -4,6 +4,7 @@ import { shallowRef } from 'vue'
 import { createMemoryHistory, createRouter, matchedRouteKey } from 'vue-router'
 import ConversationsView from '../../src/client/views/ConversationsView.vue'
 import { apiRequest } from '../../src/client/api/client'
+import type { WorkspaceLifecyclePreview } from '../../src/shared/workspaceLifecycle'
 
 vi.mock('../../src/client/api/client', () => ({ apiRequest: vi.fn(), ApiError: class extends Error {} }))
 vi.mock('../../src/client/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner' }, profiles: [], refreshProfileAvatars: async () => {} }) }))
@@ -12,12 +13,19 @@ vi.mock('../../src/client/stores/theme', () => ({ useThemeStore: () => ({}) }))
 let wrapper: VueWrapper | undefined
 afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks(); vi.mocked(apiRequest).mockReset() })
 
-async function setup(kind: 'direct' | 'group', remove: () => Promise<void> = async () => {}) {
-  let archived = [{ id: 'archived', name: '旧聊天', kind, archived: true, memberIds: ['bot'], updatedAt: 1 }]
+async function setup(kind: 'direct' | 'group', remove: () => Promise<void> = async () => {}, groups: WorkspaceLifecyclePreview['groups'] = [], initiallyArchived = true) {
+  let archived = [{ id: 'archived', name: '旧聊天', kind, archived: initiallyArchived, memberIds: ['bot'], updatedAt: 1 }]
   const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/:id?', component: { template: '<div />' } }] })
   await router.push('/conversations'); await router.isReady()
   vi.mocked(apiRequest).mockImplementation(async (path, options) => {
-    if (options?.method === 'DELETE') { await remove(); archived = []; return {} as never }
+    if (path.endsWith('/lifecycle')) {
+      if (options?.method !== 'POST') return { name: '旧聊天', kind, groups, confirmationToken: 'a'.repeat(64) } as never
+      await remove()
+      const action = (options.body as { action: string }).action
+      if (action === 'delete') archived = []
+      else archived = archived.map(item => ({ ...item, archived: action === 'archive' }))
+      return {} as never
+    }
     if (path === '/api/app/agents') return { agents: [{ id: 'bot', name: '旧聊天', archived: true }] } as never
     if (path === '/api/app/conversations') return { conversations: archived, cursor: 1 } as never
     if (path.startsWith('/api/app/events')) return { events: [], cursor: 1 } as never
@@ -25,7 +33,7 @@ async function setup(kind: 'direct' | 'group', remove: () => Promise<void> = asy
   })
   wrapper = shallowMount(ConversationsView, { attachTo: document.body, global: {
     provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) },
-    plugins: [router], stubs: { WorkspaceShell: { template: '<div><slot /></div>' }, FloatingResourceSearch: false, Teleport: false },
+    plugins: [router], stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, FloatingResourceSearch: false, Teleport: false },
   } })
   await flushPromises()
   document.dispatchEvent(new CustomEvent('hermes-yaoyao:sidebar-search', { detail: { section: 'groups' } }))
@@ -43,18 +51,18 @@ describe('archived chat deletion from search', () => {
     document.querySelector<HTMLButtonElement>('.search-delete')!.click()
     await flushPromises()
     expect(confirm).toHaveBeenCalledWith(expect.stringContaining('永久删除 Bot「旧聊天」'))
-    expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'DELETE')).toBe(false)
+    expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'POST')).toBe(false)
     expect(router.currentRoute.value.path).toBe('/conversations')
     expect(document.querySelector('[role="dialog"]')?.textContent).toContain('旧聊天')
   })
 
-  it.each([['direct', '/api/app/agents/bot'], ['group', '/api/app/conversations/archived']] as const)('deletes an archived %s through its own endpoint and updates the open search', async (kind, endpoint) => {
+  it.each(['direct', 'group'] as const)('deletes an archived %s with the confirmed snapshot and updates the open search', async kind => {
     await setup(kind)
     vi.spyOn(window, 'confirm').mockReturnValue(true)
     const button = document.querySelector<HTMLButtonElement>('.search-delete')!
     button.focus(); button.click()
     await flushPromises()
-    expect(apiRequest).toHaveBeenCalledWith(endpoint, { method: 'DELETE' })
+    expect(apiRequest).toHaveBeenCalledWith('/api/app/conversations/archived/lifecycle', { method: 'POST', body: { action: 'delete', confirmationToken: 'a'.repeat(64) } })
     expect(document.querySelector('[role="dialog"]')?.textContent).toContain('没有已归档聊天')
     expect(document.querySelector('[role="tab"][aria-label="已归档"]')?.textContent).toContain('0')
     expect(document.activeElement).toBe(document.querySelector('[role="dialog"] input'))
@@ -68,11 +76,38 @@ describe('archived chat deletion from search', () => {
     button.click(); await flushPromises()
     expect(button.disabled).toBe(true)
     button.click()
-    expect(vi.mocked(apiRequest).mock.calls.filter(([, options]) => options?.method === 'DELETE')).toHaveLength(1)
+    expect(vi.mocked(apiRequest).mock.calls.filter(([, options]) => options?.method === 'POST')).toHaveLength(1)
     reject(new Error('此 Bot 仍是群聊成员'))
     await flushPromises()
     expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toBe('此 Bot 仍是群聊成员')
     expect(button.disabled).toBe(false)
     expect(document.querySelector('[role="dialog"]')?.textContent).toContain('旧聊天')
   })
+  it('restores a chat without opening it and updates both archive counts', async () => {
+    const router = await setup('direct')
+    document.querySelector<HTMLButtonElement>('.search-restore')!.click()
+    await flushPromises()
+    expect(apiRequest).toHaveBeenCalledWith('/api/app/conversations/archived/lifecycle', { method: 'POST', body: { action: 'restore' } })
+    expect(router.currentRoute.value.path).toBe('/conversations')
+    expect(document.querySelector('[role="tab"][aria-label="未归档"]')?.textContent).toContain('1')
+    expect(document.querySelector('[role="tab"][aria-label="已归档"]')?.textContent).toContain('0')
+  })
+  it.each(['archive', 'delete'] as const)('warns about group membership before %s from the chat menu', async operation => {
+    await setup('direct', async () => {}, [{ id: 'group', name: '设计群', administrator: false }], false)
+    const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
+    wrapper!.findComponent({ name: 'ConversationList' }).vm.$emit(operation, 'archived')
+    await flushPromises()
+    expect(confirm).toHaveBeenCalledWith(expect.stringContaining('「设计群」'))
+    expect(confirm).toHaveBeenCalledWith(expect.stringContaining('先从这些群聊移除'))
+    expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'POST')).toBe(false)
+  })
+  it('blocks a group administrator and asks for a transfer instead of deleting', async () => {
+    await setup('direct', async () => {}, [{ id: 'group', name: '管理群', administrator: true }])
+    const confirm = vi.spyOn(window, 'confirm')
+    document.querySelector<HTMLButtonElement>('.search-delete')!.click()
+    await flushPromises()
+    expect(confirm).not.toHaveBeenCalled()
+    expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toContain('「管理群」的管理者,请先转交管理权')
+    expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'POST')).toBe(false)
+  })
 })

+ 18 - 0
tests/client/systemUpdatePanel.test.ts

@@ -60,6 +60,24 @@ afterEach(() => {
 })
 
 describe('SystemUpdatePanel', () => {
+  it('hides a previous successful update that does not match the running Web version', async () => {
+    const old = { ...doneJob, target: { ...manifest, webVersion: '0.1.0', releaseVersion: '0.1.0' }, message: '已升级 Web 0.1.0' }
+    api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, job: old })
+    api.checkSystemUpdate.mockResolvedValue({ ...readyStatus, job: old })
+    const wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
+    await flushPromises()
+    expect(wrapper.text()).toContain(manifest.webVersion)
+    expect(wrapper.text()).not.toContain('已升级 Web 0.1.0')
+    expect(wrapper.text()).toContain('回滚上一版本')
+    wrapper.unmount()
+  })
+  it('clears a finished job when refreshed status no longer includes it', async () => {
+    api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, job: { ...doneJob, target: manifest } })
+    const wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
+    await flushPromises()
+    expect(wrapper.find('.update-progress').exists()).toBe(false)
+    wrapper.unmount()
+  })
   it('shows GitHub failures without claiming the installed version is latest', async () => {
     api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, releaseSource: 'https://github.com/Lsamien/hermes-yaoyao.git', releasePageUrl: 'https://github.com/Lsamien/hermes-yaoyao/releases' })
     api.checkSystemUpdate.mockRejectedValue(new Error('GitHub 请求受限,请稍后重试'))

+ 10 - 0
tests/server/updateManager.test.ts

@@ -89,6 +89,16 @@ describe('system release contract', () => {
 })
 
 describe('SystemUpdateManager', () => {
+  it('keeps old completed job history without showing it as the current upgrade', async () => {
+    const { manager, launched, root } = fixture()
+    const job = await manager.startUpdate(latest.releaseVersion)
+    const stored = JSON.parse(readFileSync(launched[0]!, 'utf8'))
+    writeFileSync(launched[0]!, JSON.stringify({ ...stored, state: 'succeeded', message: '已升级 Web 0.3.0' }))
+    expect(manager.status().job).toBeUndefined()
+    expect(manager.job(job.id)?.state).toBe('succeeded')
+    writeFileSync(join(root, 'release.json'), JSON.stringify(latest))
+    expect(manager.status().job?.id).toBe(job.id)
+  })
   it('uses the migrated source and compares versions rather than mirror commit identities', async () => {
     const { root, config } = fixture()
     let sourceSeen = ''

+ 81 - 0
tests/server/workspaceRoutes.test.ts

@@ -95,6 +95,87 @@ afterEach(() => {
   rmSync(home, { recursive: true, force: true })
 })
 describe('application workspace HTTP contract', () => {
+  it.each(['archive', 'delete'] as const)('%s removes a confirmed ordinary member from all groups atomically', async action => {
+    const store = runtime.workspace
+    const lead = store.createAgent('first', { name: '管理者', profile: 'default' })
+    const bot = store.createAgent('first', { name: '待处理成员', profile: 'default' })
+    const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
+    const group = store.createGroup('first', { name: '设计群', memberIds: [lead.id, bot.id], administratorId: lead.id, autoReplyIds: [bot.id], memberRoles: { [bot.id]: { name: '设计', description: '' } } })
+    const oldGroup = store.createGroup('first', { name: '旧群', memberIds: [lead.id, bot.id], administratorId: lead.id })
+    store.updateConversation('first', oldGroup.id, { archived: true })
+    const path = `/api/app/conversations/${direct.id}/lifecycle`
+    const preview = (await req('get', path).expect(200)).body
+    expect(preview.groups).toEqual(expect.arrayContaining([{ id: group.id, name: '设计群', administrator: false }, { id: oldGroup.id, name: '旧群', administrator: false }]))
+    await req('post', path).send({ action }).expect(409)
+    expect((await req('patch', `/api/app/agents/${bot.id}`).send({ archived: true }).expect(409)).body.code).toBe('agent_in_group')
+    await req('get', path, 'foreign').expect(404)
+    await req('post', path, 'foreign').send({ action, confirmationToken: preview.confirmationToken }).expect(404)
+    await req('post', path).set('X-CSRF-Token', 'invalid').send({ action, confirmationToken: preview.confirmationToken }).expect(403)
+    await req('post', path).send({ action, confirmationToken: preview.confirmationToken }).expect(200)
+    expect(store.require<any>('first', 'conversation', group.id)).toMatchObject({ memberIds: [lead.id], autoReplyIds: [], memberRoles: {}, administratorId: lead.id })
+    expect(store.require<any>('first', 'conversation', oldGroup.id).memberIds).toEqual([lead.id])
+    if (action === 'delete') {
+      expect(store.get('first', 'agent', bot.id)).toBeUndefined()
+      expect(store.get('first', 'conversation', direct.id)).toBeUndefined()
+    } else {
+      expect(store.require<any>('first', 'agent', bot.id).archived).toBe(true)
+      await req('post', path).send({ action: 'restore' }).expect(200)
+      expect(store.require<any>('first', 'conversation', direct.id).archived).toBe(false)
+      expect(store.require<any>('first', 'conversation', group.id).memberIds).toEqual([lead.id])
+    }
+    expect(store.get('first', 'agent', lead.id)).toBeDefined()
+  })
+  it.each(['archive', 'delete'] as const)('blocks %s for a group administrator without removing other memberships', async action => {
+    const store = runtime.workspace
+    const bot = store.createAgent('first', { name: '群管理者', profile: 'default' })
+    const other = store.createAgent('first', { name: '其他成员', profile: 'default' })
+    const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
+    const regular = store.createGroup('first', { name: '普通群', memberIds: [bot.id, other.id], administratorId: other.id })
+    const managed = store.createGroup('first', { name: '管理群', memberIds: [bot.id, other.id], administratorId: bot.id })
+    const path = `/api/app/conversations/${direct.id}/lifecycle`
+    const preview = (await req('get', path).expect(200)).body
+    const cursor = store.cursor('first')
+    const result = await req('post', path).send({ action, confirmationToken: preview.confirmationToken }).expect(409)
+    expect(result.body.code).toBe('agent_group_administrator')
+    expect(result.body.error).toContain('管理群')
+    expect(store.require<any>('first', 'conversation', regular.id).memberIds).toContain(bot.id)
+    expect(store.require<any>('first', 'conversation', managed.id).administratorId).toBe(bot.id)
+    expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
+    expect(store.cursor('first')).toBe(cursor)
+  })
+  it('rejects a stale group confirmation and rolls back membership on a later deletion failure', async () => {
+    const store = runtime.workspace
+    const lead = store.createAgent('first', { name: '负责人', profile: 'default' })
+    const bot = store.createAgent('first', { name: '成员', profile: 'default' })
+    const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
+    const path = `/api/app/conversations/${direct.id}/lifecycle`
+    const old = (await req('get', path).expect(200)).body
+    const group = store.createGroup('first', { name: '后来加入的群', memberIds: [lead.id, bot.id], administratorId: lead.id })
+    expect((await req('post', path).send({ action: 'delete', confirmationToken: old.confirmationToken }).expect(409)).body.code).toBe('lifecycle_confirmation_stale')
+    const current = (await req('get', path).expect(200)).body
+    store.put('first', 'turn', 'pending', { id: 'pending', agentId: bot.id, conversationId: group.id, status: 'uncertain' })
+    await req('post', path).send({ action: 'archive', confirmationToken: current.confirmationToken }).expect(409)
+    store.remove('first', 'turn', 'pending')
+    const cursor = store.cursor('first')
+    const failure = vi.spyOn(store, 'deleteAgent').mockImplementation(() => { throw new Error('模拟写入失败') })
+    await req('post', path).send({ action: 'delete', confirmationToken: current.confirmationToken }).expect(500)
+    failure.mockRestore()
+    expect(store.require<any>('first', 'conversation', group.id).memberIds).toContain(bot.id)
+    expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
+    expect(store.require<any>('first', 'conversation', direct.id).archived).toBe(false)
+    expect(store.cursor('first')).toBe(cursor)
+  })
+  it('deletes an unarchived group while preserving its member Bots', async () => {
+    const store = runtime.workspace
+    const a = store.createAgent('first', { name: '甲', profile: 'default' }), b = store.createAgent('first', { name: '乙', profile: 'default' })
+    const group = store.createGroup('first', { name: '待删除群', memberIds: [a.id, b.id], administratorId: a.id })
+    const path = `/api/app/conversations/${group.id}/lifecycle`
+    const preview = (await req('get', path).expect(200)).body
+    await req('post', path).send({ action: 'delete', confirmationToken: preview.confirmationToken }).expect(200)
+    expect(store.get('first', 'conversation', group.id)).toBeUndefined()
+    expect(store.get('first', 'agent', a.id)).toBeDefined()
+    expect(store.get('first', 'agent', b.id)).toBeDefined()
+  })
   it('deletes only archived owned chats, clears their records, and preserves library files and members', async () => {
     const store = runtime.workspace
     const lead = store.createAgent('first', { name: '负责人', profile: 'default' })