mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
fix(ai): enable exporting visible AI conversations (#231)
This commit is contained in:
1 parent
307b0380bd
commit
835985fbf6
4 files changed
+133
-28
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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), [])
|
||||
})
|
||||
})
|
||||
@@ -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 格式
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user