feat(sidebar): unify chat records and contact detail panels

This commit is contained in:
digua committed 2026-09-28 14:53:15 +08:00
1 parent 3e0e0fbbfb
commit 5e34b7d255
26 files changed
+775 -589

No files matched your search

+3 -6
View File
@@ -41,12 +41,11 @@ const LockScreen = IS_ELECTRON ? defineAsyncComponent(() => import('@/components
const DataDirCleanupNotice = defineAsyncComponent(() => import('@/components/common/DataDirCleanupNotice.vue'))
const loadScreenCaptureModal = () => import('@/components/common/ScreenCaptureModal.vue')
const loadSettingsModal = () => import('@/components/common/SettingsModal.vue')
const loadChatRecordDrawer = () => import('@/components/common/ChatRecord/ChatRecordDrawer.vue')
const loadChatRecordWorkspace = () => import('@/components/common/ChatRecord/ChatRecordWorkspace.vue')
const loadGlobalTaskBar = () => import('@/components/AIChat/GlobalTaskBar.vue')
const loadDebugToolsPanel = () => import('@/components/layout/DebugToolsPanel.vue')
const ScreenCaptureModal = defineAsyncComponent(loadScreenCaptureModal)
const SettingsModal = defineAsyncComponent(loadSettingsModal)
const ChatRecordDrawer = defineAsyncComponent(loadChatRecordDrawer)
const GlobalTaskBar = defineAsyncComponent(loadGlobalTaskBar)
const DebugToolsPanel = defineAsyncComponent(loadDebugToolsPanel)
@@ -120,7 +119,7 @@ function scheduleNonCriticalUiPrefetch() {
cancelNonCriticalUiPrefetch = null
void Promise.allSettled([
loadSettingsModal(),
loadChatRecordDrawer(),
loadChatRecordWorkspace(),
loadScreenCaptureModal(),
loadDebugToolsPanel(),
])
@@ -359,7 +358,7 @@ useWindowsTitleBarOverlay([
() => route.fullPath,
() => layoutStore.showSettings,
() => layoutStore.showScreenCaptureModal,
() => layoutStore.showChatRecordDrawer,
() => layoutStore.sidePanelOpen,
])
onMounted(async () => {
@@ -484,8 +483,6 @@ onUnmounted(() => {
/>
<!-- 全局设置弹窗 -->
<SettingsModal v-if="settingsModalMounted" />
<!-- 全局聊天记录查看器 -->
<ChatRecordDrawer v-if="layoutStore.showChatRecordDrawer || layoutStore.chatRecordQuery" />
<!-- 全局 AI 后台任务条:允许用户离开当前页面后仍然快速返回进行中的对话。 -->
<GlobalTaskBar />
<!-- Desktop 与 CLI Web 迁移后都提醒人工清理。 -->
@@ -31,7 +31,7 @@ const sessionGroups = computed(() => {
})
function viewSource(source: CrossChatEvidenceSource): void {
layoutStore.openChatRecordDrawer({
layoutStore.openChatRecords({
sessionId: source.sessionId,
scrollToMessageId: source.messageId,
})
+1 -1
View File
@@ -43,7 +43,7 @@ const statusDotClass: Record<EvidenceStatus, string> = {
}
function viewSource(messageId: number): void {
layoutStore.openChatRecordDrawer({ scrollToMessageId: messageId })
layoutStore.openChatRecords({ scrollToMessageId: messageId })
}
</script>
@@ -78,7 +78,7 @@ function getMessageIdColumnIndex(columns: string[]): number {
* 查看消息上下文
*/
function viewMessageContext(messageId: number) {
layoutStore.openChatRecordDrawer({
layoutStore.openChatRecords({
scrollToMessageId: messageId,
})
}
@@ -53,7 +53,7 @@ async function loadCatchphraseAnalysis() {
}
function handlePhraseClick(content: string) {
layoutStore.openChatRecordDrawer(buildCatchphraseRecordQuery(content, props.timeFilter))
layoutStore.openChatRecords(buildCatchphraseRecordQuery(content, props.timeFilter))
}
function getOverviewPhrases(member: MemberCatchphrase) {
@@ -48,7 +48,7 @@ async function loadRepeatAnalysis() {
* 查看复读内容的聊天记录上下文
*/
function viewRepeatContext(item: { content: string; firstMessageId: number }) {
layoutStore.openChatRecordDrawer({
layoutStore.openChatRecords({
scrollToMessageId: item.firstMessageId,
highlightKeywords: [item.content],
})
@@ -43,7 +43,7 @@ async function loadData() {
function handleWordClick(word: string) {
if (!props.enableRecordNavigation) return
layoutStore.openChatRecordDrawer({ keywords: [word] })
layoutStore.openChatRecords({ keywords: [word] })
}
watch(
@@ -430,7 +430,7 @@ watch(locale, () => {
// 点击词语,打开聊天记录查看器
function handleWordClick(word: string) {
if (!props.enableRecordNavigation) return
layoutStore.openChatRecordDrawer({
layoutStore.openChatRecords({
keywords: [word],
})
}
@@ -1,146 +0,0 @@
<script setup lang="ts">
/**
* Chat record viewer drawer.
* Keeps the quick-view shell while ChatRecordWorkspace owns shared orchestration.
*/
import { computed, onBeforeUnmount, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import ChatRecordWorkspace from './ChatRecordWorkspace.vue'
import { useLayoutStore } from '@/stores/layout'
const { t } = useI18n()
const layoutStore = useLayoutStore()
// 平台检测
const isWindows = ref(false)
const MIN_DRAWER_WIDTH = 480
const VIEWPORT_MARGIN = 32
let dragStartX = 0
let dragStartWidth = 0
let resizeHandle: HTMLElement | null = null
let resizePointerId: number | null = null
const maxDrawerWidth = ref(1280 - VIEWPORT_MARGIN)
const minDrawerWidth = computed(() => Math.min(MIN_DRAWER_WIDTH, maxDrawerWidth.value))
const effectiveDrawerWidth = computed(() => clampDrawerWidth(layoutStore.chatRecordDrawerWidth))
function updateViewportLimit() {
maxDrawerWidth.value = Math.max(0, window.innerWidth - VIEWPORT_MARGIN)
}
function clampDrawerWidth(width: number) {
return Math.min(maxDrawerWidth.value, Math.max(minDrawerWidth.value, width))
}
function stopResize(event?: Event) {
if (event instanceof PointerEvent && event.pointerId !== resizePointerId) return
window.removeEventListener('pointermove', handleResize)
window.removeEventListener('pointerup', stopResize)
window.removeEventListener('pointercancel', stopResize)
window.removeEventListener('blur', stopResize)
resizeHandle?.removeEventListener('lostpointercapture', stopResize)
if (resizeHandle && resizePointerId !== null && resizeHandle.hasPointerCapture(resizePointerId)) {
resizeHandle.releasePointerCapture(resizePointerId)
}
resizeHandle = null
resizePointerId = null
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
function handleResize(event: PointerEvent) {
if (event.pointerId !== resizePointerId) return
layoutStore.chatRecordDrawerWidth = clampDrawerWidth(dragStartWidth + dragStartX - event.clientX)
}
function startResize(event: PointerEvent) {
if (event.button !== 0 || resizePointerId !== null) return
resizeHandle = event.currentTarget as HTMLElement
resizePointerId = event.pointerId
resizeHandle.setPointerCapture(resizePointerId)
resizeHandle.addEventListener('lostpointercapture', stopResize)
dragStartX = event.clientX
dragStartWidth = effectiveDrawerWidth.value
window.addEventListener('pointermove', handleResize)
window.addEventListener('pointerup', stopResize)
window.addEventListener('pointercancel', stopResize)
window.addEventListener('blur', stopResize)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
function resizeWithKeyboard(event: KeyboardEvent) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
const delta = event.key === 'ArrowLeft' ? 24 : -24
layoutStore.chatRecordDrawerWidth = clampDrawerWidth(effectiveDrawerWidth.value + delta)
}
onMounted(() => {
isWindows.value = navigator.platform.toLowerCase().includes('win')
updateViewportLimit()
window.addEventListener('resize', updateViewportLimit)
})
onBeforeUnmount(() => {
stopResize()
window.removeEventListener('resize', updateViewportLimit)
})
</script>
<template>
<UDrawer
v-model:open="layoutStore.showChatRecordDrawer"
direction="right"
:handle="false"
handle-only
:ui="{ content: 'z-50' }"
>
<template #content>
<div
data-vaul-no-drag
class="chat-record-drawer-content relative flex h-full flex-col bg-white dark:bg-page-dark"
:style="{ width: `${effectiveDrawerWidth}px`, maxWidth: `calc(100vw - ${VIEWPORT_MARGIN}px)` }"
style="-webkit-app-region: no-drag"
>
<div
class="group absolute inset-y-0 left-0 z-10 w-2 -translate-x-1/2 cursor-col-resize touch-none"
role="separator"
aria-orientation="vertical"
:aria-label="t('records.drawer.resize')"
:aria-valuemin="minDrawerWidth"
:aria-valuemax="maxDrawerWidth"
:aria-valuenow="effectiveDrawerWidth"
tabindex="0"
@pointerdown.prevent="startResize"
@keydown="resizeWithKeyboard"
>
<div
class="mx-auto h-full w-px bg-transparent transition-colors group-hover:bg-primary-400 group-focus:bg-primary-500"
/>
</div>
<!-- 头部 -->
<div
class="flex items-center justify-between border-b border-gray-200 px-4 dark:border-gray-800"
:class="isWindows ? 'pt-10 pb-3' : 'py-3'"
>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('records.drawer.title') }}</h3>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="sm"
@click="layoutStore.closeChatRecordDrawer()"
/>
</div>
<ChatRecordWorkspace
class="min-h-0 flex-1"
:initial-query="layoutStore.chatRecordQuery"
:active="layoutStore.showChatRecordDrawer"
mode="drawer"
/>
</div>
</template>
</UDrawer>
</template>
@@ -124,8 +124,8 @@ function resetFilter() {
</script>
<template>
<div class="overflow-x-auto border-b border-gray-200 px-4 py-2.5 dark:border-gray-800">
<div class="flex min-w-[686px] items-center gap-2">
<div class="shrink-0 border-b border-gray-200 px-4 py-2.5 dark:border-gray-800">
<div class="flex flex-wrap items-center gap-2">
<UInput
v-model="formData.messageId"
type="number"
@@ -154,7 +154,7 @@ function resetFilter() {
class="min-w-24 max-w-[260px] flex-1"
@keydown="handleKeywordsKeydown"
/>
<div class="flex shrink-0 gap-1">
<div class="ml-auto flex shrink-0 gap-1">
<UButton color="neutral" variant="ghost" size="sm" @click="resetFilter">
{{ t('records.filter.reset') }}
</UButton>
+1 -2
View File
@@ -1,8 +1,7 @@
/**
* 聊天记录查看器组件
* 导出主 Drawer 组件和相关类型
* Shared workspace and query types.
*/
export { default as ChatRecordDrawer } from './ChatRecordDrawer.vue'
export { default as ChatRecordWorkspace } from './ChatRecordWorkspace.vue'
export * from './types'
+3 -1
View File
@@ -33,6 +33,7 @@ const props = withDefaults(
// Emits
const emit = defineEmits<{
'data-changed': []
'view-records': []
}>()
// 成员列表
@@ -84,11 +85,12 @@ function getFirstChar(member: MemberWithStats): string {
}
function viewMemberChatRecords(member: MemberWithStats) {
layoutStore.openChatRecordDrawer({
layoutStore.openChatRecords({
sessionId: props.sessionId,
memberId: member.id,
memberName: getDisplayName(member),
})
emit('view-records')
}
const filteredSortedMembers = computed(() => filterAndSortMembers(members.value, searchQuery.value, sortState.value))
+18
View File
@@ -5,6 +5,11 @@
*/
import { IS_ELECTRON } from '@/utils/platform'
import { useI18n } from 'vue-i18n'
import { useLayoutStore } from '@/stores/layout'
const { t } = useI18n()
const layout = useLayoutStore()
const props = withDefaults(
defineProps<{
@@ -77,6 +82,19 @@ const props = withDefaults(
<!-- 右侧操作区域 -->
<div class="header-actions relative z-[40] flex items-center gap-2">
<slot name="actions" />
<UButton
v-if="layout.canOpenSidePanel"
data-side-panel-toggle
icon="i-lucide-panel-right"
color="neutral"
variant="ghost"
size="sm"
:class="layout.sidePanelOpen ? 'bg-gray-100 dark:bg-white/10' : ''"
:aria-label="t(layout.sidePanelOpen ? 'common.sidePanel.collapse' : 'common.sidePanel.expand')"
:aria-expanded="layout.sidePanelOpen"
aria-controls="app-side-panel"
@click="layout.toggleSidePanel()"
/>
</div>
</div>
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useResizeObserver } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useLayoutStore } from '@/stores/layout'
const props = defineProps<{ sessionId?: string | null; contextKey?: string | null }>()
const ChatRecordWorkspace = defineAsyncComponent(() => import('@/components/common/ChatRecord/ChatRecordWorkspace.vue'))
const layout = useLayoutStore()
const route = useRoute()
const { t } = useI18n()
const root = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
const containerWidth = ref(1200)
const resizing = ref(false)
const records = computed(() => layout.sidePanelView === 'records')
const title = computed(() => t(records.value ? 'records.drawer.title' : 'contacts.detail.title'))
const preferredWidth = computed({
get: () => (records.value ? layout.chatRecordDrawerWidth : layout.contactPanelWidth),
set: (value) => {
if (records.value) layout.chatRecordDrawerWidth = value
else layout.contactPanelWidth = value
},
})
const minimumWidth = computed(() => (records.value ? 480 : 360))
// Measure the available page area, not the window: the main navigation also takes space.
const overlay = computed(() => containerWidth.value < minimumWidth.value + 520)
const maximumWidth = computed(() => Math.max(0, containerWidth.value - (overlay.value ? 16 : 520)))
const width = computed(() => Math.min(maximumWidth.value, Math.max(minimumWidth.value, preferredWidth.value)))
const canGoBack = computed(() => records.value && layout.sidePanelHasContact)
let previousFocus: HTMLElement | null = null
let dragStartX = 0
let dragStartWidth = 0
let resizeHandle: HTMLElement | null = null
let resizePointerId: number | null = null
useResizeObserver(root, ([entry]) => {
if (entry) containerWidth.value = entry.contentRect.width
})
watch(
[() => route.path, () => props.sessionId, () => props.contextKey],
() => layout.setSidePanelContext(props.sessionId ?? null),
{ immediate: true, flush: 'sync' }
)
watch(
[() => layout.sidePanelOpen, overlay],
async ([open, isOverlay], [wasOpen]) => {
if (open) {
if (!wasOpen) previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
if (isOverlay) {
await nextTick()
if (layout.sidePanelOpen) panel.value?.focus({ preventScroll: true })
}
} else {
stopResize()
if (panel.value?.contains(document.activeElement)) {
const toggle = document.querySelector<HTMLElement>('[data-side-panel-toggle]')
;(toggle || previousFocus)?.focus({ preventScroll: true })
}
}
},
{ flush: 'pre' }
)
function clampWidth(value: number) {
return Math.min(maximumWidth.value, Math.max(minimumWidth.value, value))
}
function stopResize(event?: Event) {
if (event instanceof PointerEvent && event.pointerId !== resizePointerId) return
window.removeEventListener('pointermove', handleResize)
window.removeEventListener('pointerup', stopResize)
window.removeEventListener('pointercancel', stopResize)
window.removeEventListener('blur', stopResize)
resizeHandle?.removeEventListener('lostpointercapture', stopResize)
if (resizeHandle && resizePointerId !== null && resizeHandle.hasPointerCapture(resizePointerId)) {
resizeHandle.releasePointerCapture(resizePointerId)
}
resizeHandle = null
resizePointerId = null
if (resizing.value) {
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
resizing.value = false
}
function handleResize(event: PointerEvent) {
if (event.pointerId !== resizePointerId) return
preferredWidth.value = clampWidth(dragStartWidth + dragStartX - event.clientX)
}
function startResize(event: PointerEvent) {
if (event.button !== 0 || resizePointerId !== null) return
resizeHandle = event.currentTarget as HTMLElement
resizePointerId = event.pointerId
resizeHandle.setPointerCapture(resizePointerId)
resizeHandle.addEventListener('lostpointercapture', stopResize)
dragStartX = event.clientX
dragStartWidth = width.value
resizing.value = true
window.addEventListener('pointermove', handleResize)
window.addEventListener('pointerup', stopResize)
window.addEventListener('pointercancel', stopResize)
window.addEventListener('blur', stopResize)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
function resizeWithKeyboard(event: KeyboardEvent) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
preferredWidth.value = clampWidth(width.value + (event.key === 'ArrowLeft' ? 24 : -24))
}
onBeforeUnmount(() => {
stopResize()
layout.setSidePanelContext(null)
})
</script>
<template>
<div ref="root" class="side-panel-layout relative flex min-h-0 min-w-0 flex-1 overflow-hidden">
<div class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" :inert="overlay && layout.sidePanelOpen">
<slot />
</div>
<Transition name="side-panel-backdrop">
<div
v-if="overlay && layout.sidePanelOpen"
class="absolute inset-0 z-40 bg-black/15 dark:bg-black/35"
aria-hidden="true"
@click="layout.closeSidePanel()"
/>
</Transition>
<aside
id="app-side-panel"
ref="panel"
tabindex="-1"
:aria-label="title"
:aria-hidden="!layout.sidePanelOpen"
:inert="!layout.sidePanelOpen"
class="side-panel relative z-[41] h-full shrink-0 overflow-hidden outline-none"
:class="{ 'side-panel-overlay': overlay, 'side-panel-resizing': resizing }"
:style="{ width: layout.sidePanelOpen ? `${width}px` : '0px' }"
@keydown.esc.stop="layout.closeSidePanel()"
>
<!-- Keep the inner width stable while the outer shell collapses. -->
<div class="h-full" :style="{ width: `${width}px` }">
<div
class="relative flex h-full min-h-0 flex-col border-l border-gray-200 bg-white dark:border-white/5 dark:bg-page-dark"
>
<div
class="group absolute inset-y-0 left-0 z-10 w-1.5 cursor-col-resize touch-none"
role="separator"
aria-orientation="vertical"
:aria-label="t('common.sidePanel.resize')"
:aria-valuemin="Math.min(minimumWidth, maximumWidth)"
:aria-valuemax="maximumWidth"
:aria-valuenow="width"
tabindex="0"
@pointerdown.prevent="startResize"
@keydown="resizeWithKeyboard"
>
<div class="h-full w-px transition-colors group-hover:bg-primary-400 group-focus:bg-primary-500" />
</div>
<header class="flex h-12 shrink-0 items-center gap-2 border-b border-gray-100 px-3 dark:border-white/5">
<UButton
v-if="canGoBack"
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
size="sm"
:aria-label="t('common.back')"
@click="layout.returnToContactPanel()"
/>
<h2 class="min-w-0 flex-1 truncate text-sm font-medium text-gray-900 dark:text-gray-100">{{ title }}</h2>
<UButton
icon="i-lucide-panel-right-close"
color="neutral"
variant="ghost"
size="sm"
:aria-label="t('common.sidePanel.collapse')"
@click="layout.closeSidePanel()"
/>
</header>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<ChatRecordWorkspace
v-if="layout.chatRecordQuery"
v-show="records"
:initial-query="layout.chatRecordQuery"
class="min-h-0 flex-1"
mode="drawer"
/>
<div
v-if="layout.sidePanelHasContact"
v-show="!records"
class="flex min-h-0 flex-1 flex-col overflow-hidden"
>
<slot name="contact" />
</div>
</div>
</div>
</div>
</aside>
</div>
</template>
<style scoped>
.side-panel {
transition: width 240ms cubic-bezier(0.22, 1, 0.36, 1);
-webkit-app-region: no-drag;
}
.side-panel-overlay {
position: absolute;
right: 0;
top: 0;
}
.side-panel-resizing {
transition: none;
}
.side-panel-backdrop-enter-active,
.side-panel-backdrop-leave-active {
transition: opacity 200ms ease;
}
.side-panel-backdrop-enter-from,
.side-panel-backdrop-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.side-panel,
.side-panel-backdrop-enter-active,
.side-panel-backdrop-leave-active {
transition: none;
}
}
</style>
@@ -36,7 +36,6 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'openIncrementalImport'): void
(e: 'openMemberManagement'): void
(e: 'openChatRecord'): void
(e: 'update:fullRange', value: { start: number; end: number } | null): void
(e: 'update:availableYears', value: number[]): void
(e: 'timeRangeInitialized', hasRange: boolean): void
@@ -59,16 +58,6 @@ const navigationItems = computed(() =>
<PageHeader :title="title" :avatar="avatar" size="compact" :icon="icon" :icon-class="iconClass">
<template #actions>
<template v-if="showSessionActions && layoutStore.effectiveToolsPanelPosition === 'header'">
<UTooltip :text="t('analysis.tooltip.viewChatRecord')">
<UButton
icon="i-heroicons-chat-bubble-bottom-center-text"
variant="ghost"
color="gray"
size="sm"
class="hover:bg-gray-100 dark:hover:bg-gray-800"
@click="emit('openChatRecord')"
/>
</UTooltip>
<UTooltip :text="t('analysis.tooltip.incrementalImport')">
<UButton
icon="i-heroicons-plus-circle"
+5
View File
@@ -10,6 +10,11 @@
"back": "Back",
"collapseSidebar": "Collapse sidebar",
"expandSidebar": "Expand sidebar",
"sidePanel": {
"expand": "Expand side panel",
"collapse": "Collapse side panel",
"resize": "Resize side panel"
},
"done": "Done",
"retry": "Retry",
"initFailed": "App initialization failed",
+5
View File
@@ -10,6 +10,11 @@
"back": "戻る",
"collapseSidebar": "サイドバーを折りたたむ",
"expandSidebar": "サイドバーを展開",
"sidePanel": {
"expand": "右パネルを開く",
"collapse": "右パネルを閉じる",
"resize": "パネルの幅を調整"
},
"done": "完了",
"retry": "リトライ",
"initFailed": "アプリの初期化に失敗しました",
+5
View File
@@ -10,6 +10,11 @@
"back": "返回",
"collapseSidebar": "收起侧栏",
"expandSidebar": "展开侧栏",
"sidePanel": {
"expand": "展开右侧面板",
"collapse": "收起右侧面板",
"resize": "调整面板宽度"
},
"done": "完成",
"retry": "重试",
"initFailed": "应用初始化失败",
+5
View File
@@ -10,6 +10,11 @@
"back": "返回上一頁",
"collapseSidebar": "收起側邊欄",
"expandSidebar": "展開側邊欄",
"sidePanel": {
"expand": "展開右側面板",
"collapse": "收起右側面板",
"resize": "調整面板寬度"
},
"done": "完成",
"retry": "重試",
"initFailed": "應用程式初始化失敗",
+121 -118
View File
@@ -15,6 +15,7 @@ import { useChatScroll } from '@/components/AIChat/composables/useChatScroll'
import { useProgressiveChatHistory } from '@/components/AIChat/composables/useProgressiveChatHistory'
import { groupMessagesToQAPairs } from '@/components/AIChat/utils/chatMessages'
import PageHeader from '@/components/layout/PageHeader.vue'
import SidePanelLayout from '@/components/layout/SidePanelLayout.vue'
import { useAIService } from '@/services'
import type { AIChat } from '@/services/ai/types'
import { useAIChatStore } from '@/stores/aiChat'
@@ -297,134 +298,136 @@ onUnmounted(() => {
</template>
</PageHeader>
<div class="main-content flex min-h-0 flex-1 overflow-hidden">
<ConversationList
session-id=""
:active-id="currentAIChatId"
:conversations="conversations"
:loading="conversationsLoading"
:disabled="isAIThinking"
@select="selectConversation"
@create="startNewConversation"
@rename="renameConversation"
@delete="deleteConversation"
/>
<SidePanelLayout :context-key="currentAIChatId">
<div class="main-content flex min-h-0 flex-1 overflow-hidden">
<ConversationList
session-id=""
:active-id="currentAIChatId"
:conversations="conversations"
:loading="conversationsLoading"
:disabled="isAIThinking"
@select="selectConversation"
@create="startNewConversation"
@rename="renameConversation"
@delete="deleteConversation"
/>
<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">
<UIcon name="i-lucide-loader-2" class="h-5 w-5 animate-spin text-gray-400" />
</div>
<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">
<UIcon name="i-lucide-loader-2" class="h-5 w-5 animate-spin text-gray-400" />
</div>
<div
v-else-if="messages.length === 0"
class="mx-auto flex h-full max-w-lg flex-col items-center justify-center pb-16 text-center"
>
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary-50 text-primary-500 dark:bg-primary-500/10 dark:text-primary-300"
v-else-if="messages.length === 0"
class="mx-auto flex h-full max-w-lg flex-col items-center justify-center pb-16 text-center"
>
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-6 w-6" />
</div>
<h2 class="text-base text-gray-800 dark:text-gray-100">{{ t('ai.global.empty.title') }}</h2>
<p class="mt-2 text-sm leading-6 text-gray-500 dark:text-gray-400">
{{ t('ai.global.empty.description') }}
</p>
</div>
<div v-else class="mx-auto max-w-3xl space-y-4">
<div v-if="hasOlderPairs" class="flex justify-center pb-2">
<button
type="button"
class="flex h-8 items-center gap-1.5 rounded-full px-3 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"
@click="loadOlderPairs"
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary-50 text-primary-500 dark:bg-primary-500/10 dark:text-primary-300"
>
<UIcon name="i-heroicons-arrow-up" class="h-3.5 w-3.5" />
{{ t('ai.chat.history.loadEarlier') }}
</button>
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-6 w-6" />
</div>
<h2 class="text-base text-gray-800 dark:text-gray-100">{{ t('ai.global.empty.title') }}</h2>
<p class="mt-2 text-sm leading-6 text-gray-500 dark:text-gray-400">
{{ t('ai.global.empty.description') }}
</p>
</div>
<template v-for="pair in visiblePairs" :key="pair.id">
<ChatMessage
v-if="pair.standalone"
:role="pair.standalone.role"
:content="pair.standalone.content"
:timestamp="pair.standalone.timestamp"
/>
<div v-else class="qa-pair space-y-6 pb-4">
<ChatMessage
v-if="pair.user"
:role="pair.user.role"
:message-id="pair.user.id"
:content="pair.user.content"
:timestamp="pair.user.timestamp"
:entity-refs="pair.user.entityRefs"
:highlighted="highlightedSourceMessageId === pair.user.id"
/>
<ChatMessage
v-if="
pair.assistant &&
(pair.assistant.content || pair.assistant.contentBlocks?.length || !pair.assistant.isStreaming)
"
:role="pair.assistant.role"
:message-id="pair.assistant.id"
:content="pair.assistant.content"
:timestamp="pair.assistant.timestamp"
:is-streaming="pair.assistant.isStreaming"
:process-duration-ms="pair.assistant.processDurationMs"
:content-blocks="pair.assistant.contentBlocks"
:show-capture-button="!pair.assistant.isStreaming"
:active-tool="pair.assistant.isStreaming ? state.currentToolStatus : null"
:highlighted="highlightedSourceMessageId === pair.assistant.id"
/>
<AIThinkingIndicator
v-else-if="pair.assistant?.isStreaming"
:current-tool-status="state.currentToolStatus"
:agent-status="state.agentStatus"
/>
<div v-else class="mx-auto max-w-3xl space-y-4">
<div v-if="hasOlderPairs" class="flex justify-center pb-2">
<button
type="button"
class="flex h-8 items-center gap-1.5 rounded-full px-3 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"
@click="loadOlderPairs"
>
<UIcon name="i-heroicons-arrow-up" class="h-3.5 w-3.5" />
{{ t('ai.chat.history.loadEarlier') }}
</button>
</div>
</template>
<template v-for="pair in visiblePairs" :key="pair.id">
<ChatMessage
v-if="pair.standalone"
:role="pair.standalone.role"
:content="pair.standalone.content"
:timestamp="pair.standalone.timestamp"
/>
<div v-else class="qa-pair space-y-6 pb-4">
<ChatMessage
v-if="pair.user"
:role="pair.user.role"
:message-id="pair.user.id"
:content="pair.user.content"
:timestamp="pair.user.timestamp"
:entity-refs="pair.user.entityRefs"
:highlighted="highlightedSourceMessageId === pair.user.id"
/>
<ChatMessage
v-if="
pair.assistant &&
(pair.assistant.content || pair.assistant.contentBlocks?.length || !pair.assistant.isStreaming)
"
:role="pair.assistant.role"
:message-id="pair.assistant.id"
:content="pair.assistant.content"
:timestamp="pair.assistant.timestamp"
:is-streaming="pair.assistant.isStreaming"
:process-duration-ms="pair.assistant.processDurationMs"
:content-blocks="pair.assistant.contentBlocks"
:show-capture-button="!pair.assistant.isStreaming"
:active-tool="pair.assistant.isStreaming ? state.currentToolStatus : null"
:highlighted="highlightedSourceMessageId === pair.assistant.id"
/>
<AIThinkingIndicator
v-else-if="pair.assistant?.isStreaming"
:current-tool-status="state.currentToolStatus"
:agent-status="state.agentStatus"
/>
</div>
</template>
</div>
</div>
<Transition name="fade-up">
<button
v-if="showScrollToBottom"
type="button"
class="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-gray-800/90 px-3 py-1.5 text-xs text-white shadow-lg backdrop-blur-sm hover:bg-gray-700 dark:bg-gray-700/90"
@click="handleScrollToBottom"
>
<UIcon name="i-heroicons-arrow-down" class="h-3.5 w-3.5" />
{{ t('ai.chat.scrollToBottom') }}
</button>
</Transition>
</div>
<div class="shrink-0 px-4 pb-4">
<div
class="relative mx-auto max-w-3xl overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5"
>
<AIChatInput
ref="chatInputRef"
embedded
:disabled="isAIThinking"
:status="isAIThinking ? 'streaming' : 'ready'"
mention-scope="global"
:skills-enabled="false"
:placeholder="t('ai.global.input.placeholder')"
@send="handleSend"
@stop="aiChatStore.stopGeneration(chatKey)"
/>
<ChatStatusBar
class="pb-1.5 pl-2 pr-[52px] pt-0.5"
:session-token-usage="state.sessionTokenUsage"
:agent-status="state.agentStatus"
:estimated-context-tokens="estimatedContextTokens"
/>
</div>
</div>
<Transition name="fade-up">
<button
v-if="showScrollToBottom"
type="button"
class="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-gray-800/90 px-3 py-1.5 text-xs text-white shadow-lg backdrop-blur-sm hover:bg-gray-700 dark:bg-gray-700/90"
@click="handleScrollToBottom"
>
<UIcon name="i-heroicons-arrow-down" class="h-3.5 w-3.5" />
{{ t('ai.chat.scrollToBottom') }}
</button>
</Transition>
</div>
<div class="shrink-0 px-4 pb-4">
<div
class="relative mx-auto max-w-3xl overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5"
>
<AIChatInput
ref="chatInputRef"
embedded
:disabled="isAIThinking"
:status="isAIThinking ? 'streaming' : 'ready'"
mention-scope="global"
:skills-enabled="false"
:placeholder="t('ai.global.input.placeholder')"
@send="handleSend"
@stop="aiChatStore.stopGeneration(chatKey)"
/>
<ChatStatusBar
class="pb-1.5 pl-2 pr-[52px] pt-0.5"
:session-token-usage="state.sessionTokenUsage"
:agent-status="state.agentStatus"
:estimated-context-tokens="estimatedContextTokens"
/>
</div>
</div>
</section>
</div>
</section>
</div>
</SidePanelLayout>
<AIMemoryManagerModal v-model:open="showMemoryManager" @view-source="handleViewMemorySource" />
</div>
+54 -51
View File
@@ -11,6 +11,7 @@ import RankingView from '@/components/analysis/ranking/RankingView.vue'
import MemberList from '@/components/common/member/MemberList.vue'
import NicknameHistoryEntry from './components/member/NicknameHistoryEntry.vue'
import SessionAnalysisHeader from '@/components/layout/session/SessionAnalysisHeader.vue'
import SidePanelLayout from '@/components/layout/SidePanelLayout.vue'
import SemanticIndexSessionModal from '@/components/analysis/SemanticIndexSessionModal.vue'
import OwnerPromptModal from '@/components/analysis/member/OwnerPromptModal.vue'
import IncrementalImportModal from '@/components/analysis/IncrementalImportModal.vue'
@@ -48,7 +49,7 @@ const showMemberManagementModal = ref(false)
// 打开聊天记录查看器
function openChatRecordViewer() {
layoutStore.openChatRecordDrawer({})
layoutStore.openChatRecords({})
}
// Tab 配置
@@ -135,63 +136,64 @@ const filteredMemberCount = computed(() => {
:initial-time-state="initialTimeState"
@open-incremental-import="showIncrementalImportModal = true"
@open-member-management="showMemberManagementModal = true"
@open-chat-record="openChatRecordViewer"
@update:full-range="fullTimeRange = $event"
@update:available-years="availableYears = $event"
@time-range-initialized="handleTimeRangeInitialized"
/>
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
<SidePanelLayout :session-id="currentSessionId">
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
<GroupChatInsights
v-if="activeTab === 'insights'"
:key="'insights-' + currentSessionId"
:session-id="currentSessionId!"
:session="session"
:member-activity="memberActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="fullTimeRange"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
@select-tab="trackInsightTab"
/>
<RankingView
v-else-if="activeTab === 'ranking'"
:key="'ranking-' + currentSessionId"
:session-id="currentSessionId!"
:time-filter="timeFilter"
/>
<ChatExplorer
v-else-if="activeTab === 'ai-chat'"
:key="'ai-chat-' + currentSessionId"
:session-id="currentSessionId!"
:session-name="session.name"
chat-type="group"
:time-filter="timeFilter"
/>
<MemoryTab
v-else-if="activeTab === 'memory'"
:key="'memory-' + currentSessionId"
:session-id="currentSessionId!"
:session-name="session.name"
/>
<MoreTab
v-else-if="activeTab === 'more'"
:key="'more-' + currentSessionId"
:session-id="currentSessionId!"
chat-type="group"
/>
</Transition>
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
<GroupChatInsights
v-if="activeTab === 'insights'"
:key="'insights-' + currentSessionId"
:session-id="currentSessionId!"
:session="session"
:member-activity="memberActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="fullTimeRange"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
@select-tab="trackInsightTab"
/>
<RankingView
v-else-if="activeTab === 'ranking'"
:key="'ranking-' + currentSessionId"
:session-id="currentSessionId!"
:time-filter="timeFilter"
/>
<ChatExplorer
v-else-if="activeTab === 'ai-chat'"
:key="'ai-chat-' + currentSessionId"
:session-id="currentSessionId!"
:session-name="session.name"
chat-type="group"
:time-filter="timeFilter"
/>
<MemoryTab
v-else-if="activeTab === 'memory'"
:key="'memory-' + currentSessionId"
:session-id="currentSessionId!"
:session-name="session.name"
/>
<MoreTab
v-else-if="activeTab === 'more'"
:key="'more-' + currentSessionId"
:session-id="currentSessionId!"
chat-type="group"
/>
</Transition>
</div>
</div>
</div>
</SidePanelLayout>
<ActionToolsPanel
@open-incremental-import="showIncrementalImportModal = true"
@@ -267,6 +269,7 @@ const filteredMemberCount = computed(() => {
:session-id="currentSessionId"
:show-header="false"
chat-type="group"
@view-records="showMemberManagementModal = false"
@data-changed="
() => {
loadData()
@@ -4,14 +4,12 @@ import { useI18n } from 'vue-i18n'
import type { ContactItem } from '@openchatlab/shared-types'
const props = defineProps<{
selectedKey: string | null
contact: ContactItem | null
isLoading: boolean
isFriendActionLoading: boolean
}>()
const emit = defineEmits<{
clear: []
openSource: [source: ContactItem['sourceSessions'][number]]
viewSourceRecords: [source: ContactItem['sourceSessions'][number]]
markFriend: []
@@ -37,175 +35,132 @@ function avatarText(contact: ContactItem): string {
</script>
<template>
<Transition name="contact-detail-panel">
<aside
v-if="selectedKey"
class="flex h-full w-[420px] max-w-[80vw] shrink-0 flex-col border-l border-gray-200 bg-white dark:border-gray-800 dark:bg-page-dark"
style="-webkit-app-region: no-drag"
<div class="flex min-h-0 flex-1 flex-col" data-testid="contact-detail-content">
<div
v-if="isLoading"
class="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-gray-400 dark:text-gray-500"
>
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('contacts.detail.title') }}</h3>
<UButton
:aria-label="t('contacts.actions.clearSelection')"
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="sm"
@click="emit('clear')"
/>
</div>
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
<span>{{ t('common.loading') }}</span>
</div>
<div
v-if="isLoading"
class="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-gray-400 dark:text-gray-500"
>
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="contact" class="min-h-0 flex-1 overflow-y-auto">
<section class="px-5 py-5">
<div class="flex items-start gap-4">
<div class="relative shrink-0">
<img
v-if="contact.avatar"
:src="contact.avatar"
:alt="contact.displayName"
loading="lazy"
decoding="async"
class="h-16 w-16 rounded-2xl object-cover shadow-sm ring-2 ring-white dark:ring-page-dark"
/>
<div
v-else
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-gray-155 to-gray-200 text-lg font-bold text-gray-700 dark:from-gray-800 dark:to-page-dark dark:text-gray-200"
>
{{ avatarText(contact) }}
</div>
</div>
<div class="min-w-0 flex-1 pt-1">
<h2 class="truncate text-lg font-bold tracking-tight text-gray-900 dark:text-white">
{{ contact.displayName }}
</h2>
<p class="mt-1 truncate font-mono text-xs text-gray-400 dark:text-gray-500">
{{ contact.platform }} · {{ contact.platformId }}
</p>
<div v-if="visibleAliases.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="alias in visibleAliases.slice(0, 4)"
:key="alias"
class="max-w-full truncate rounded-md bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/5 dark:text-gray-400"
>
{{ alias }}
</span>
</div>
<div v-if="showFriendActions" class="mt-3 flex flex-wrap items-center gap-2">
<UButton
v-if="canMarkFriend"
icon="i-lucide-user-plus"
color="primary"
variant="soft"
size="xs"
:loading="isFriendActionLoading"
@click="emit('markFriend')"
>
{{ t('contacts.actions.markFriend') }}
</UButton>
<UButton
v-else-if="canUnmarkFriend"
icon="i-lucide-user-minus"
color="neutral"
variant="soft"
size="xs"
:loading="isFriendActionLoading"
@click="emit('unmarkFriend')"
>
{{ t('contacts.actions.unmarkFriend') }}
</UButton>
</div>
</div>
</div>
</section>
<section class="border-t border-gray-100 px-5 py-4 dark:border-white/5">
<h3 class="mb-3 text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ t('contacts.detail.sources') }}
</h3>
<div class="space-y-2.5">
<div v-else-if="contact" class="min-h-0 flex-1 overflow-y-auto">
<section class="px-5 py-5">
<div class="flex items-start gap-4">
<div class="relative shrink-0">
<img
v-if="contact.avatar"
:src="contact.avatar"
:alt="contact.displayName"
loading="lazy"
decoding="async"
class="h-16 w-16 rounded-2xl object-cover shadow-sm ring-2 ring-white dark:ring-page-dark"
/>
<div
v-for="source in contact.sourceSessions"
:key="source.id"
class="w-full rounded-2xl border border-gray-100 bg-white/40 px-3.5 py-3 text-left transition duration-300 hover:border-gray-200 dark:border-white/5 dark:bg-page-dark/10 dark:hover:border-white/10"
v-else
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-gray-155 to-gray-200 text-lg font-bold text-gray-700 dark:from-gray-800 dark:to-page-dark dark:text-gray-200"
>
<div class="flex items-center justify-between gap-2">
<span class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300">
{{ source.name }}
</span>
<span
class="flex items-center gap-1 text-[10px] font-bold text-gray-400 transition group-hover/item:text-pink-500 dark:text-gray-500"
>
{{
source.type === 'private'
? t('contacts.detail.sourceType.private')
: t('contacts.detail.sourceType.group')
}}
</span>
</div>
<div class="mt-1.5 truncate text-[11px] font-medium text-gray-400 dark:text-gray-500">
{{
source.privateMessageCount != null
? t('contacts.metrics.privateMessages', { count: source.privateMessageCount })
: t('contacts.metrics.groupSignals', { count: source.coOccurrenceCount ?? 0 })
}}
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<UButton
icon="i-lucide-arrow-up-right"
color="primary"
variant="soft"
size="xs"
@click="emit('openSource', source)"
>
{{ t('contacts.actions.openSourceSession') }}
</UButton>
<UButton
icon="i-lucide-messages-square"
color="primary"
variant="soft"
size="xs"
@click="emit('viewSourceRecords', source)"
>
{{ t('contacts.actions.viewSourceRecords') }}
</UButton>
</div>
{{ avatarText(contact) }}
</div>
</div>
</section>
</div>
</aside>
</Transition>
<div class="min-w-0 flex-1 pt-1">
<h2 class="truncate text-lg font-bold tracking-tight text-gray-900 dark:text-white">
{{ contact.displayName }}
</h2>
<p class="mt-1 truncate font-mono text-xs text-gray-400 dark:text-gray-500">
{{ contact.platform }} · {{ contact.platformId }}
</p>
<div v-if="visibleAliases.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="alias in visibleAliases.slice(0, 4)"
:key="alias"
class="max-w-full truncate rounded-md bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/5 dark:text-gray-400"
>
{{ alias }}
</span>
</div>
<div v-if="showFriendActions" class="mt-3 flex flex-wrap items-center gap-2">
<UButton
v-if="canMarkFriend"
icon="i-lucide-user-plus"
color="primary"
variant="soft"
size="xs"
:loading="isFriendActionLoading"
@click="emit('markFriend')"
>
{{ t('contacts.actions.markFriend') }}
</UButton>
<UButton
v-else-if="canUnmarkFriend"
icon="i-lucide-user-minus"
color="neutral"
variant="soft"
size="xs"
:loading="isFriendActionLoading"
@click="emit('unmarkFriend')"
>
{{ t('contacts.actions.unmarkFriend') }}
</UButton>
</div>
</div>
</div>
</section>
<section class="border-t border-gray-100 px-5 py-4 dark:border-white/5">
<h3 class="mb-3 text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ t('contacts.detail.sources') }}
</h3>
<div class="space-y-2.5">
<div
v-for="source in contact.sourceSessions"
:key="source.id"
class="w-full rounded-2xl border border-gray-100 bg-white/40 px-3.5 py-3 text-left transition duration-300 hover:border-gray-200 dark:border-white/5 dark:bg-page-dark/10 dark:hover:border-white/10"
>
<div class="flex items-center justify-between gap-2">
<span class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300">
{{ source.name }}
</span>
<span
class="flex items-center gap-1 text-[10px] font-bold text-gray-400 transition group-hover/item:text-pink-500 dark:text-gray-500"
>
{{
source.type === 'private'
? t('contacts.detail.sourceType.private')
: t('contacts.detail.sourceType.group')
}}
</span>
</div>
<div class="mt-1.5 truncate text-[11px] font-medium text-gray-400 dark:text-gray-500">
{{
source.privateMessageCount != null
? t('contacts.metrics.privateMessages', { count: source.privateMessageCount })
: t('contacts.metrics.groupSignals', { count: source.coOccurrenceCount ?? 0 })
}}
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<UButton
icon="i-lucide-arrow-up-right"
color="primary"
variant="soft"
size="xs"
@click="emit('openSource', source)"
>
{{ t('contacts.actions.openSourceSession') }}
</UButton>
<UButton
icon="i-lucide-messages-square"
color="primary"
variant="soft"
size="xs"
@click="emit('viewSourceRecords', source)"
>
{{ t('contacts.actions.viewSourceRecords') }}
</UButton>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.contact-detail-panel-enter-active,
.contact-detail-panel-leave-active {
overflow: hidden;
transition:
width 0.22s ease,
opacity 0.18s ease,
transform 0.22s ease;
}
.contact-detail-panel-enter-from,
.contact-detail-panel-leave-to {
width: 0 !important;
opacity: 0;
transform: translateX(16px);
}
.contact-detail-panel-enter-to,
.contact-detail-panel-leave-from {
width: 420px;
opacity: 1;
transform: translateX(0);
}
</style>
+17 -14
View File
@@ -18,6 +18,7 @@ import { LoadingState, ThemeCard } from '@/components/UI'
import { SectionTabs } from '@/components/navigation'
import { usePeoplePageHeader } from '../people-page-header'
import ContactDetailPanel from './components/ContactDetailPanel.vue'
import SidePanelLayout from '@/components/layout/SidePanelLayout.vue'
import ContactsStatusBlocks from './components/ContactsStatusBlocks.vue'
import {
resolveFriendActionScrollTop,
@@ -616,6 +617,7 @@ function clearCompletedContactsTaskProgress() {
async function selectContact(contact: ContactListItem) {
selectedKey.value = contact.key
layoutStore.openContactPanel()
await loadSelectedContactDetail(contact.key)
}
@@ -730,6 +732,7 @@ async function refreshContactsPageMetadata() {
function clearSelectedContact() {
selectedKey.value = null
selectedContact.value = null
layoutStore.clearContactPanel()
isDetailLoading.value = false
}
@@ -801,7 +804,7 @@ function openSourceSession(source: ContactItem['sourceSessions'][number]) {
}
function viewSourceSessionRecords(source: ContactItem['sourceSessions'][number]) {
layoutStore.openChatRecordDrawer({ sessionId: source.id })
layoutStore.openChatRecords({ sessionId: source.id })
}
function handleListScroll(event: Event) {
@@ -887,7 +890,7 @@ function getGroupSectionScrollTop(): number | null {
</template>
</SectionTabs>
<div class="flex min-h-0 flex-1 overflow-hidden">
<SidePanelLayout>
<main class="min-h-0 min-w-0 flex-1 overflow-hidden">
<div class="flex h-full min-h-0 w-full flex-col gap-6 px-6 pb-3 pt-4">
<ContactsStatusBlocks
@@ -1134,18 +1137,18 @@ function getGroupSectionScrollTop(): number | null {
</div>
</main>
<ContactDetailPanel
:selected-key="selectedKey"
:contact="selectedContact"
:is-loading="isDetailLoading"
:is-friend-action-loading="isFriendActionSaving"
@clear="clearSelectedContact"
@open-source="openSourceSession"
@view-source-records="viewSourceSessionRecords"
@mark-friend="markSelectedContactAsFriend"
@unmark-friend="unmarkSelectedContactAsFriend"
/>
</div>
<template #contact>
<ContactDetailPanel
:contact="selectedContact"
:is-loading="isDetailLoading"
:is-friend-action-loading="isFriendActionSaving"
@open-source="openSourceSession"
@view-source-records="viewSourceSessionRecords"
@mark-friend="markSelectedContactAsFriend"
@unmark-friend="unmarkSelectedContactAsFriend"
/>
</template>
</SidePanelLayout>
</div>
</template>
+48 -45
View File
@@ -9,6 +9,7 @@ import { ChatExplorer } from '@/components/AIChat'
import PrivateChatInsights from './components/insights/PrivateChatInsights.vue'
import MemberList from '@/components/common/member/MemberList.vue'
import SessionAnalysisHeader from '@/components/layout/session/SessionAnalysisHeader.vue'
import SidePanelLayout from '@/components/layout/SidePanelLayout.vue'
import SemanticIndexSessionModal from '@/components/analysis/SemanticIndexSessionModal.vue'
import OwnerPromptModal from '@/components/analysis/member/OwnerPromptModal.vue'
import IncrementalImportModal from '@/components/analysis/IncrementalImportModal.vue'
@@ -46,7 +47,7 @@ const showMemberManagementModal = ref(false)
// 打开聊天记录查看器
function openChatRecordViewer() {
layoutStore.openChatRecordDrawer({})
layoutStore.openChatRecords({})
}
// Tab 配置 - 私聊包含洞察、AI 对话、记忆和更多工具
@@ -156,56 +157,57 @@ const otherMemberAvatar = computed(() => {
:initial-time-state="initialTimeState"
@open-incremental-import="showIncrementalImportModal = true"
@open-member-management="showMemberManagementModal = true"
@open-chat-record="openChatRecordViewer"
@update:full-range="fullTimeRange = $event"
@time-range-initialized="handleTimeRangeInitialized"
/>
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
<SidePanelLayout :session-id="currentSessionId">
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
<PrivateChatInsights
v-if="activeTab === 'insights'"
:key="'insights-' + currentSessionId"
:session-id="currentSessionId!"
:session="session"
:member-activity="memberActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="fullTimeRange"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
@select-tab="trackInsightTab"
/>
<ChatExplorer
v-else-if="activeTab === 'ai-chat'"
:key="'ai-chat-' + currentSessionId"
:session-id="currentSessionId!"
:session-name="session.name"
chat-type="private"
:time-filter="timeFilter"
/>
<MemoryTab
v-else-if="activeTab === 'memory'"
:key="'memory-' + currentSessionId"
:session-id="currentSessionId!"
:session-name="session.name"
/>
<MoreTab
v-else-if="activeTab === 'more'"
:key="'more-' + currentSessionId"
:session-id="currentSessionId!"
chat-type="private"
/>
</Transition>
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
<PrivateChatInsights
v-if="activeTab === 'insights'"
:key="'insights-' + currentSessionId"
:session-id="currentSessionId!"
:session="session"
:member-activity="memberActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="fullTimeRange"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
@select-tab="trackInsightTab"
/>
<ChatExplorer
v-else-if="activeTab === 'ai-chat'"
:key="'ai-chat-' + currentSessionId"
:session-id="currentSessionId!"
:session-name="session.name"
chat-type="private"
:time-filter="timeFilter"
/>
<MemoryTab
v-else-if="activeTab === 'memory'"
:key="'memory-' + currentSessionId"
:session-id="currentSessionId!"
:session-name="session.name"
/>
<MoreTab
v-else-if="activeTab === 'more'"
:key="'more-' + currentSessionId"
:session-id="currentSessionId!"
chat-type="private"
/>
</Transition>
</div>
</div>
</div>
</SidePanelLayout>
<ActionToolsPanel
@open-incremental-import="showIncrementalImportModal = true"
@@ -277,6 +279,7 @@ const otherMemberAvatar = computed(() => {
:session-id="currentSessionId"
:show-header="false"
chat-type="private"
@view-records="showMemberManagementModal = false"
@data-changed="invalidateAnalysisData"
/>
</div>
+45
View File
@@ -41,3 +41,48 @@ test('layout persistence upgrades older state and restores a saved drawer width'
assert.equal(restoredStore.chatRecordDrawerWidth, 936)
})
test('collapsing the side panel preserves the record target and reopening restores it', async () => {
const pinia = createPersistedPinia(createMemoryStorage())
const { useLayoutStore } = await import('./layout')
const store = useLayoutStore(pinia)
store.setSidePanelContext('session-a')
store.openChatRecords({ scrollToMessageId: 42, keywords: ['hello'] })
store.closeSidePanel()
assert.equal(store.sidePanelOpen, false)
assert.deepEqual(store.chatRecordQuery, { sessionId: 'session-a', scrollToMessageId: 42, keywords: ['hello'] })
store.toggleSidePanel()
assert.equal(store.sidePanelOpen, true)
assert.equal(store.sidePanelView, 'records')
})
test('contact source records share one panel and can return to the selected contact', async () => {
const pinia = createPersistedPinia(createMemoryStorage())
const { useLayoutStore } = await import('./layout')
const store = useLayoutStore(pinia)
store.setSidePanelContext(null)
store.openContactPanel()
store.openChatRecords({ sessionId: 'contact-source', scrollToMessageId: 7 })
assert.equal(store.sidePanelView, 'records')
assert.equal(store.sidePanelHasContact, true)
store.returnToContactPanel()
assert.equal(store.sidePanelView, 'contact')
assert.equal(store.sidePanelOpen, true)
})
test('changing page context clears stale records and opens only the new session', async () => {
const pinia = createPersistedPinia(createMemoryStorage())
const { useLayoutStore } = await import('./layout')
const store = useLayoutStore(pinia)
store.setSidePanelContext('session-a')
store.openChatRecords({ scrollToMessageId: 42 })
store.setSidePanelContext('session-b')
assert.equal(store.sidePanelOpen, false)
assert.equal(store.chatRecordQuery, null)
store.toggleSidePanel()
assert.deepEqual(store.chatRecordQuery, { sessionId: 'session-b' })
store.setSidePanelContext(null)
assert.equal(store.canOpenSidePanel, false)
store.openChatRecords({ scrollToMessageId: 42 })
assert.equal(store.sidePanelOpen, false)
})
+65 -18
View File
@@ -14,9 +14,15 @@ export const useLayoutStore = defineStore(
const isAIChatSidebarCollapsed = ref(false)
const showScreenCaptureModal = ref(false)
const screenCaptureImage = ref<string | null>(null)
const showChatRecordDrawer = ref(false)
const sidePanelOpen = ref(false)
const sidePanelView = ref<'records' | 'contact' | null>(null)
const sidePanelSessionId = ref<string | null>(null)
const sidePanelHasContact = ref(false)
const chatRecordQuery = ref<ChatRecordQuery | null>(null)
// Keep the existing persisted preference when replacing the drawer shell.
const chatRecordDrawerWidth = ref(750)
const contactPanelWidth = ref(420)
const canOpenSidePanel = computed(() => Boolean(sidePanelView.value || sidePanelSessionId.value))
const isToolsPanelLocked = ref(false)
const isToolsPanelMini = ref(false)
@@ -60,22 +66,53 @@ export const useLayoutStore = defineStore(
}, 300)
}
/**
* 打开聊天记录抽屉并设置查询参数
*/
function openChatRecordDrawer(query: ChatRecordQuery) {
chatRecordQuery.value = query
showChatRecordDrawer.value = true
function setSidePanelContext(sessionId: string | null) {
sidePanelOpen.value = false
sidePanelView.value = null
sidePanelHasContact.value = false
chatRecordQuery.value = null
sidePanelSessionId.value = sessionId
}
/**
* 关闭聊天记录抽屉并重置查询
*/
function closeChatRecordDrawer() {
showChatRecordDrawer.value = false
setTimeout(() => {
chatRecordQuery.value = null
}, 300)
function openChatRecords(query: ChatRecordQuery) {
const sessionId = query.sessionId || sidePanelSessionId.value
if (!sessionId) return
chatRecordQuery.value = { ...query, sessionId }
sidePanelView.value = 'records'
sidePanelOpen.value = true
}
function openContactPanel() {
sidePanelHasContact.value = true
sidePanelView.value = 'contact'
sidePanelOpen.value = true
chatRecordQuery.value = null
}
function returnToContactPanel() {
if (!sidePanelHasContact.value) return
sidePanelView.value = 'contact'
sidePanelOpen.value = true
}
function clearContactPanel() {
sidePanelHasContact.value = false
if (sidePanelView.value !== 'contact') return
sidePanelView.value = null
sidePanelOpen.value = false
}
function closeSidePanel() {
sidePanelOpen.value = false
}
function toggleSidePanel() {
if (!canOpenSidePanel.value) return
if (!sidePanelView.value) {
openChatRecords({})
return
}
sidePanelOpen.value = !sidePanelOpen.value
}
function toggleToolsPanelLock() {
@@ -116,9 +153,13 @@ export const useLayoutStore = defineStore(
isToolsPanelOpen,
showScreenCaptureModal,
screenCaptureImage,
showChatRecordDrawer,
sidePanelOpen,
sidePanelView,
sidePanelHasContact,
canOpenSidePanel,
chatRecordQuery,
chatRecordDrawerWidth,
contactPanelWidth,
showSettings,
settingsTab,
settingsSubTab,
@@ -129,8 +170,13 @@ export const useLayoutStore = defineStore(
toggleToolsPanelMini,
openScreenCaptureModal,
closeScreenCaptureModal,
openChatRecordDrawer,
closeChatRecordDrawer,
setSidePanelContext,
openChatRecords,
openContactPanel,
returnToContactPanel,
clearContactPanel,
closeSidePanel,
toggleSidePanel,
openSettings,
closeSettings,
}
@@ -145,6 +191,7 @@ export const useLayoutStore = defineStore(
'isToolsPanelMini',
'toolsPanelPosition',
'chatRecordDrawerWidth',
'contactPanelWidth',
],
storage: localStorage,
},