fix(ai): enable exporting visible AI conversations (#231)

This commit is contained in:
digua committed 2026-06-23 14:18:32 +08:00
1 parent 307b0380bd
commit 835985fbf6
4 files changed
+133 -28

No files matched your search

+2
View File
@@ -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"
/>
</div>
+56 -28
View File
@@ -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 (!conv || messages.length === 0) {
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 (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) }}
</div>
<div>{{ t('ai.chat.statusBar.tokenUsageTitle') }}: {{ totalTokenUsageCompactText }}</div>
<div v-if="hasCacheData">
{{ t('ai.chat.statusBar.cacheHit') }}: {{ cacheReadText }}
</div>
<div v-if="hasCacheData">{{ t('ai.chat.statusBar.cacheHit') }}: {{ cacheReadText }}</div>
</div>
</template>
</UTooltip>
@@ -362,7 +390,7 @@ const thinkingLevelLabel = computed(() => {
<button
class="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-xs text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-gray-800 dark:hover:text-gray-300"
:title="t('ai.chat.statusBar.export.title')"
:disabled="isExporting || !currentAIChatId"
:disabled="isExporting || !canExportConversation"
@click="handleExportConversation"
>
<UIcon name="i-heroicons-arrow-down-tray" class="h-3.5 w-3.5" />
+29
View File
@@ -0,0 +1,29 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { getExportableConversationMessages, hasExportableConversationMessages } from './conversationExport'
describe('conversationExport', () => {
it('treats visible user and assistant messages as exportable before an aiChatId exists', () => {
const messages = [
{ role: 'summary' as const, content: 'internal summary', timestamp: 1 },
{ role: 'user' as const, content: 'hello', timestamp: 2 },
{ role: 'assistant' as const, content: 'hi there', timestamp: 3 },
]
assert.equal(hasExportableConversationMessages(messages), true)
assert.deepEqual(getExportableConversationMessages(messages), [
{ role: 'user', content: 'hello', timestamp: 2 },
{ role: 'assistant', content: 'hi there', timestamp: 3 },
])
})
it('does not export internal or blank messages', () => {
const messages = [
{ role: 'summary' as const, content: 'internal summary', timestamp: 1 },
{ role: 'assistant' as const, content: ' ', timestamp: 2 },
]
assert.equal(hasExportableConversationMessages(messages), false)
assert.deepEqual(getExportableConversationMessages(messages), [])
})
})
+46
View File
@@ -14,6 +14,52 @@ export interface ExportLabels {
assistant: string
}
export interface ConversationExportSourceMessage {
role: string
content: string
timestamp: number
contentBlocks?: Array<{ type: string; text?: string }>
}
function getMessageContent(message: ConversationExportSourceMessage): string {
if (message.content.trim()) {
return message.content
}
const textFromBlocks =
message.contentBlocks
?.filter((block): block is { type: 'text'; text: string } => block.type === 'text' && Boolean(block.text))
.map((block) => block.text)
.join('') ?? ''
return textFromBlocks
}
export function getExportableConversationMessages(messages: ConversationExportSourceMessage[]): ExportMessage[] {
return messages.flatMap((message) => {
if (message.role !== 'user' && message.role !== 'assistant') {
return []
}
const content = getMessageContent(message)
if (!content.trim()) {
return []
}
return [
{
role: message.role,
content,
timestamp: message.timestamp,
},
]
})
}
export function hasExportableConversationMessages(messages: ConversationExportSourceMessage[]): boolean {
return getExportableConversationMessages(messages).length > 0
}
/**
* 格式化对话为 Markdown 格式
*/