feat(ai): unify conversation navigation

This commit is contained in:
digua committed 2026-08-22 18:42:22 +08:00
1 parent f03b203d39
commit 714baa54e4
9 files changed
+250 -231

No files matched your search

@@ -123,7 +123,7 @@ export class BrowserAIConversationRepository implements ConversationRepository {
createConversation(sessionId: string, title: string | null = null): Promise<RuntimeConversation> {
const now = Date.now()
const conversation: RuntimeConversation = {
id: createId('conversation'),
id: createId('conv'),
sessionId,
title,
createdAt: now,
+5 -5
View File
@@ -51,7 +51,7 @@ const emit = defineEmits<{
}>()
const initialAIChatId = typeof route.query.aiChatId === 'string' ? route.query.aiChatId : null
if (initialAIChatId) emit('restore-loading-change', true)
emit('restore-loading-change', true)
// 使用 AI 对话 Composable
const {
@@ -449,14 +449,14 @@ watch(
/>
<!-- 右侧:对话区域(始终显示) -->
<div class="flex h-full flex-1 overflow-hidden">
<div class="flex h-full flex-1">
<div class="flex h-full min-w-0 flex-1 overflow-hidden">
<div class="flex h-full min-w-0 flex-1">
<div class="relative flex min-w-[480px] flex-1 flex-col overflow-hidden">
<!-- 顶部:有消息时显示助手切换按钮 -->
<template v-if="messages.length > 0 || isAIThinking">
<div class="flex items-center justify-between gap-2 px-3 py-1.5">
<div class="flex items-center justify-end gap-1 px-3 py-1.5">
<button
class="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
:disabled="isAIThinking || !assistantStore.selectedAssistant?.id"
:class="{ 'cursor-not-allowed opacity-50': isAIThinking || !assistantStore.selectedAssistant?.id }"
@click="handleConfigureAssistant(assistantStore.selectedAssistant!.id)"
+147 -184
View File
@@ -1,10 +1,16 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
import SidebarCollapseButton from '@/components/common/sidebar/SidebarCollapseButton.vue'
import { useAIService } from '@/services'
import { useLayoutStore } from '@/stores/layout'
const { t } = useI18n()
const layoutStore = useLayoutStore()
const { isAIChatSidebarCollapsed: isCollapsed } = storeToRefs(layoutStore)
const { toggleAIChatSidebar } = layoutStore
interface Conversation {
id: string
@@ -22,17 +28,22 @@ interface ConversationGroup {
}
// Props
const props = defineProps<{
sessionId: string
activeId: string | null
/** 仅禁用新建、重命名、删除等会改写状态的操作,仍允许只读切换查看其他对话。 */
disabled?: boolean
/** 传入后切换为受控模式,不再访问平台 AI service。 */
conversations?: Conversation[]
loading?: boolean
embedded?: boolean
collapsible?: boolean
}>()
const props = withDefaults(
defineProps<{
sessionId: string
activeId: string | null
/** 仅禁用新建、重命名、删除等会改写状态的操作,仍允许只读切换查看其他对话。 */
disabled?: boolean
/** 传入后切换为受控模式,不再访问平台 AI service。 */
conversations?: Conversation[]
loading?: boolean
embedded?: boolean
collapsible?: boolean
}>(),
{
collapsible: true,
}
)
// Emits
const emit = defineEmits<{
@@ -47,7 +58,6 @@ const serviceConversations = ref<Conversation[]>([])
const isLoading = ref(false)
const editingId = ref<string | null>(null)
const editingTitle = ref('')
const isCollapsed = ref(false)
const menuOpenId = ref<string | null>(null)
const isControlled = computed(() => props.conversations !== undefined)
const visibleConversations = computed(() => props.conversations ?? serviceConversations.value)
@@ -199,203 +209,156 @@ defineExpose({
<template>
<div
class="flex shrink-0 flex-col overflow-hidden bg-white transition-all duration-300 ease-in-out dark:bg-sidebar-dark"
:class="[props.embedded ? 'h-full' : 'm-3 h-[calc(100%-1.5rem)] rounded-lg', collapsed ? 'w-14' : 'w-64']"
class="relative shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out motion-reduce:transition-none"
:class="[props.embedded ? 'h-full' : 'm-3 h-[calc(100%-1.5rem)]', collapsed ? 'w-[92px]' : 'w-64']"
>
<!-- 头部 -->
<div
class="flex items-center"
:class="
collapsed
? 'justify-center px-2 pb-2 pt-5'
: 'justify-between border-b border-gray-200 py-2 pl-3 pr-2 dark:border-gray-800'
"
class="absolute inset-y-0 left-0 overflow-hidden rounded-lg bg-white transition-[width] duration-300 ease-in-out motion-reduce:transition-none dark:bg-sidebar-dark"
:class="collapsed ? 'pointer-events-none w-0' : 'w-64'"
:aria-hidden="collapsed"
>
<template v-if="!collapsed">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
:disabled="disabled"
:class="{ 'cursor-not-allowed opacity-50': disabled }"
@click="!disabled && emit('create')"
>
<UIcon name="i-heroicons-plus" class="h-3.5 w-3.5" />
<span>{{ t('ai.chat.conversation.newConversation') }}</span>
</button>
<UButton
v-if="canCollapse"
color="neutral"
variant="ghost"
size="sm"
class="group flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-white/[0.06]"
@click="isCollapsed = !isCollapsed"
>
<UIcon name="i-lucide-panel-right" class="size-4 scale-x-[-1] group-hover:hidden" />
<UIcon name="i-lucide-panel-right-close" class="size-4 hidden scale-x-[-1] group-hover:block" />
</UButton>
</template>
<template v-else>
<button
type="button"
class="group relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-white/[0.06]"
@click="isCollapsed = !isCollapsed"
>
<UIcon name="i-lucide-panel-right-open" class="size-4 scale-x-[-1]" />
</button>
</template>
</div>
<div class="flex h-full w-64 flex-col">
<div class="h-[52px] shrink-0 border-b border-gray-200 dark:border-gray-800" />
<!-- 展开状态列表 -->
<div v-if="!collapsed" class="flex-1 overflow-y-auto p-2">
<!-- 加载中 -->
<div v-if="visibleLoading" class="flex items-center justify-center py-8">
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin text-gray-400" />
</div>
<!-- 空状态 -->
<div
v-else-if="visibleConversations.length === 0"
class="flex flex-col items-center justify-center py-12 text-center"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-gray-50 dark:bg-gray-800">
<UIcon name="i-heroicons-chat-bubble-left" class="h-6 w-6 text-gray-300 dark:text-gray-600" />
</div>
<p class="mt-3 text-xs text-gray-400">{{ t('ai.chat.conversation.empty') }}</p>
<UButton
class="mt-2"
size="xs"
variant="link"
color="primary"
:disabled="disabled"
@click="!disabled && emit('create')"
>
{{ t('ai.chat.conversation.startNew') }}
</UButton>
</div>
<!-- 分组对话列表 -->
<div v-else class="space-y-3">
<div v-for="group in groupedConversations" :key="group.label">
<!-- 分组标题 -->
<div class="px-2 pb-1 pt-1.5 text-[10px] font-medium tracking-wide text-gray-400 dark:text-gray-500">
{{ group.label }}
<!-- 展开状态列表 -->
<div class="flex-1 overflow-y-auto p-2">
<!-- 加载中 -->
<div v-if="visibleLoading" class="flex items-center justify-center py-8">
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin text-gray-400" />
</div>
<!-- 对话项 -->
<div class="space-y-0.5">
<div
v-for="conv in group.conversations"
:key="conv.id"
class="group relative rounded-lg px-3 py-2 transition-all"
:class="[
'cursor-pointer',
activeId === conv.id
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800/50',
]"
@click="emit('select', conv.id)"
<!-- 空状态 -->
<div
v-else-if="visibleConversations.length === 0"
class="flex flex-col items-center justify-center py-12 text-center"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-gray-50 dark:bg-gray-800">
<UIcon name="i-heroicons-chat-bubble-left" class="h-6 w-6 text-gray-300 dark:text-gray-600" />
</div>
<p class="mt-3 text-xs text-gray-400">{{ t('ai.chat.conversation.empty') }}</p>
<UButton
class="mt-2"
size="xs"
variant="link"
color="primary"
:disabled="disabled"
@click="!disabled && emit('create')"
>
<!-- 编辑模式 -->
<template v-if="editingId === conv.id">
<input
v-model="editingTitle"
class="w-full rounded border-none bg-white px-2 py-1 text-sm shadow-sm outline-none ring-1 ring-gray-200 focus:ring-2 focus:ring-primary-500 dark:bg-page-dark dark:ring-gray-700"
:placeholder="t('ai.chat.conversation.titlePlaceholder')"
autoFocus
@blur="saveTitle(conv.id)"
@keyup.enter="saveTitle(conv.id)"
@click.stop
/>
</template>
{{ t('ai.chat.conversation.startNew') }}
</UButton>
</div>
<!-- 正常模式 -->
<template v-else>
<div class="flex items-center">
<p class="line-clamp-1 min-w-0 flex-1 text-sm leading-snug">
{{ getTitle(conv) }}
</p>
<!-- 分组对话列表 -->
<div v-else class="space-y-3">
<div v-for="group in groupedConversations" :key="group.label">
<!-- 分组标题 -->
<div class="px-2 pb-1 pt-1.5 text-[10px] font-medium tracking-wide text-gray-400 dark:text-gray-500">
{{ group.label }}
</div>
<!-- 三点菜单 -->
<div
class="ml-1 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
:class="{ 'opacity-100': activeId === conv.id || menuOpenId === conv.id }"
@click.stop
>
<UPopover
:open="menuOpenId === conv.id"
:ui="{ content: 'z-50 p-0' }"
@update:open="(val: boolean) => (menuOpenId = val ? conv.id : null)"
>
<button
class="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
<!-- 对话项 -->
<div class="space-y-0.5">
<div
v-for="conv in group.conversations"
:key="conv.id"
class="group relative rounded-lg px-3 py-2 transition-all"
:class="[
'cursor-pointer',
activeId === conv.id
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800/50',
]"
@click="emit('select', conv.id)"
>
<!-- 编辑模式 -->
<template v-if="editingId === conv.id">
<input
v-model="editingTitle"
class="w-full rounded border-none bg-white px-2 py-1 text-sm shadow-sm outline-none ring-1 ring-gray-200 focus:ring-2 focus:ring-primary-500 dark:bg-page-dark dark:ring-gray-700"
:placeholder="t('ai.chat.conversation.titlePlaceholder')"
autoFocus
@blur="saveTitle(conv.id)"
@keyup.enter="saveTitle(conv.id)"
@click.stop
/>
</template>
<!-- 正常模式 -->
<template v-else>
<div class="flex items-center">
<p class="line-clamp-1 min-w-0 flex-1 text-sm leading-snug">
{{ getTitle(conv) }}
</p>
<!-- 三点菜单 -->
<div
class="ml-1 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
:class="{ 'opacity-100': activeId === conv.id || menuOpenId === conv.id }"
@click.stop
>
<UIcon name="i-heroicons-ellipsis-horizontal" class="h-4 w-4" />
</button>
<template #content>
<div class="w-28 p-1">
<UPopover
:open="menuOpenId === conv.id"
:ui="{ content: 'z-50 p-0' }"
@update:open="(val: boolean) => (menuOpenId = val ? conv.id : null)"
>
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
:disabled="disabled"
@click="handleMenuRename(conv)"
class="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<UIcon name="i-heroicons-pencil" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.rename') }}
<UIcon name="i-heroicons-ellipsis-horizontal" class="h-4 w-4" />
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
@click="handleMenuCopyId(conv.id)"
>
<UIcon name="i-heroicons-clipboard-document" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.copyId') }}
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-red-500 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
:disabled="disabled"
@click="handleMenuDelete(conv.id)"
>
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.delete') }}
</button>
</div>
</template>
</UPopover>
</div>
<template #content>
<div class="w-28 p-1">
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
:disabled="disabled"
@click="handleMenuRename(conv)"
>
<UIcon name="i-heroicons-pencil" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.rename') }}
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
@click="handleMenuCopyId(conv.id)"
>
<UIcon name="i-heroicons-clipboard-document" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.copyId') }}
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-red-500 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
:disabled="disabled"
@click="handleMenuDelete(conv.id)"
>
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.delete') }}
</button>
</div>
</template>
</UPopover>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 折叠状态列表 -->
<div v-else class="flex flex-1 flex-col items-center gap-1 overflow-y-auto px-2 pb-2">
<!-- 常驻工具栏独立于面板,收起过程中不重排 -->
<div class="pointer-events-none absolute left-0 top-0 z-10 h-[52px] w-[92px]">
<div v-if="canCollapse" class="pointer-events-auto absolute left-2 top-2">
<SidebarCollapseButton :collapsed="collapsed" @click="toggleAIChatSidebar" />
</div>
<button
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-200/40 hover:text-primary-600 dark:text-gray-300 dark:hover:bg-white/[0.06] dark:hover:text-primary-400"
type="button"
class="pointer-events-auto absolute left-12 top-2 flex h-9 w-9 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
:class="{ 'cursor-not-allowed opacity-50': disabled }"
:title="t('ai.chat.conversation.newConversation')"
:disabled="disabled"
:class="{ 'cursor-not-allowed opacity-50': disabled }"
@click="!disabled && emit('create')"
>
<UIcon name="i-heroicons-plus" class="h-5 w-5" />
</button>
<button
v-for="conv in visibleConversations"
:key="conv.id"
class="relative mx-auto flex h-10 w-10 items-center justify-center rounded-lg transition-all duration-200"
:class="[
activeId === conv.id
? 'bg-gray-200/50 text-primary-600 dark:bg-white/[0.07] dark:text-primary-400 dark:ring-1 dark:ring-white/10'
: 'text-gray-600 hover:bg-gray-200/40 dark:text-gray-300 dark:hover:bg-white/[0.06]',
]"
:title="getTitle(conv)"
@click="emit('select', conv.id)"
>
<span
v-if="activeId === conv.id"
class="absolute -left-2 top-1/2 h-4.5 w-[3px] -translate-y-1/2 rounded-r-full bg-primary-500"
/>
<UIcon name="i-heroicons-chat-bubble-left" class="h-5 w-5" />
<UIcon name="i-lucide-square-pen" class="h-4 w-4" />
</button>
</div>
</div>
+4 -20
View File
@@ -8,6 +8,7 @@ import { shouldEnableContactsEntry } from '@openchatlab/core'
import type { AnalysisSession } from '@/types/base'
import LazyAvatar from '@/components/common/avatar/LazyAvatar.vue'
import SidebarButton from './sidebar/SidebarButton.vue'
import SidebarCollapseButton from './sidebar/SidebarCollapseButton.vue'
import SidebarFooter from './sidebar/SidebarFooter.vue'
import SidebarSortPopover from './sidebar/SidebarSortPopover.vue'
import { resolveSidebarSessionContentState } from './sidebar/session-content-state'
@@ -499,27 +500,10 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
{{ t('layout.updateNotice.tag') }}
</button>
</div>
<div
v-else
class="group relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-white/[0.06]"
style="-webkit-app-region: no-drag"
@click="toggleSidebar"
>
<SidebarCollapseButton :collapsed="isCollapsed" @click="toggleSidebar">
<img :src="logoSvg" alt="ChatLab" class="size-5 select-none pointer-events-none group-hover:hidden" />
<UIcon name="i-lucide-panel-right-open" class="size-4 hidden group-hover:block scale-x-[-1]" />
</div>
<UButton
v-if="!isCollapsed"
color="neutral"
variant="ghost"
size="sm"
class="group flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-white/[0.06]"
style="-webkit-app-region: no-drag"
@click="toggleSidebar"
>
<UIcon name="i-lucide-panel-right" class="size-4 group-hover:hidden scale-x-[-1]" />
<UIcon name="i-lucide-panel-right-close" class="size-4 hidden group-hover:block scale-x-[-1]" />
</UButton>
<UIcon name="i-lucide-panel-right-open" class="size-4 hidden scale-x-[-1] group-hover:block" />
</SidebarCollapseButton>
</div>
<div class="space-y-1">
@@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{
collapsed?: boolean
}>()
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<UButton
v-if="!collapsed"
color="neutral"
variant="ghost"
size="sm"
square
class="group flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-white/[0.06]"
style="-webkit-app-region: no-drag"
@click="emit('click', $event)"
>
<template #leading>
<UIcon name="i-lucide-panel-right" class="size-4 scale-x-[-1] group-hover:hidden" />
<UIcon name="i-lucide-panel-right-close" class="size-4 hidden scale-x-[-1] group-hover:block" />
</template>
</UButton>
<div
v-else
class="group relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-white/[0.06]"
style="-webkit-app-region: no-drag"
@click="emit('click', $event)"
>
<slot>
<UIcon name="i-lucide-panel-right-open" class="size-4 scale-x-[-1]" />
</slot>
</div>
</template>
+10 -10
View File
@@ -186,12 +186,16 @@ onMounted(async () => {
const currentSelectionRequest = ++conversationSelectionRequestId
const preferredAIChatId = typeof route.query.aiChatId === 'string' ? route.query.aiChatId : null
const restoredPreferredChat = preferredAIChatId ? await aiChatStore.loadAIChat(chatKey, preferredAIChatId) : false
let restored = preferredAIChatId ? await aiChatStore.loadAIChat(chatKey, preferredAIChatId) : false
if (currentSelectionRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return
if (!restoredPreferredChat) {
const latestAIChatId = conversations.value[0]?.id
if (!restored && latestAIChatId && latestAIChatId !== preferredAIChatId) {
restored = await aiChatStore.loadAIChat(chatKey, latestAIChatId)
if (currentSelectionRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return
}
if (!restored) {
aiChatStore.startNewAIChat(chatKey)
if (preferredAIChatId) await syncAIChatIdToRoute(null)
}
if (!isGlobalPageActive()) return
@@ -211,10 +215,7 @@ onUnmounted(() => {
</script>
<template>
<div
class="flex h-full min-w-0 flex-col bg-page-bg text-gray-900 dark:bg-page-dark dark:text-gray-100"
style="padding-top: var(--titlebar-area-height)"
>
<div class="flex h-full min-w-0 flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<PageHeader
:title="t('ai.global.title')"
:description="t('ai.global.subtitle')"
@@ -223,21 +224,20 @@ onUnmounted(() => {
size="compact"
/>
<div class="flex min-h-0 min-w-0 flex-1">
<div class="main-content flex min-h-0 flex-1 overflow-hidden">
<ConversationList
session-id=""
:active-id="currentAIChatId"
:conversations="conversations"
:loading="conversationsLoading"
:disabled="isAIThinking"
embedded
@select="selectConversation"
@create="startNewConversation"
@rename="renameConversation"
@delete="deleteConversation"
/>
<section class="flex min-w-0 flex-1 flex-col border-l border-gray-200 dark:border-gray-800">
<section class="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
<div class="relative min-h-0 flex-1">
<div ref="messagesContainer" class="absolute inset-0 overflow-y-auto px-5 py-6">
<div v-if="initializing" class="flex h-full items-center justify-center">
+11 -3
View File
@@ -624,9 +624,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
}
/**
* 每次 ChatExplorer 挂载时调用。
* 如果存在有效的记忆助手则直接进入对应助手,否则回到助手选择页。
* 从浮动任务条返回当前会话时跳过重置,以保留正在运行的对话状态。
* Restore the requested or most recently updated conversation when ChatExplorer mounts.
* Returning from the floating task bar skips restoration to preserve the running state.
*/
async function resetToSelectorOnEnter(chatKey: string, preferredAIChatId?: string | null): Promise<void> {
const shouldPreserveFocusedTask = pendingFocusReturnChatKey === chatKey
@@ -645,6 +644,15 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
return
}
try {
const latestAIChat = (await useAIService().getAIChats(state.sessionId))[0]
if (latestAIChat && latestAIChat.id !== preferredAIChatId && (await loadAIChat(chatKey, latestAIChat.id))) {
return
}
} catch (error) {
console.error('[AI] Failed to load the latest conversation:', error)
}
if (!state.selectedAssistantId) {
const defaultId = getDefaultGeneralAssistantId(state.locale)
selectAssistantForSession(chatKey, defaultId)
+21 -7
View File
@@ -3,10 +3,10 @@ import test from 'node:test'
import { createPinia, setActivePinia } from 'pinia'
import { ref } from 'vue'
test('restores only the latest requested AI chat without leaking navigation state', async (t) => {
const makeConversation = (id: string) => ({
test('restores the latest valid AI chat without leaking stale navigation state', async (t) => {
const makeConversation = (id: string, sessionId = 'session-one') => ({
id,
sessionId: 'session-one',
sessionId,
kind: 'session' as const,
title: 'Saved chat',
assistantId: 'assistant-one',
@@ -20,8 +20,11 @@ test('restores only the latest requested AI chat without leaking navigation stat
const aiService = {
getAIChat: async (id: string) => {
if (id === 'chat-slow') return slowConversation
return id === 'chat-one' || id === 'chat-latest' ? makeConversation(id) : null
if (id === 'chat-one' || id === 'chat-newest') return makeConversation(id)
return id === 'chat-latest' ? makeConversation(id, 'session-three') : null
},
getAIChats: async (sessionId: string) =>
sessionId === 'session-three' ? [makeConversation('chat-latest', sessionId)] : [],
getMessages: async (aiChatId: string) => [
{
id: `message-${aiChatId}`,
@@ -99,12 +102,23 @@ test('restores only the latest requested AI chat without leaking navigation stat
assert.equal(second.state.messages.length, 0)
const staleLoad = store.loadAIChat(first.chatKey, 'chat-slow')
assert.equal(await store.loadAIChat(first.chatKey, 'chat-latest'), true)
assert.equal(await store.loadAIChat(first.chatKey, 'chat-newest'), true)
resolveSlowConversation(makeConversation('chat-slow'))
assert.equal(await staleLoad, false)
assert.equal(first.state.currentAIChatId, 'chat-latest')
assert.equal(first.state.messages[0]?.content, 'Saved answer for chat-latest')
assert.equal(first.state.currentAIChatId, 'chat-newest')
assert.equal(first.state.messages[0]?.content, 'Saved answer for chat-newest')
const third = store.ensureSessionState({
sessionId: 'session-three',
sessionName: 'Third session',
chatType: 'private',
locale: 'zh-CN',
})
await store.resetToSelectorOnEnter(third.chatKey)
assert.equal(third.state.currentAIChatId, 'chat-latest')
assert.equal(third.state.messages[0]?.content, 'Saved answer for chat-latest')
const global = store.ensureGlobalState('zh-CN')
store.activeTask = {
+14 -1
View File
@@ -11,6 +11,7 @@ export const useLayoutStore = defineStore(
() => {
const settingsStore = useSettingsStore()
const isSidebarCollapsed = ref(false)
const isAIChatSidebarCollapsed = ref(false)
const showScreenCaptureModal = ref(false)
const screenCaptureImage = ref<string | null>(null)
const showChatRecordDrawer = ref(false)
@@ -36,6 +37,10 @@ export const useLayoutStore = defineStore(
isSidebarCollapsed.value = !isSidebarCollapsed.value
}
function toggleAIChatSidebar() {
isAIChatSidebarCollapsed.value = !isAIChatSidebarCollapsed.value
}
/**
* 打开截屏预览弹窗
*/
@@ -102,6 +107,7 @@ export const useLayoutStore = defineStore(
return {
isSidebarCollapsed,
isAIChatSidebarCollapsed,
isToolsPanelLocked,
isToolsPanelMini,
toolsPanelPosition,
@@ -115,6 +121,7 @@ export const useLayoutStore = defineStore(
settingsTab,
settingsSubTab,
toggleSidebar,
toggleAIChatSidebar,
toggleToolsPanelLock,
toggleToolsPanelOpen,
toggleToolsPanelMini,
@@ -129,7 +136,13 @@ export const useLayoutStore = defineStore(
{
persist: [
{
pick: ['isSidebarCollapsed', 'isToolsPanelLocked', 'isToolsPanelMini', 'toolsPanelPosition'],
pick: [
'isSidebarCollapsed',
'isAIChatSidebarCollapsed',
'isToolsPanelLocked',
'isToolsPanelMini',
'toolsPanelPosition',
],
storage: localStorage,
},
],