From e38dcfe940a10ba79d1ecf5b92adbb0e9a47477f Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 22 Jun 2026 15:39:44 +0800 Subject: [PATCH 1/4] fix(ai): enable exporting visible AI conversations (#231) --- src/components/AIChat/ChatExplorer.vue | 2 + src/components/AIChat/chat/ChatStatusBar.vue | 84 +++++++++++++------- src/utils/conversationExport.test.ts | 29 +++++++ src/utils/conversationExport.ts | 46 +++++++++++ 4 files changed, 133 insertions(+), 28 deletions(-) create mode 100644 src/utils/conversationExport.test.ts diff --git a/src/components/AIChat/ChatExplorer.vue b/src/components/AIChat/ChatExplorer.vue index 48f37632c..585ecee08 100644 --- a/src/components/AIChat/ChatExplorer.vue +++ b/src/components/AIChat/ChatExplorer.vue @@ -512,6 +512,8 @@ watch( :session-token-usage="sessionTokenUsage" :agent-status="agentStatus" :current-ai-chat-id="currentAIChatId" + :current-messages="messages" + :fallback-title="sessionName" :estimated-context-tokens="estimatedContextTokens" /> diff --git a/src/components/AIChat/chat/ChatStatusBar.vue b/src/components/AIChat/chat/ChatStatusBar.vue index cce0e5f81..e6d34525c 100644 --- a/src/components/AIChat/chat/ChatStatusBar.vue +++ b/src/components/AIChat/chat/ChatStatusBar.vue @@ -6,11 +6,18 @@ import { useToast } from '@/composables/useToast' import { usePromptStore } from '@/stores/prompt' import { useLayoutStore } from '@/stores/layout' import { useLLMStore } from '@/stores/llm' -import { exportConversation, type ExportFormat, type ExportMessage } from '@/utils/conversationExport' +import { + exportConversation, + getExportableConversationMessages, + hasExportableConversationMessages, + type ConversationExportSourceMessage, + type ExportFormat, +} from '@/utils/conversationExport' import type { AgentRuntimeStatus } from '@electron/shared/types' import { useAIService } from '@/services' import { getSupportedThinkingLevels, type ThinkingLevel } from '@openchatlab/core' import { useCacheService } from '@/services/cache/service' +import type { ChatMessage } from '@/composables/useAIChat' const { t } = useI18n() const toast = useToast() @@ -21,6 +28,8 @@ const props = defineProps<{ sessionTokenUsage: { totalTokens: number; cacheReadTokens: number; cacheWriteTokens: number } agentStatus?: AgentRuntimeStatus | null currentAIChatId?: string | null + currentMessages?: ChatMessage[] + fallbackTitle?: string estimatedContextTokens?: number }>() @@ -129,39 +138,60 @@ function openModelSettings() { // 导出当前对话 const isExporting = ref(false) +const visibleExportMessages = computed(() => getExportableConversationMessages(props.currentMessages ?? [])) +const canExportConversation = computed(() => { + return Boolean(props.currentAIChatId) || hasExportableConversationMessages(props.currentMessages ?? []) +}) + +function getExportLabels() { + return { + createdAt: t('ai.chat.conversation.export.createdAt'), + user: t('ai.chat.conversation.export.user'), + assistant: t('ai.chat.conversation.export.assistant'), + } +} + +function toExportSourceMessages(messages: ConversationExportSourceMessage[]): ConversationExportSourceMessage[] { + return messages.map((message) => ({ + ...message, + timestamp: message.timestamp * 1000, + })) +} async function handleExportConversation() { - if (isExporting.value || !props.currentAIChatId) return + if (isExporting.value || !canExportConversation.value) return isExporting.value = true try { - const [conv, messages] = await Promise.all([ - useAIService().getAIChat(props.currentAIChatId), - useAIService().getMessages(props.currentAIChatId), - ]) + const format = (aiGlobalSettings.value.exportFormat || 'markdown') as ExportFormat + const labels = getExportLabels() + let title = props.fallbackTitle || t('ai.chat.conversation.newChat') + let createdAt = visibleExportMessages.value[0]?.timestamp ?? Date.now() + let messages = visibleExportMessages.value + + if (props.currentAIChatId) { + const [conv, persistedMessages] = await Promise.all([ + useAIService().getAIChat(props.currentAIChatId), + useAIService().getMessages(props.currentAIChatId), + ]) + + if (conv) { + title = conv.title || title + createdAt = conv.createdAt * 1000 + } + + const persistedExportMessages = getExportableConversationMessages(toExportSourceMessages(persistedMessages)) + if (persistedExportMessages.length > 0) { + messages = persistedExportMessages + } + } - if (!conv || messages.length === 0) { + if (messages.length === 0) { toast.warn(t('ai.chat.conversation.export.noMessages')) return } - const format = (aiGlobalSettings.value.exportFormat || 'markdown') as ExportFormat - const title = conv.title || t('ai.chat.conversation.newChat') - const labels = { - createdAt: t('ai.chat.conversation.export.createdAt'), - user: t('ai.chat.conversation.export.user'), - assistant: t('ai.chat.conversation.export.assistant'), - } - // 导出面向用户可见的问答内容,跳过压缩摘要等系统生成的内部消息。 - const messagesWithMs: ExportMessage[] = messages - .filter((msg) => msg.role === 'user' || msg.role === 'assistant') - .map((msg) => ({ - role: msg.role as ExportMessage['role'], - content: msg.content, - timestamp: msg.timestamp * 1000, - })) - - const result = await exportConversation(title, messagesWithMs, conv.createdAt * 1000, format, labels) + const result = await exportConversation(title, messages, createdAt, format, labels) if (result.success && result.filePath) { const filename = result.filePath.split('/').pop() || result.filePath @@ -341,9 +371,7 @@ const thinkingLevelLabel = computed(() => { {{ formatCompactNumber(modelContextWindow) }}
{{ t('ai.chat.statusBar.tokenUsageTitle') }}: {{ totalTokenUsageCompactText }}
-
- {{ t('ai.chat.statusBar.cacheHit') }}: {{ cacheReadText }} -
+
{{ t('ai.chat.statusBar.cacheHit') }}: {{ cacheReadText }}
@@ -362,7 +390,7 @@ const thinkingLevelLabel = computed(() => {