Selaa lähdekoodia

修复普通聊天删除失败并补齐取消确认

识别 Hermes 已不存在的会话并清理本地记录,通知各客户端更新会话列表。

删除前提供明确的取消和确认按钮,显示请求状态及失败原因,避免重复提交。

验证:类型检查和构建通过,29 项相关单元测试、4 项桌面及移动 Web 端到端测试通过。
Samien 1 kuukausi sitten
vanhempi
sitoutus
1aa0b381ee

+ 52 - 5
src/client/views/ChatView.vue

@@ -4,6 +4,8 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import AppIcon from '@/components/common/AppIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
+import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
+import type { SessionSummary } from '@shared/types'
 import type { ChoiceOption } from '@/components/common/types'
 import ComposerShell from '@/components/composer/ComposerShell.vue'
 import ModelChoiceDialog from '@/components/composer/ModelChoiceDialog.vue'
@@ -46,6 +48,9 @@ const showThinking = ref(false)
 const queueMode = ref(false)
 const actionSessionId = ref('')
 const renaming = ref(false)
+const pendingDeleteSession = ref<Pick<SessionSummary, 'id' | 'profile' | 'title'> | null>(null)
+const deletingSession = ref(false)
+const deleteSessionError = ref('')
 const renameValue = ref('')
 const actionMenuPosition = ref({ x: 8, y: 8 })
 const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
@@ -337,12 +342,35 @@ function navigateOutline(target: { messageId: string; anchorId: string }) {
   if (window.matchMedia('(max-width: 900px)').matches) closeInspector()
 }
 
-async function deleteSession() {
-  const session = chat.sessions.find(item => item.id === actionSessionId.value)
+function confirmDeleteSession() {
+  const session = actionSession.value
   if (!session) return
-  await chat.removeSession(session.id, session.profile)
+  pendingDeleteSession.value = { id: session.id, profile: session.profile, title: session.title }
+  deleteSessionError.value = ''
   closeSessionActions()
-  if (route.params.sessionId === session.id) await router.replace('/chat')
+}
+
+function cancelDeleteSession() {
+  if (deletingSession.value) return
+  pendingDeleteSession.value = null
+  deleteSessionError.value = ''
+  void nextTick(() => headerSessionMenuButton.value?.focus())
+}
+
+async function deleteSession() {
+  const session = pendingDeleteSession.value
+  if (!session || deletingSession.value) return
+  deletingSession.value = true
+  deleteSessionError.value = ''
+  try {
+    await chat.removeSession(session.id, session.profile)
+    if (route.params.sessionId === session.id) await router.replace({ path: '/chat', query: { profile: session.profile } })
+    pendingDeleteSession.value = null
+  } catch (cause) {
+    deleteSessionError.value = cause instanceof Error ? cause.message : '删除失败,请重试'
+  } finally {
+    deletingSession.value = false
+  }
 }
 
 async function branch() {
@@ -517,6 +545,17 @@ watch(() => chat.activeSessionId, async id => {
 
   <ModelChoiceDialog :open="modelDialog" :options="chat.models" :selected-id="selectedModelId" :busy="modelSwitching" @close="modelDialog = false" @select="selectModel" />
 
+  <StandaloneDialog v-if="pendingDeleteSession" title="删除会话" compact :before-close="() => !deletingSession" @close="cancelDeleteSession">
+    <div class="session-delete" :aria-busy="deletingSession">
+      <p>确定删除「{{ pendingDeleteSession.title || '新会话' }}」吗?删除后无法恢复。</p>
+      <p v-if="deleteSessionError" class="session-delete-error" role="alert">{{ deleteSessionError }}</p>
+      <div class="session-delete-actions">
+        <button class="quiet-button" type="button" :disabled="deletingSession" @click="cancelDeleteSession">取消</button>
+        <button class="solid-button session-delete-confirm" type="button" :disabled="deletingSession" @click="deleteSession">{{ deletingSession ? '删除中…' : '删除' }}</button>
+      </div>
+    </div>
+  </StandaloneDialog>
+
   <Teleport to="body">
     <Transition name="session-menu">
       <section v-if="actionSessionId" class="session-actions" :style="actionMenuStyle" role="menu" aria-label="会话操作" @contextmenu.prevent>
@@ -529,7 +568,7 @@ watch(() => chat.activeSessionId, async id => {
           <button v-if="actionSessionId === chat.activeSessionId" class="action-row" role="menuitem" type="button" :disabled="chat.isStreaming || chat.isQueued || chat.isSending || chat.activeRouteState?.isLoadingHistory" @click="forceRefreshHistory"><AppIcon name="refresh" :size="14" />强制刷新历史</button>
           <button class="action-row" role="menuitem" type="button" @click="toggleSessionPinned"><AppIcon :name="actionSession?.pinned ? 'pin-off' : 'pin'" :size="14" />{{ actionSession?.pinned ? '取消置顶' : '置顶会话' }}</button>
           <button class="action-row" role="menuitem" type="button" @click="renaming = true"><AppIcon name="edit" :size="14" />重命名</button>
-          <button class="action-row danger" role="menuitem" type="button" @click="deleteSession"><AppIcon name="trash" :size="14" />删除会话</button>
+          <button class="action-row danger" role="menuitem" type="button" @click="confirmDeleteSession"><AppIcon name="trash" :size="14" />删除会话</button>
         </template>
       </section>
     </Transition>
@@ -538,6 +577,14 @@ watch(() => chat.activeSessionId, async id => {
 
 <style scoped>
 .chat-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
+.session-delete p { margin: 0; font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
+.session-delete .session-delete-error { margin-top: 12px; color: var(--danger); }
+.session-delete-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
+.session-delete-actions button { min-width: 80px; min-height: 44px; font-size: 14px; }
+.session-delete-confirm { border-color: var(--danger); background: var(--danger); color: var(--text-on-solid); }
+.session-delete-confirm:hover { transform: none; filter: brightness(.95); }
+.session-delete-actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.session-delete-actions button:disabled { opacity: .6; cursor: wait; }
 .sidebar-primary-action { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 10px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; font-size: 12px; font-weight: 610; text-align: left; transition: background-color 120ms ease; }
 .sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
 .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }

+ 4 - 0
src/server/routes.ts

@@ -356,6 +356,8 @@ function isMissingSessionResponse(response: UpstreamResponse): boolean {
     const payload = parseJson(response)
     if (payload.code === 'session_not_found') return true
     const detail = payload.detail
+    // Hermes also uses this detail for a stored session absent from its history.
+    if (typeof detail === 'string' && /^not found[.!]?$/i.test(detail.trim())) return true
     const message = [payload.error, payload.message, detail,
       detail && typeof detail === 'object' && 'message' in detail ? detail.message : undefined]
       .find(value => typeof value === 'string')
@@ -2305,6 +2307,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       if (isMissingSessionResponse(existing)) {
         // Only this account's cached copy remains; no upstream mutation is needed.
         dependencies.chatCache?.store.deleteSession(owner, profile, id)
+        dependencies.onChatListChanged?.(owner, profile, id)
         ctx.body = { ok: true }
         return
       }
@@ -2320,6 +2323,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
           profile,
           id,
         )
+        dependencies.onChatListChanged?.(owner, profile, id)
       }
       if (alreadyMissing) {
         ctx.body = { ok: true }

+ 102 - 0
tests/e2e/session-deletion.spec.ts

@@ -0,0 +1,102 @@
+import { expect, test, type Page } from '@playwright/test'
+
+async function login(page: Page) {
+  await page.goto('/chat')
+  await page.getByLabel('账号').fill('admin')
+  await page.getByLabel('密码').fill('e2e-password')
+  await page.getByRole('button', { name: '登录', exact: true }).click()
+  await expect(page.getByRole('navigation').first()).toBeVisible()
+}
+
+async function openDeleteDialog(page: Page) {
+  await page.getByRole('button', { name: '会话操作', exact: true }).click()
+  await page.getByRole('menuitem', { name: '删除会话', exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: '删除会话', exact: true })
+  await expect(dialog).toBeVisible()
+  return dialog
+}
+
+for (const mobile of [false, true]) {
+  test.describe(mobile ? 'mobile ordinary chat deletion' : 'desktop ordinary chat deletion', () => {
+    test.use({ hasTouch: mobile })
+
+    test('cancels safely and deletes a locally retained session missing from Hermes', async ({ page }, testInfo) => {
+      await login(page)
+      await page.getByRole('button', { name: '新建聊天', exact: true }).click()
+      await page.locator('.composer-textarea').fill('删除流程验收')
+      await page.getByRole('button', { name: '发送消息' }).click()
+      await expect(page).toHaveURL(/\/chat\/session-new/)
+      await expect(page.locator('.message__content').filter({ hasText: '这是来自假 Gateway 的流式回复。' })).toHaveCount(1)
+      if (mobile) await page.setViewportSize({ width: 375, height: 812 })
+      const originalURL = page.url()
+      const deletions: string[] = []
+      page.on('request', request => {
+        if (request.method() === 'DELETE') deletions.push(request.url())
+      })
+
+      let dialog = await openDeleteDialog(page)
+      await expect(dialog.getByRole('button', { name: '取消', exact: true })).toBeVisible()
+      await page.screenshot({ path: testInfo.outputPath('delete-confirmation.png') })
+      if (mobile) await dialog.getByRole('button', { name: '取消', exact: true }).tap()
+      else await dialog.getByRole('button', { name: '取消', exact: true }).click()
+      await expect(dialog).toBeHidden()
+      expect(page.url()).toBe(originalURL)
+      expect(deletions).toEqual([])
+      await expect(page.getByRole('button', { name: '会话操作', exact: true })).toBeFocused()
+
+      dialog = await openDeleteDialog(page)
+      await page.keyboard.press('Escape')
+      await expect(dialog).toBeHidden()
+      expect(deletions).toEqual([])
+
+      dialog = await openDeleteDialog(page)
+      const removed = page.waitForResponse(response => response.request().method() === 'DELETE')
+      if (mobile) await dialog.getByRole('button', { name: '删除', exact: true }).tap()
+      else await dialog.getByRole('button', { name: '删除', exact: true }).click()
+      expect((await removed).ok()).toBe(true)
+      await expect(dialog).toBeHidden()
+      await expect(page).toHaveURL(/\/chat\?profile=yaoyao$/)
+      expect(deletions).toHaveLength(1)
+      await page.reload()
+      const exists = await page.evaluate(async () => {
+        const response = await fetch('/api/app/sessions?view=chat&profile=yaoyao')
+        const body = await response.json()
+        return body.sessions.some((session: { id: string }) => session.id === 'session-new')
+      })
+      expect(exists).toBe(false)
+    })
+
+    test('shows a failure, blocks duplicate requests, and lets the user cancel or retry', async ({ page }) => {
+      await login(page)
+      await page.goto('/chat/session-demo?profile=yaoyao')
+      if (mobile) await page.setViewportSize({ width: 375, height: 812 })
+      const originalURL = page.url()
+      let finish!: () => void
+      let attempts = 0
+      await page.route('**/api/app/sessions/session-demo?profile=yaoyao', async route => {
+        if (route.request().method() !== 'DELETE') return route.continue()
+        attempts++
+        await new Promise<void>(resolve => { finish = resolve })
+        await route.fulfill({ status: 503, json: { error: '删除服务暂时不可用,请重试' } })
+      })
+
+      const dialog = await openDeleteDialog(page)
+      await dialog.getByRole('button', { name: '删除', exact: true }).click()
+      await expect(dialog.getByRole('button', { name: '删除中…', exact: true })).toBeDisabled()
+      await expect(dialog.getByRole('button', { name: '取消', exact: true })).toBeDisabled()
+      await page.keyboard.press('Escape')
+      await expect(dialog).toBeVisible()
+      await expect.poll(() => attempts).toBe(1)
+      finish()
+      await expect(dialog.getByRole('alert')).toHaveText('删除服务暂时不可用,请重试')
+      expect(page.url()).toBe(originalURL)
+      await dialog.getByRole('button', { name: '删除', exact: true }).click()
+      await expect.poll(() => attempts).toBe(2)
+      finish()
+      await expect(dialog.getByRole('alert')).toBeVisible()
+      await dialog.getByRole('button', { name: '取消', exact: true }).click()
+      await expect(dialog).toBeHidden()
+      expect(page.url()).toBe(originalURL)
+    })
+  })
+}

+ 1 - 0
tests/server/sessionDeletion.test.ts

@@ -77,6 +77,7 @@ describe('deleting sessions retained only in cache', () => {
     { error: 'session 不存在' },
     { message: '会话不存在' },
     { detail: 'Session not found: cached-session' },
+    { detail: 'Not found' },
     { detail: { message: 'Session not found' } },
     { code: 'session_not_found' },
   ])('cleans a missing session and keeps deletion idempotent: %j', async payload => {