Explorar el Código

精简聊天顶部操作并支持删除归档聊天

LSamien hace 1 mes
padre
commit
ea95acd52b

+ 15 - 3
src/client/components/app/FloatingResourceSearch.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
@@ -93,6 +93,10 @@ function containFocus(event: KeyboardEvent) {
   if (event.shiftKey && document.activeElement === elements[0]) { event.preventDefault(); elements.at(-1)?.focus() }
   else if (!event.shiftKey && document.activeElement === elements.at(-1)) { event.preventDefault(); elements[0]?.focus() }
 }
+watch(results, async () => {
+  await nextTick()
+  if (open.value && document.activeElement === document.body) input.value?.focus()
+})
 onMounted(() => { document.addEventListener(SIDEBAR_SEARCH_EVENT, show); document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, close) })
 onBeforeUnmount(() => { document.removeEventListener(SIDEBAR_SEARCH_EVENT, show); document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, close) })
 </script>
@@ -124,10 +128,11 @@ onBeforeUnmount(() => { document.removeEventListener(SIDEBAR_SEARCH_EVENT, show)
           </nav>
           <section :id="`${section}-search-results`" class="floating-resource-search__result-pane" :role="tabbed ? 'tabpanel' : undefined" :aria-label="tabbed ? activeParent?.title : undefined">
             <div v-if="activeParent && !tabbed" class="floating-resource-search__context"><strong>{{ activeParent.title }}</strong><span>{{ activeParent.subtitle }}</span></div>
-            <div class="floating-resource-search__results" role="listbox" aria-label="搜索结果">
+            <div class="floating-resource-search__results" :role="$slots['item-actions'] ? 'list' : 'listbox'" aria-label="搜索结果">
               <template v-for="row in resultRows" :key="row.item.id">
                 <div v-if="row.showSection" class="floating-resource-search__section">{{ row.item.section }}</div>
-                <button type="button" role="option" :aria-haspopup="row.hasChildren ? 'listbox' : undefined" @click="choose(row.item)">
+                <div class="floating-resource-search__row" :role="$slots['item-actions'] ? 'listitem' : 'presentation'">
+                <button class="floating-resource-search__select" type="button" :role="$slots['item-actions'] ? undefined : 'option'" :aria-haspopup="row.hasChildren ? 'listbox' : undefined" @click="choose(row.item)">
                   <span v-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" class="floating-resource-search__icon floating-resource-search__icon--avatar">
                     <AgentAvatar v-if="row.item.avatarKind === 'agent'" :name="row.item.title" :avatar="row.item.avatar" :state="row.item.avatarState" :size="30" />
                     <TeamAvatar v-else :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :fallback-key="row.item.avatarFallbackKey || row.item.id" :size="30" />
@@ -136,11 +141,14 @@ onBeforeUnmount(() => { document.removeEventListener(SIDEBAR_SEARCH_EVENT, show)
                   <span class="floating-resource-search__copy"><strong>{{ row.item.title }}</strong><small v-if="row.item.subtitle">{{ row.item.subtitle }}</small></span>
                   <small v-if="row.item.meta">{{ row.item.meta }}</small>
                 </button>
+                <slot name="item-actions" :item="row.item" />
+                </div>
               </template>
               <p v-if="!results.length">{{ activeParent?.emptyText || (activeParent ? `暂无${activeParent.title}内容` : '没有匹配结果') }}</p>
             </div>
           </section>
         </div>
+        <slot name="footer" />
       </section>
     </div>
   </Teleport>
@@ -155,4 +163,8 @@ onBeforeUnmount(() => { document.removeEventListener(SIDEBAR_SEARCH_EVENT, show)
 .floating-resource-search__tabs button{display:flex;align-items:center;justify-content:center;gap:9px;min-height:34px;padding:6px 18px;border:0;border-radius:8px;background:transparent;color:var(--text-secondary);font:13px var(--font-ui);cursor:pointer}
 .floating-resource-search__tabs button[aria-selected="true"]{background:var(--surface-hover);color:var(--text-primary);font-weight:650}
 .floating-resource-search__tabs small{color:var(--text-muted);font-size:11px}
+.floating-resource-search__row{display:flex;align-items:center;gap:8px}
+.floating-resource-search__row .floating-resource-search__select{flex:1;min-width:0}
+.floating-resource-search__select>small{flex-shrink:0;color:var(--text-muted);font-size:10px}
+.floating-resource-search__select:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
 </style>

+ 37 - 3
src/client/views/ConversationsView.vue

@@ -77,6 +77,8 @@ 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 archivedIds = computed(() => new Set(conversations.value.filter(c => c.archived).map(c => c.id)))
+const deletingId = ref(''), deleteError = ref('')
 const composer = ref<InstanceType<typeof ComposerShell>>()
 const timeline = ref<InstanceType<typeof MessageTimeline>>()
 const quoted = ref<UiMessage | null>(null)
@@ -553,6 +555,28 @@ async function action(operation: 'pin' | 'archive', id = active.value?.id) {
     error.value = String(e)
   }
 }
+async function deleteArchivedConversation(id: string) {
+  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
+  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')
+    await refresh()
+  } catch (cause) {
+    deleteError.value = cause instanceof Error ? cause.message : '删除失败,请重试'
+  } finally {
+    deletingId.value = ''
+  }
+}
 async function stopMember(id: string) {
   if (!active.value) return
   try {
@@ -683,9 +707,7 @@ onBeforeUnmount(() => {
             <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>
@@ -702,7 +724,14 @@ 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 @select="select" />
+    <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @open="deleteError = ''" @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>
+      </template>
+      <template #footer><p v-if="deleteError" class="search-delete-error" role="alert">{{ deleteError }}</p></template>
+    </FloatingResourceSearch>
     <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" />
@@ -906,4 +935,9 @@ onBeforeUnmount(() => {
   margin-top: 8px;
 }
 .workspace-list-back{display:none}@media(max-width:900px){.workspace-list-back{display:grid}}
+.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}
+.search-delete:hover:not(:disabled){background:var(--surface-hover)}
+.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}
 </style>

+ 78 - 0
tests/client/conversationSearchDelete.test.ts

@@ -0,0 +1,78 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
+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'
+
+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 () => {} }) }))
+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 }]
+  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 === '/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
+    return {} as never
+  })
+  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 },
+  } })
+  await flushPromises()
+  document.dispatchEvent(new CustomEvent('hermes-yaoyao:sidebar-search', { detail: { section: 'groups' } }))
+  await flushPromises()
+  expect(document.querySelector('.search-delete')).toBeNull()
+  document.querySelector<HTMLButtonElement>('[role="tab"][aria-label="已归档"]')!.click()
+  await flushPromises()
+  return router
+}
+
+describe('archived chat deletion from search', () => {
+  it('keeps the archived result and route when confirmation is cancelled', async () => {
+    const router = await setup('direct')
+    const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
+    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(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) => {
+    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(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'))
+  })
+
+  it('disables repeated deletion and keeps server errors inside search', async () => {
+    let reject!: (error: Error) => void
+    await setup('direct', () => new Promise<void>((_, fail) => { reject = fail }))
+    vi.spyOn(window, 'confirm').mockReturnValue(true)
+    const button = document.querySelector<HTMLButtonElement>('.search-delete')!
+    button.click(); await flushPromises()
+    expect(button.disabled).toBe(true)
+    button.click()
+    expect(vi.mocked(apiRequest).mock.calls.filter(([, options]) => options?.method === 'DELETE')).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('旧聊天')
+  })
+})