mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(memory): add linked chat records view
This commit is contained in:
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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -37,7 +37,9 @@
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"comingSoon": "Memory feature coming soon, stay tuned"
|
||||
"tabs": {
|
||||
"records": "Records"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"viewChatRecord": "View Chat History",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"comingSoon": "メモリー機能は近日公開予定です。お楽しみに"
|
||||
"tabs": {
|
||||
"records": "記録"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"viewChatRecord": "チャット履歴を見る",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"drawer": {
|
||||
"title": "チャット履歴ビューア",
|
||||
"loadedCount": "{count} 件のメッセージを読み込みました"
|
||||
"title": "チャット履歴ビューア"
|
||||
},
|
||||
"workspace": {
|
||||
"indexMissingTitle": "会話要約インデックスがまだありません",
|
||||
"indexMissingDescription": "元のメッセージはそのまま閲覧できます。インデックスを生成すると要約が会話区間ごとに整理されます。この操作だけでは AI は自動実行されません。",
|
||||
"generateIndex": "インデックスを生成",
|
||||
"indexGenerated": "{count} 件の会話区間を生成しました",
|
||||
"indexGenerationFailed": "会話インデックスの生成に失敗しました"
|
||||
},
|
||||
"messageList": {
|
||||
"loading": "読み込み中...",
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"comingSoon": "正在计划中,如果你对记忆功能有什么建议,欢迎加群讨论。",
|
||||
"joinLink": "https://chatlab.fun/cn/other/community"
|
||||
"tabs": {
|
||||
"records": "记录"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"viewChatRecord": "查看聊天记录",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"drawer": {
|
||||
"title": "聊天记录查看器",
|
||||
"loadedCount": "已加载 {count} 条消息"
|
||||
"title": "聊天记录查看器"
|
||||
},
|
||||
"workspace": {
|
||||
"indexMissingTitle": "还没有可用的会话摘要索引",
|
||||
"indexMissingDescription": "原始聊天记录仍可直接查看。生成索引后,左侧会按聊天片段组织摘要;此操作不会自动调用 AI。",
|
||||
"generateIndex": "生成索引",
|
||||
"indexGenerated": "已生成 {count} 个聊天片段",
|
||||
"indexGenerationFailed": "会话索引生成失败"
|
||||
},
|
||||
"messageList": {
|
||||
"loading": "加载中...",
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"comingSoon": "記憶功能即將上線,敬請期待"
|
||||
"tabs": {
|
||||
"records": "紀錄"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"viewChatRecord": "查看聊天紀錄",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"drawer": {
|
||||
"title": "聊天紀錄檢視器",
|
||||
"loadedCount": "已載入 {count} 條訊息"
|
||||
"title": "聊天紀錄檢視器"
|
||||
},
|
||||
"workspace": {
|
||||
"indexMissingTitle": "尚未建立可用的會話摘要索引",
|
||||
"indexMissingDescription": "原始聊天紀錄仍可直接查看。產生索引後,左側會依聊天片段整理摘要;此操作不會自動呼叫 AI。",
|
||||
"generateIndex": "產生索引",
|
||||
"indexGenerated": "已產生 {count} 個聊天片段",
|
||||
"indexGenerationFailed": "會話索引產生失敗"
|
||||
},
|
||||
"messageList": {
|
||||
"loading": "載入中...",
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
Reference in new issue
Block a user