mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(ai): unify conversation navigation
This commit is contained in:
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,
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user