feat(memory): add linked chat records view

This commit is contained in:
digua committed 2026-08-09 18:44:40 +08:00
1 parent 3b8fec78cc
commit ed7360f8ac
20 files changed
+401 -275

No files matched your search

@@ -107,6 +107,28 @@ describe('generateSessionSummary', () => {
assert.equal(result.summary, 'Mock summary result')
})
it('does not write generated summary plaintext to logs', async () => {
const messages: SummaryMessage[] = Array.from({ length: 5 }, (_, index) => ({
senderName: `User${index}`,
content: `Message content number ${index} with enough text`,
}))
const logMessages: string[] = []
const deps = mockDeps(messages)
deps.logger = {
info: (_category, message) => logMessages.push(message),
error: (_category, message) => logMessages.push(message),
}
const result = await generateSessionSummary(deps, 7)
assert.equal(result.success, true)
assert.ok(logMessages.some((message) => message.includes('segment 7')))
assert.equal(
logMessages.some((message) => message.includes('Mock summary result')),
false
)
})
const errorCases: Array<{ name: string; messages: SummaryMessage[] | null }> = [
{ name: 'returns error when too few messages', messages: [{ senderName: 'A', content: 'hi' }] },
{ name: 'returns error when session not found', messages: null },
@@ -308,7 +308,8 @@ export async function generateSessionSummary(
summary = postProcessSummary(summary, lengthLimit)
deps.saveSummary(segmentId, summary)
log?.info('Summary', `Summary generated: "${summary.slice(0, 50)}..."`)
// 摘要属于用户聊天内容,日志只记录定位故障所需的元数据,避免持久化明文。
log?.info('Summary', `Summary generated for segment ${segmentId} (${summary.length} characters)`)
return { success: true, summary }
} catch (error) {
log?.error('Summary', 'Summary generation failed', error)
+17 -18
View File
@@ -1,33 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { SectionTabs } from '@/components/navigation'
import { ChatRecordWorkspace } from '@/components/common/ChatRecord'
const { t, te } = useI18n()
const { t } = useI18n()
defineProps<{
sessionId: string
sessionName?: string
}>()
const joinLink = computed(() => (te('analysis.memory.joinLink') ? t('analysis.memory.joinLink') : ''))
const activeSubTab = ref('records')
const subTabs = computed(() => [
{
id: 'records',
label: t('analysis.memory.tabs.records'),
icon: 'i-heroicons-chat-bubble-left-right',
},
])
</script>
<template>
<div class="flex h-full items-center justify-center">
<div class="max-w-sm text-center">
<UIcon name="i-heroicons-light-bulb" class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
<p class="mt-3 text-sm text-gray-400 dark:text-gray-500">
{{ t('analysis.memory.comingSoon') }}
</p>
<a
v-if="joinLink"
:href="joinLink"
target="_blank"
rel="noopener noreferrer"
class="mt-2 inline-block text-sm text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ joinLink }}
</a>
<div class="flex h-full min-h-0 flex-col">
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="memoryTab" />
<div class="min-h-0 flex-1 overflow-hidden">
<ChatRecordWorkspace v-if="activeSubTab === 'records'" :key="sessionId" :session-id="sessionId" mode="page" />
</div>
</div>
</template>
@@ -1,25 +1,15 @@
<script setup lang="ts">
/**
* 聊天记录查看器 Drawer
* 主组件,组合筛选面板、消息列表、会话时间线等子组件
* 保留快速查看入口,完整的记录编排由 ChatRecordWorkspace 复用。
*/
import { computed, ref, toRaw, nextTick, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import FilterPanel from './FilterPanel.vue'
import MessageList from './MessageList.vue'
import SessionTimeline from './SessionTimeline.vue'
import type { ChatRecordQuery } from './types'
import ChatRecordWorkspace from './ChatRecordWorkspace.vue'
import { useLayoutStore } from '@/stores/layout'
import { useSessionStore } from '@/stores/session'
import { useSessionIndexService } from '@/services'
import { storeToRefs } from 'pinia'
import { preserveChatRecordSessionId, resolveChatRecordSessionId } from './query-session'
import { watchLazyOverlayVisibility } from '../lazy-overlay-visibility'
const { t } = useI18n()
const layoutStore = useLayoutStore()
const sessionStore = useSessionStore()
const { currentSessionId } = storeToRefs(sessionStore)
// 平台检测
const isWindows = ref(false)
@@ -27,159 +17,6 @@ const isWindows = ref(false)
onMounted(() => {
isWindows.value = navigator.platform.toLowerCase().includes('win')
})
// 消息列表组件引用
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
// 本地查询条件(可编辑的副本)
const localQuery = ref<ChatRecordQuery>({})
const effectiveSessionId = computed(() => resolveChatRecordSessionId(localQuery.value, currentSessionId.value))
// 消息数量
const messageCount = ref(0)
// 时间线折叠状态
const timelineCollapsed = ref(false)
// 当前激活的会话 ID(用于联动高亮)
const activeSessionId = ref<number | undefined>(undefined)
// 会话列表缓存(用于根据消息 ID 查找所属会话)
const sessionsCache = ref<Array<{ id: number; startTs: number; endTs: number; firstMessageId: number }>>([])
// 匹配消息所属的会话 ID 集合(用于关键词筛选时联动时间线)
const matchedSessionIds = ref<Set<number> | undefined>(undefined)
// 应用筛选
function handleApplyFilter(query: ChatRecordQuery) {
localQuery.value = preserveChatRecordSessionId(query, localQuery.value)
}
// 重置筛选
function handleResetFilter() {
localQuery.value = preserveChatRecordSessionId({}, localQuery.value)
matchedSessionIds.value = undefined
}
// 处理消息数量变化
function handleCountChange(count: number) {
messageCount.value = count
}
// 处理消息时间戳变化(用于计算匹配的会话 ID)
function handleMessageTimestampsChange(timestamps: number[]) {
// 只有在关键词筛选时才计算匹配的会话
if (!localQuery.value.keywords?.length || !sessionsCache.value.length) {
matchedSessionIds.value = undefined
return
}
// 根据消息时间戳找出对应的会话 ID
const sessionIds = new Set<number>()
for (const ts of timestamps) {
for (const session of sessionsCache.value) {
if (ts >= session.startTs && ts <= session.endTs) {
sessionIds.add(session.id)
break
}
}
}
matchedSessionIds.value = sessionIds.size > 0 ? sessionIds : undefined
}
// 处理当前可见消息变化(用于联动高亮时间线)
function handleVisibleMessageChange(payload: { id: number; timestamp: number }) {
if (!sessionsCache.value.length) return
// 优先按时间范围匹配所属会话,避免增量导入后 messageId 与时间顺序不一致导致联动错误。
let targetSession: { id: number } | undefined
for (const session of sessionsCache.value) {
if (payload.timestamp >= session.startTs && payload.timestamp <= session.endTs) {
targetSession = session
break
}
}
// 兜底:若时间匹配失败,再退回旧的 messageId 规则,保证历史行为可用。
if (!targetSession) {
for (const session of sessionsCache.value) {
if (session.firstMessageId <= payload.id) {
targetSession = session
} else {
break
}
}
}
if (targetSession && targetSession.id !== activeSessionId.value) {
activeSessionId.value = targetSession.id
}
}
// 处理时间线会话选择
function handleSessionSelect(_sessionId: number, firstMessageId: number) {
activeSessionId.value = _sessionId
// 检查目标消息是否在当前已加载的消息范围内
// 如果不在,则通过设置 scrollToMessageId 触发重新加载
// 这样 MessageList 会以目标消息为中心加载前后各 50 条
localQuery.value = {
...localQuery.value,
scrollToMessageId: firstMessageId,
}
}
// 处理跳转到消息(查看上下文)
function handleJumpToMessage(messageId: number) {
// 清空筛选条件,只保留 scrollToMessageId
localQuery.value = preserveChatRecordSessionId({ scrollToMessageId: messageId }, localQuery.value)
}
// 加载会话列表缓存
async function loadSessionsCache() {
const sessionId = effectiveSessionId.value
if (!sessionId) return
try {
const sessions = await useSessionIndexService().getSessions(sessionId)
sessionsCache.value = sessions.map((s) => ({
id: s.id,
startTs: s.startTs,
endTs: s.endTs,
firstMessageId: s.firstMessageId,
}))
} catch {
sessionsCache.value = []
}
}
// 监听 Drawer 打开
watchLazyOverlayVisibility(
() => layoutStore.showChatRecordDrawer,
async (isOpen) => {
if (isOpen) {
// 复制查询参数到本地
const query = toRaw(layoutStore.chatRecordQuery)
localQuery.value = query ? { ...query } : {}
// 加载会话缓存
await loadSessionsCache()
// 设置初始激活会话为最后一个(最新的)
if (sessionsCache.value.length > 0) {
activeSessionId.value = sessionsCache.value[sessionsCache.value.length - 1].id
}
// 等待 DOM 更新后主动触发加载
await nextTick()
messageListRef.value?.refresh()
} else {
// 关闭时清理
localQuery.value = {}
messageCount.value = 0
activeSessionId.value = undefined
sessionsCache.value = []
}
}
)
</script>
<template>
@@ -211,54 +48,13 @@ watchLazyOverlayVisibility(
/>
</div>
<!-- 筛选面板 -->
<FilterPanel
:query="localQuery"
:session-id="effectiveSessionId || undefined"
@apply="handleApplyFilter"
@reset="handleResetFilter"
<ChatRecordWorkspace
class="min-h-0 flex-1"
:initial-query="layoutStore.chatRecordQuery"
:active="layoutStore.showChatRecordDrawer"
mode="drawer"
/>
<!-- 主内容区:时间线 + 消息列表 -->
<div class="flex min-h-0 flex-1">
<!-- 会话时间线 -->
<SessionTimeline
v-if="effectiveSessionId"
v-model:collapsed="timelineCollapsed"
:session-id="effectiveSessionId"
:active-session-id="activeSessionId"
:filter-start-ts="localQuery.startTs"
:filter-end-ts="localQuery.endTs"
:filter-matched-session-ids="matchedSessionIds"
@select="handleSessionSelect"
/>
<!-- 消息列表容器 -->
<div class="min-h-0 min-w-0 flex-1">
<MessageList
ref="messageListRef"
:query="localQuery"
@count-change="handleCountChange"
@visible-message-change="handleVisibleMessageChange"
@jump-to-message="handleJumpToMessage"
@message-timestamps-change="handleMessageTimestampsChange"
/>
</div>
</div>
<!-- 底部统计 -->
<div v-if="messageCount > 0" class="shrink-0 border-t border-gray-200 px-4 py-2 dark:border-gray-800">
<span class="text-xs text-gray-500">{{ t('records.drawer.loadedCount', { count: messageCount }) }}</span>
</div>
</div>
</template>
</UDrawer>
</template>
<style scoped>
.chat-record-drawer-content :deep(.chat-record-message-content),
.chat-record-drawer-content :deep(.chat-record-message-content *) {
-webkit-user-select: text;
user-select: text;
}
</style>
@@ -0,0 +1,264 @@
<script setup lang="ts">
/**
* 聊天记录工作区
*
* Drawer 和“记忆 → 记录”共用同一套连续消息流、摘要时间线与左右滚动联动。
*/
import { computed, nextTick, ref, toRaw, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useToast } from '@/composables/useToast'
import { useSessionIndexService, type ChatSessionItem } from '@/services'
import { useSessionStore } from '@/stores/session'
import FilterPanel from './FilterPanel.vue'
import MessageList from './MessageList.vue'
import SessionTimeline from './SessionTimeline.vue'
import type { ChatRecordQuery } from './types'
import { preserveChatRecordSessionId, resolveChatRecordSessionId, scopeChatRecordQueryToSession } from './query-session'
const props = withDefaults(
defineProps<{
sessionId?: string | null
initialQuery?: ChatRecordQuery | null
active?: boolean
mode?: 'drawer' | 'page'
}>(),
{
initialQuery: null,
active: true,
mode: 'drawer',
}
)
const { t } = useI18n()
const toast = useToast()
const sessionStore = useSessionStore()
const { currentSessionId } = storeToRefs(sessionStore)
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
const localQuery = ref<ChatRecordQuery>({})
const timelineCollapsed = ref(false)
const activeSessionId = ref<number | undefined>()
const sessionsCache = ref<ChatSessionItem[]>([])
const matchedSessionIds = ref<Set<number> | undefined>()
const indexState = ref<'loading' | 'ready' | 'missing' | 'error'>('loading')
const isGeneratingIndex = ref(false)
const timelineVersion = ref(0)
let initializationVersion = 0
const isPageMode = computed(() => props.mode === 'page')
const fallbackSessionId = computed(() => props.sessionId || currentSessionId.value)
const effectiveSessionId = computed(() => resolveChatRecordSessionId(localQuery.value, fallbackSessionId.value))
function resetWorkspace() {
localQuery.value = {}
activeSessionId.value = undefined
sessionsCache.value = []
matchedSessionIds.value = undefined
indexState.value = 'loading'
}
async function initializeWorkspace() {
const version = ++initializationVersion
if (!props.active) {
resetWorkspace()
return
}
const initialQuery = props.initialQuery ? { ...toRaw(props.initialQuery) } : {}
const requestedSessionId = resolveChatRecordSessionId(initialQuery, fallbackSessionId.value)
localQuery.value = requestedSessionId ? scopeChatRecordQueryToSession(initialQuery, requestedSessionId) : initialQuery
activeSessionId.value = undefined
sessionsCache.value = []
matchedSessionIds.value = undefined
indexState.value = 'loading'
if (!requestedSessionId) {
indexState.value = 'missing'
return
}
try {
const service = useSessionIndexService()
const [stats, sessions] = await Promise.all([
service.getStats(requestedSessionId),
service.getSessions(requestedSessionId),
])
if (version !== initializationVersion) return
sessionsCache.value = sessions
indexState.value = stats.hasIndex || sessions.length > 0 ? 'ready' : 'missing'
activeSessionId.value = sessions[sessions.length - 1]?.id
} catch (error) {
if (version !== initializationVersion) return
console.error('Failed to load chat record sessions:', error)
indexState.value = 'error'
}
await nextTick()
if (version === initializationVersion) {
messageListRef.value?.refresh()
}
}
function handleApplyFilter(query: ChatRecordQuery) {
localQuery.value = preserveChatRecordSessionId(query, localQuery.value)
}
function handleResetFilter() {
localQuery.value = preserveChatRecordSessionId({}, localQuery.value)
matchedSessionIds.value = undefined
}
function handleMessageTimestampsChange(timestamps: number[]) {
if (!localQuery.value.keywords?.length || !sessionsCache.value.length) {
matchedSessionIds.value = undefined
return
}
const sessionIds = new Set<number>()
for (const ts of timestamps) {
const session = sessionsCache.value.find((item) => ts >= item.startTs && ts <= item.endTs)
if (session) sessionIds.add(session.id)
}
matchedSessionIds.value = sessionIds.size > 0 ? sessionIds : undefined
}
function handleVisibleMessageChange(payload: { id: number; timestamp: number }) {
if (!sessionsCache.value.length) return
let targetSession = sessionsCache.value.find(
(session) => payload.timestamp >= session.startTs && payload.timestamp <= session.endTs
)
if (!targetSession) {
for (const session of sessionsCache.value) {
if (session.firstMessageId <= payload.id) targetSession = session
else break
}
}
if (targetSession && targetSession.id !== activeSessionId.value) {
activeSessionId.value = targetSession.id
}
}
function handleSessionSelect(segmentId: number, firstMessageId: number) {
activeSessionId.value = segmentId
localQuery.value = { ...localQuery.value, scrollToMessageId: firstMessageId }
}
function handleJumpToMessage(messageId: number) {
localQuery.value = preserveChatRecordSessionId({ scrollToMessageId: messageId }, localQuery.value)
}
function handleSummaryUpdated(updatedSession: ChatSessionItem) {
const index = sessionsCache.value.findIndex((session) => session.id === updatedSession.id)
if (index !== -1) sessionsCache.value[index] = updatedSession
}
function handleSessionsUpdated(sessions: ChatSessionItem[]) {
sessionsCache.value = sessions
indexState.value = sessions.length > 0 ? 'ready' : 'missing'
}
async function generateIndex() {
const sessionId = effectiveSessionId.value
if (!sessionId || isGeneratingIndex.value) return
isGeneratingIndex.value = true
try {
const count = await useSessionIndexService().generate(sessionId)
toast.success(t('records.workspace.indexGenerated', { count }))
timelineVersion.value++
await initializeWorkspace()
} catch (error) {
toast.fail(t('records.workspace.indexGenerationFailed'), { description: String(error) })
} finally {
isGeneratingIndex.value = false
}
}
watch([() => props.active, fallbackSessionId, () => props.initialQuery], () => initializeWorkspace(), {
immediate: true,
})
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden bg-white dark:bg-page-dark"
data-testid="chat-record-workspace"
>
<FilterPanel
:query="localQuery"
:session-id="effectiveSessionId || undefined"
@apply="handleApplyFilter"
@reset="handleResetFilter"
/>
<div
v-if="isPageMode && (indexState === 'missing' || indexState === 'error')"
class="flex shrink-0 items-start justify-between gap-4 border-b border-amber-200/70 bg-amber-50/80 px-4 py-3 dark:border-amber-800/50 dark:bg-amber-950/20"
>
<div class="flex min-w-0 gap-3">
<UIcon name="i-heroicons-queue-list" class="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div class="min-w-0">
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">
{{ t('records.workspace.indexMissingTitle') }}
</p>
<p class="mt-0.5 text-xs leading-relaxed text-amber-700 dark:text-amber-300">
{{ t('records.workspace.indexMissingDescription') }}
</p>
</div>
</div>
<UButton
class="shrink-0"
color="warning"
variant="soft"
size="sm"
icon="i-heroicons-bolt"
:loading="isGeneratingIndex"
@click="generateIndex"
>
{{ t('records.workspace.generateIndex') }}
</UButton>
</div>
<div class="flex min-h-0 flex-1">
<SessionTimeline
v-if="effectiveSessionId"
:key="`${effectiveSessionId}-${timelineVersion}`"
v-model:collapsed="timelineCollapsed"
:session-id="effectiveSessionId"
:active-session-id="activeSessionId"
:filter-start-ts="localQuery.startTs"
:filter-end-ts="localQuery.endTs"
:filter-matched-session-ids="matchedSessionIds"
@select="handleSessionSelect"
@summary-updated="handleSummaryUpdated"
@sessions-updated="handleSessionsUpdated"
/>
<div class="min-h-0 min-w-0 flex-1">
<MessageList
ref="messageListRef"
:query="localQuery"
@visible-message-change="handleVisibleMessageChange"
@jump-to-message="handleJumpToMessage"
@message-timestamps-change="handleMessageTimestampsChange"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
[data-testid='chat-record-workspace'] :deep(.chat-record-message-content),
[data-testid='chat-record-workspace'] :deep(.chat-record-message-content *) {
-webkit-user-select: text;
user-select: text;
}
</style>
@@ -124,42 +124,37 @@ function resetFilter() {
</script>
<template>
<div class="border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<!-- 第一行:消息ID、成员、时间范围 -->
<div class="flex items-center gap-3">
<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">
<UInput
v-model="formData.messageId"
type="number"
:placeholder="t('records.filter.messageId')"
size="sm"
class="w-24"
class="w-20 shrink-0"
/>
<MemberSearchSelect
v-model="formData.memberId"
v-model:member-name="formData.memberName"
:session-id="sessionId"
:placeholder="t('records.filter.memberPlaceholder')"
width-class="w-36"
width-class="w-28"
@select="applyFilter"
@clear="applyFilter"
/>
<div class="flex items-center gap-2">
<DatePicker v-model="formData.startDate" :placeholder="t('records.filter.startDate')" />
<div class="flex shrink-0 items-center gap-1.5">
<DatePicker v-model="formData.startDate" :placeholder="t('records.filter.startDate')" width-class="w-28" />
<span class="text-xs text-gray-400">~</span>
<DatePicker v-model="formData.endDate" :placeholder="t('records.filter.endDate')" />
<DatePicker v-model="formData.endDate" :placeholder="t('records.filter.endDate')" width-class="w-28" />
</div>
</div>
<!-- 第二行:关键词和操作按钮 -->
<div class="mt-2 flex items-center gap-3">
<UInput
v-model="formData.keywords"
:placeholder="t('records.filter.keywordsPlaceholder')"
size="sm"
class="flex-1"
class="min-w-24 max-w-[260px] flex-1"
@keydown="handleKeywordsKeydown"
/>
<div class="flex gap-2">
<div class="flex shrink-0 gap-1">
<UButton color="neutral" variant="ghost" size="sm" @click="resetFilter">
{{ t('records.filter.reset') }}
</UButton>
@@ -35,6 +35,10 @@ const emit = defineEmits<{
(e: 'select', sessionId: number, firstMessageId: number): void
/** 折叠状态变化 */
(e: 'update:collapsed', value: boolean): void
/** 单条摘要更新,供共享工作区同步会话缓存 */
(e: 'summary-updated', session: ChatSessionItem): void
/** 批量生成或重新加载后同步完整会话列表 */
(e: 'sessions-updated', sessions: ChatSessionItem[]): void
}>()
const { t, locale } = useI18n()
@@ -177,13 +181,13 @@ const totalSize = computed(() => virtualizer.value.getTotalSize())
// 格式化日期
function formatDate(ts: number): string {
const date = new Date(ts * 1000)
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
return date.toLocaleDateString(locale.value, { month: '2-digit', day: '2-digit' })
}
// 格式化时间
function formatTime(ts: number): string {
const date = new Date(ts * 1000)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
return date.toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit' })
}
// 获取日期键
@@ -200,6 +204,7 @@ async function loadSessions() {
try {
const data = await useSessionIndexService().getSessions(props.sessionId)
allSessions.value = data
emit('sessions-updated', data)
} catch (error) {
console.error('加载会话列表失败:', error)
} finally {
@@ -243,18 +248,11 @@ async function generateSummary(session: ChatSessionItem, event: Event) {
event.stopPropagation() // 防止触发选择会话
event.preventDefault()
console.log('[SessionTimeline] 开始生成摘要:', session.id, props.sessionId)
if (generatingSummaryIds.value.has(session.id)) {
console.log('[SessionTimeline] 已在生成中,跳过')
return
}
if (generatingSummaryIds.value.has(session.id)) return
generatingSummaryIds.value.add(session.id)
console.log('[SessionTimeline] 正在生成中的会话:', Array.from(generatingSummaryIds.value))
try {
console.log('[SessionTimeline] 调用 IPC...')
const result = await useSessionIndexService().generateSummary(
props.sessionId,
session.id,
@@ -262,12 +260,13 @@ async function generateSummary(session: ChatSessionItem, event: Event) {
false,
getSummaryStrategy()
)
console.log('[SessionTimeline] IPC 返回:', result)
if (result.success && result.summary) {
const index = allSessions.value.findIndex((s) => s.id === session.id)
if (index !== -1) {
allSessions.value[index] = { ...allSessions.value[index], summary: result.summary }
const updatedSession = { ...allSessions.value[index], summary: result.summary }
allSessions.value[index] = updatedSession
emit('summary-updated', updatedSession)
}
} else {
toast.fail(t('records.summaryFailed', '摘要生成失败'), {
@@ -278,10 +277,13 @@ async function generateSummary(session: ChatSessionItem, event: Event) {
toast.fail(t('records.summaryFailed', '摘要生成失败'), { description: String(error) })
} finally {
generatingSummaryIds.value.delete(session.id)
console.log('[SessionTimeline] 生成完成')
}
}
async function handleBatchCompleted() {
await loadSessions()
}
// 判断是否正在生成摘要
function isGenerating(sessionId: number): boolean {
return generatingSummaryIds.value.has(sessionId)
@@ -363,6 +365,7 @@ watch(
:ref="(el) => measureElement(el as Element)"
class="absolute left-0 top-0 w-full"
:style="{ transform: `translateY(${virtualItem.start}px)` }"
:data-index="virtualItem.index"
>
<!-- 日期头 -->
<template v-if="flatList[virtualItem.index]?.type === 'date'">
@@ -477,7 +480,7 @@ watch(
</div>
<!-- 批量生成摘要弹窗 -->
<BatchSummaryModal v-model:open="showBatchSummaryModal" :session-id="sessionId" @completed="loadSessions" />
<BatchSummaryModal v-model:open="showBatchSummaryModal" :session-id="sessionId" @completed="handleBatchCompleted" />
</template>
<style scoped>
@@ -4,4 +4,5 @@
*/
export { default as ChatRecordDrawer } from './ChatRecordDrawer.vue'
export { default as ChatRecordWorkspace } from './ChatRecordWorkspace.vue'
export * from './types'
@@ -4,7 +4,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { preserveChatRecordSessionId, resolveChatRecordSessionId } from './query-session'
import { preserveChatRecordSessionId, resolveChatRecordSessionId, scopeChatRecordQueryToSession } from './query-session'
test('uses the chat record query session id before the current route session id', () => {
assert.equal(resolveChatRecordSessionId({ sessionId: 'source-session' }, 'current-session'), 'source-session')
@@ -28,3 +28,10 @@ test('preserves the current chat record session id when applying drawer filters'
test('does not add an empty session id when the current query is not session scoped', () => {
assert.deepEqual(preserveChatRecordSessionId({ keywords: ['refund'] }, {}), { keywords: ['refund'] })
})
test('replaces a stale session id when a full-page workspace switches chats', () => {
assert.deepEqual(scopeChatRecordQueryToSession({ sessionId: 'old-session', keywords: ['refund'] }, 'new-session'), {
sessionId: 'new-session',
keywords: ['refund'],
})
})
@@ -11,3 +11,10 @@ export function preserveChatRecordSessionId(
const sessionId = currentQuery.sessionId?.trim()
return sessionId ? { ...nextQuery, sessionId } : nextQuery
}
/**
* 将查询固定到明确的聊天会话,避免页面切换后继续读取上一会话的数据。
*/
export function scopeChatRecordQueryToSession(query: ChatRecordQuery, sessionId: string): ChatRecordQuery {
return { ...query, sessionId }
}
+3 -1
View File
@@ -37,7 +37,9 @@
}
},
"memory": {
"comingSoon": "Memory feature coming soon, stay tuned"
"tabs": {
"records": "Records"
}
},
"tooltip": {
"viewChatRecord": "View Chat History",
+8 -2
View File
@@ -1,7 +1,13 @@
{
"drawer": {
"title": "Chat Record Viewer",
"loadedCount": "{count} messages loaded"
"title": "Chat Record Viewer"
},
"workspace": {
"indexMissingTitle": "No conversation summary index yet",
"indexMissingDescription": "You can still read the original messages. Generate an index to organize summaries into conversation segments; this does not call AI automatically.",
"generateIndex": "Generate index",
"indexGenerated": "Generated {count} conversation segments",
"indexGenerationFailed": "Failed to generate conversation index"
},
"messageList": {
"loading": "Loading...",
+3 -1
View File
@@ -37,7 +37,9 @@
}
},
"memory": {
"comingSoon": "メモリー機能は近日公開予定です。お楽しみに"
"tabs": {
"records": "記録"
}
},
"tooltip": {
"viewChatRecord": "チャット履歴を見る",
+8 -2
View File
@@ -1,7 +1,13 @@
{
"drawer": {
"title": "チャット履歴ビューア",
"loadedCount": "{count} 件のメッセージを読み込みました"
"title": "チャット履歴ビューア"
},
"workspace": {
"indexMissingTitle": "会話要約インデックスがまだありません",
"indexMissingDescription": "元のメッセージはそのまま閲覧できます。インデックスを生成すると要約が会話区間ごとに整理されます。この操作だけでは AI は自動実行されません。",
"generateIndex": "インデックスを生成",
"indexGenerated": "{count} 件の会話区間を生成しました",
"indexGenerationFailed": "会話インデックスの生成に失敗しました"
},
"messageList": {
"loading": "読み込み中...",
+3 -2
View File
@@ -37,8 +37,9 @@
}
},
"memory": {
"comingSoon": "正在计划中,如果你对记忆功能有什么建议,欢迎加群讨论。",
"joinLink": "https://chatlab.fun/cn/other/community"
"tabs": {
"records": "记录"
}
},
"tooltip": {
"viewChatRecord": "查看聊天记录",
+8 -2
View File
@@ -1,7 +1,13 @@
{
"drawer": {
"title": "聊天记录查看器",
"loadedCount": "已加载 {count} 条消息"
"title": "聊天记录查看器"
},
"workspace": {
"indexMissingTitle": "还没有可用的会话摘要索引",
"indexMissingDescription": "原始聊天记录仍可直接查看。生成索引后,左侧会按聊天片段组织摘要;此操作不会自动调用 AI。",
"generateIndex": "生成索引",
"indexGenerated": "已生成 {count} 个聊天片段",
"indexGenerationFailed": "会话索引生成失败"
},
"messageList": {
"loading": "加载中...",
+3 -1
View File
@@ -37,7 +37,9 @@
}
},
"memory": {
"comingSoon": "記憶功能即將上線,敬請期待"
"tabs": {
"records": "紀錄"
}
},
"tooltip": {
"viewChatRecord": "查看聊天紀錄",
+8 -2
View File
@@ -1,7 +1,13 @@
{
"drawer": {
"title": "聊天紀錄檢視器",
"loadedCount": "已載入 {count} 條訊息"
"title": "聊天紀錄檢視器"
},
"workspace": {
"indexMissingTitle": "尚未建立可用的會話摘要索引",
"indexMissingDescription": "原始聊天紀錄仍可直接查看。產生索引後,左側會依聊天片段整理摘要;此操作不會自動呼叫 AI。",
"generateIndex": "產生索引",
"indexGenerated": "已產生 {count} 個聊天片段",
"indexGenerationFailed": "會話索引產生失敗"
},
"messageList": {
"loading": "載入中...",
+1 -1
View File
@@ -56,7 +56,7 @@ const baseTabs = [
{ id: 'insights', labelKey: 'analysis.tabs.insights', icon: 'i-heroicons-presentation-chart-bar' },
{ id: 'ranking', labelKey: 'analysis.tabs.ranking', icon: 'i-heroicons-trophy' },
{ id: 'ai-chat', labelKey: 'analysis.tabs.aiChat', icon: 'i-heroicons-chat-bubble-left-ellipsis' },
// { id: 'memory', labelKey: 'analysis.tabs.memory', icon: 'i-heroicons-light-bulb' },
{ id: 'memory', labelKey: 'analysis.tabs.memory', icon: 'i-heroicons-light-bulb' },
{ id: 'lab', labelKey: 'analysis.tabs.lab', icon: 'i-heroicons-beaker' },
]
+1 -1
View File
@@ -53,7 +53,7 @@ function openChatRecordViewer() {
const baseTabs = [
{ id: 'insights', labelKey: 'analysis.tabs.insights', icon: 'i-heroicons-presentation-chart-bar' },
{ id: 'ai-chat', labelKey: 'analysis.tabs.aiChat', icon: 'i-heroicons-chat-bubble-left-ellipsis' },
// { id: 'memory', labelKey: 'analysis.tabs.memory', icon: 'i-heroicons-light-bulb' },
{ id: 'memory', labelKey: 'analysis.tabs.memory', icon: 'i-heroicons-light-bulb' },
{ id: 'lab', labelKey: 'analysis.tabs.lab', icon: 'i-heroicons-beaker' },
]