feat: AI对话支持导出

This commit is contained in:
digua
2026-01-08 00:48:38 +08:00
parent 7bb735533b
commit 9197ab8ac9
8 changed files with 365 additions and 37 deletions
+20 -3
View File
@@ -180,6 +180,9 @@ export function registerCacheHandlers(_context: IpcContext): void {
/**
* 保存文件到下载目录
* 支持两种 data URL 格式:
* 1. base64: data:image/png;base64,xxx
* 2. URL 编码: data:text/plain;charset=utf-8,xxx
*/
ipcMain.handle('cache:saveToDownloads', async (_, filename: string, dataUrl: string) => {
const chatLabDir = getChatLabDir()
@@ -191,9 +194,23 @@ export function registerCacheHandlers(_context: IpcContext): void {
await fs.mkdir(downloadsDir, { recursive: true })
}
// 从 data URL 中提取 base64 数据
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '')
const buffer = Buffer.from(base64Data, 'base64')
let buffer: Buffer
// 解析 data URL
if (dataUrl.includes(';base64,')) {
// Base64 编码格式(图片等二进制数据)
const base64Data = dataUrl.split(';base64,')[1]
buffer = Buffer.from(base64Data, 'base64')
} else if (dataUrl.includes('charset=utf-8,')) {
// URL 编码格式(文本数据)
const textData = dataUrl.split('charset=utf-8,')[1]
const decodedText = decodeURIComponent(textData)
buffer = Buffer.from(decodedText, 'utf-8')
} else {
// 默认尝试作为 base64 处理
const base64Data = dataUrl.replace(/^data:[^,]+,/, '')
buffer = Buffer.from(base64Data, 'base64')
}
// 写入文件
const filePath = path.join(downloadsDir, filename)
+173 -31
View File
@@ -1,9 +1,16 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import dayjs from 'dayjs'
import { useToast } from '@nuxt/ui/runtime/composables/useToast.js'
import { usePromptStore } from '@/stores/prompt'
import { exportConversation, type ExportFormat } from '@/utils/conversationExport'
const { t } = useI18n()
const toast = useToast()
const promptStore = usePromptStore()
const { aiGlobalSettings } = storeToRefs(promptStore)
interface Conversation {
id: string
@@ -29,6 +36,7 @@ const emit = defineEmits<{
// State
const conversations = ref<Conversation[]>([])
const isLoading = ref(false)
const isExporting = ref<string | null>(null) // 正在导出的对话 ID
const editingId = ref<string | null>(null)
const editingTitle = ref('')
const isCollapsed = ref(false)
@@ -45,10 +53,10 @@ async function loadConversations() {
}
}
// 格式化时间
// 格式化时间(数据库存储的是秒级时间戳,需转换为毫秒级)
function formatTime(timestamp: number): string {
const now = dayjs()
const date = dayjs(timestamp)
const date = dayjs(timestamp * 1000)
if (now.diff(date, 'day') === 0) {
return date.format('HH:mm')
@@ -97,6 +105,88 @@ async function handleDelete(convId: string) {
}
}
// 导出对话
async function handleExport(conv: Conversation) {
if (isExporting.value) return
isExporting.value = conv.id
try {
// 获取对话消息
const messages = await window.aiApi.getMessages(conv.id)
if (messages.length === 0) {
toast.add({
title: t('conversation.export.noMessages'),
icon: 'i-heroicons-exclamation-triangle',
color: 'warning',
duration: 2000,
})
return
}
// 获取导出格式和标题
const format = (aiGlobalSettings.value.exportFormat || 'markdown') as ExportFormat
const title = conv.title || t('conversation.newChat')
// 导出标签(国际化)
const labels = {
createdAt: t('conversation.export.createdAt'),
user: t('conversation.export.user'),
assistant: t('conversation.export.assistant'),
}
// 转换消息时间戳(数据库存储的是秒级时间戳,需转换为毫秒级)
const messagesWithMs = messages.map((msg) => ({
...msg,
timestamp: msg.timestamp * 1000,
}))
// 调用导出工具
const result = await exportConversation(title, messagesWithMs, conv.createdAt * 1000, format, labels)
if (result.success && result.filePath) {
// 获取文件名
const filename = result.filePath.split('/').pop() || result.filePath
const exportedFilePath = result.filePath
// 显示成功 toast
toast.add({
title: t('conversation.export.success'),
description: filename,
icon: 'i-heroicons-check-circle',
color: 'primary',
duration: 2000,
actions: [
{
label: t('conversation.export.openFolder'),
onClick: () => {
window.cacheApi.showInFolder(exportedFilePath)
},
},
],
})
} else {
toast.add({
title: t('conversation.export.failed'),
description: result.error,
icon: 'i-heroicons-x-circle',
color: 'error',
duration: 2000,
})
}
} catch (error) {
console.error('导出对话失败:', error)
toast.add({
title: t('conversation.export.failed'),
description: String(error),
icon: 'i-heroicons-x-circle',
color: 'error',
duration: 2000,
})
} finally {
isExporting.value = null
}
}
// 初始化
onMounted(() => {
loadConversations()
@@ -165,7 +255,9 @@ defineExpose({
<UIcon name="i-heroicons-chat-bubble-left" class="h-6 w-6 text-gray-300 dark:text-gray-600" />
</div>
<p class="mt-3 text-xs text-gray-400">{{ t('conversation.empty') }}</p>
<UButton class="mt-2" size="xs" variant="link" color="primary" @click="emit('create')">{{ t('conversation.startNew') }}</UButton>
<UButton class="mt-2" size="xs" variant="link" color="primary" @click="emit('create')">
{{ t('conversation.startNew') }}
</UButton>
</div>
<!-- 对话列表 -->
@@ -196,38 +288,70 @@ defineExpose({
<!-- 正常模式 -->
<template v-else>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ getTitle(conv) }}
</p>
<p class="mt-1 text-[10px] text-gray-400 opacity-80">
{{ formatTime(conv.updatedAt) }}
</p>
</div>
<div class="relative">
<!-- 标题 -->
<p class="line-clamp-1 pr-2 text-sm font-medium leading-snug">
{{ getTitle(conv) }}
</p>
<!-- 操作按钮 -->
<!-- 时间 -->
<p class="mt-1.5 text-[10px] text-gray-400">
{{ formatTime(conv.updatedAt) }}
</p>
<!-- 操作按钮垂直居中带渐变背景 -->
<div
class="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
class="absolute inset-y-0 right-0 flex items-center opacity-0 transition-opacity group-hover:opacity-100"
:class="{ 'opacity-100': activeId === conv.id }"
@click.stop
>
<UButton
icon="i-heroicons-pencil"
color="gray"
variant="ghost"
size="2xs"
class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
@click="startEditing(conv)"
/>
<UButton
icon="i-heroicons-trash"
color="gray"
variant="ghost"
size="2xs"
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400"
@click="handleDelete(conv.id)"
<!-- 左侧渐变过渡区域 -->
<div
class="absolute inset-y-0 -left-6 w-6 bg-gradient-to-r from-transparent"
:class="[
activeId === conv.id
? 'to-gray-100 dark:to-gray-800'
: 'to-white group-hover:to-gray-50 dark:to-gray-900 dark:group-hover:to-gray-800/50',
]"
/>
<!-- 按钮组实色背景h-full 确保完全覆盖 -->
<div
class="relative flex h-full items-center gap-0.5 pl-1 pr-0.5"
:class="[
activeId === conv.id
? 'bg-gray-100 dark:bg-gray-800'
: 'bg-white group-hover:bg-gray-50 dark:bg-gray-900 dark:group-hover:bg-gray-800/50',
]"
>
<UButton
:icon="isExporting === conv.id ? 'i-heroicons-arrow-path' : 'i-heroicons-arrow-down-tray'"
color="gray"
variant="ghost"
size="2xs"
:class="[
isExporting === conv.id ? 'animate-spin' : '',
'text-gray-400 hover:text-primary-500 dark:hover:text-primary-400',
]"
:disabled="isExporting !== null"
@click="handleExport(conv)"
/>
<UButton
icon="i-heroicons-pencil"
color="gray"
variant="ghost"
size="2xs"
class="text-gray-400 hover:text-primary-500 dark:hover:text-primary-400"
@click="startEditing(conv)"
/>
<UButton
icon="i-heroicons-trash"
color="gray"
variant="ghost"
size="2xs"
class="text-gray-400 hover:text-primary-500 dark:hover:text-primary-400"
@click="handleDelete(conv.id)"
/>
</div>
</div>
</div>
</template>
@@ -272,7 +396,16 @@ defineExpose({
"newChat": "新对话",
"empty": "暂无历史记录",
"startNew": "开始新对话",
"titlePlaceholder": "输入标题..."
"titlePlaceholder": "输入标题...",
"export": {
"createdAt": "创建时间",
"user": "用户",
"assistant": "AI 助手",
"success": "导出成功",
"failed": "导出失败",
"openFolder": "打开目录",
"noMessages": "对话没有消息"
}
}
},
"en-US": {
@@ -281,7 +414,16 @@ defineExpose({
"newChat": "New Chat",
"empty": "No history yet",
"startNew": "Start New Chat",
"titlePlaceholder": "Enter title..."
"titlePlaceholder": "Enter title...",
"export": {
"createdAt": "Created",
"user": "User",
"assistant": "AI Assistant",
"success": "Export successful",
"failed": "Export failed",
"openFolder": "Open folder",
"noMessages": "No messages to export"
}
}
}
}
@@ -45,6 +45,21 @@ const globalMaxHistoryRounds = computed({
},
})
// 导出格式选项
const exportFormatTabs = computed(() => [
{ label: 'Markdown', value: 'markdown' },
{ label: t('settings.aiPrompt.exportFormat.txtLabel'), value: 'txt' },
])
// 当前选中的导出格式
const exportFormat = computed({
get: () => aiGlobalSettings.value.exportFormat ?? 'markdown',
set: (val: string) => {
promptStore.updateAIGlobalSettings({ exportFormat: val as 'markdown' | 'txt' })
emit('config-changed')
},
})
/** 打开新增预设弹窗 */
function openAddModal(chatType: 'group' | 'private') {
editMode.value = 'add'
@@ -136,6 +151,19 @@ function handleImportPresetAdded() {
</div>
<UInput v-model.number="globalMaxHistoryRounds" type="number" min="1" max="50" class="w-24" />
</div>
<!-- 导出格式 -->
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.aiPrompt.exportFormat.title') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.exportFormat.description') }}
</p>
</div>
<UTabs v-model="exportFormat" :items="exportFormatTabs" size="xs" />
</div>
</div>
</div>
+5
View File
@@ -138,6 +138,11 @@
"title": "Context Limit",
"description": "Number of recent conversation rounds to keep (1 round = User + AI). Prevents excessive token usage."
},
"exportFormat": {
"title": "Export Format",
"description": "File format for exporting AI conversations",
"txtLabel": "TXT"
},
"presets": {
"title": "System Prompts",
"import": "Import Presets"
+5
View File
@@ -138,6 +138,11 @@
"title": "AI上下文限制",
"description": "每次对话保留最近的对话轮数(1轮 = 用户提问 + AI回复),防止上下文过长消耗 Token"
},
"exportFormat": {
"title": "对话导出格式",
"description": "导出 AI 对话时使用的文件格式",
"txtLabel": "TXT"
},
"presets": {
"title": "系统提示词",
"import": "导入预设"
+5 -3
View File
@@ -47,6 +47,7 @@ export const usePromptStore = defineStore(
const aiGlobalSettings = ref({
maxMessagesPerRequest: 500,
maxHistoryRounds: 5, // AI上下文会话轮数限制
exportFormat: 'markdown' as 'markdown' | 'txt', // 对话导出格式
})
const customKeywordTemplates = ref<KeywordTemplate[]>([])
const deletedPresetTemplateIds = ref<string[]>([])
@@ -96,7 +97,9 @@ export const usePromptStore = defineStore(
/**
* 更新 AI 全局设置
*/
function updateAIGlobalSettings(settings: Partial<{ maxMessagesPerRequest: number; maxHistoryRounds: number }>) {
function updateAIGlobalSettings(
settings: Partial<{ maxMessagesPerRequest: number; maxHistoryRounds: number; exportFormat: 'markdown' | 'txt' }>
) {
aiGlobalSettings.value = { ...aiGlobalSettings.value, ...settings }
notifyAIConfigChanged()
}
@@ -290,8 +293,7 @@ export const usePromptStore = defineStore(
// 过滤无效数据
return remotePresets.filter(
(preset) =>
preset.id && preset.name && preset.chatType && preset.roleDefinition && preset.responseRules
(preset) => preset.id && preset.name && preset.chatType && preset.roleDefinition && preset.responseRules
)
} catch {
return []
+128
View File
@@ -0,0 +1,128 @@
import dayjs from 'dayjs'
export type ExportFormat = 'markdown' | 'txt'
export interface ExportMessage {
role: 'user' | 'assistant'
content: string
timestamp: number
}
export interface ExportLabels {
createdAt: string
user: string
assistant: string
}
/**
* 格式化对话为 Markdown 格式
*/
export function formatAsMarkdown(
title: string,
messages: ExportMessage[],
createdAt: number,
labels: ExportLabels
): string {
const lines: string[] = []
// 标题
lines.push(`# ${title}`)
lines.push('')
lines.push(`> ${labels.createdAt}: ${dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss')}`)
lines.push('')
lines.push('---')
lines.push('')
// 消息
for (const msg of messages) {
const time = dayjs(msg.timestamp).format('YYYY-MM-DD HH:mm:ss')
const roleLabel = msg.role === 'user' ? labels.user : labels.assistant
lines.push(`### ${roleLabel}`)
lines.push(`*${time}*`)
lines.push('')
lines.push(msg.content)
lines.push('')
lines.push('---')
lines.push('')
}
return lines.join('\n')
}
/**
* 格式化对话为纯文本格式
*/
export function formatAsPlainText(
title: string,
messages: ExportMessage[],
createdAt: number,
labels: ExportLabels
): string {
const lines: string[] = []
// 标题
lines.push(`${title}`)
lines.push(`${labels.createdAt}: ${dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss')}`)
lines.push('')
lines.push('='.repeat(50))
lines.push('')
// 消息
for (const msg of messages) {
const time = dayjs(msg.timestamp).format('YYYY-MM-DD HH:mm:ss')
const roleLabel = msg.role === 'user' ? labels.user : labels.assistant
lines.push(`[${roleLabel}] ${time}`)
lines.push('')
lines.push(msg.content)
lines.push('')
lines.push('-'.repeat(50))
lines.push('')
}
return lines.join('\n')
}
/**
* 清理文件名中的非法字符
*/
export function sanitizeFilename(filename: string): string {
return filename.replace(/[/\\?%*:|"<>]/g, '_')
}
/**
* 导出对话到下载目录
*/
export async function exportConversation(
title: string,
messages: ExportMessage[],
createdAt: number,
format: ExportFormat,
labels: ExportLabels
): Promise<{ success: boolean; filePath?: string; error?: string }> {
if (messages.length === 0) {
return { success: false, error: 'No messages to export' }
}
// 格式化内容
let content: string
let filename: string
const timestamp = dayjs(createdAt).format('YYYYMMDD_HHmmss')
const safeTitle = sanitizeFilename(title)
if (format === 'markdown') {
content = formatAsMarkdown(title, messages, createdAt, labels)
filename = `${safeTitle}_${timestamp}.md`
} else {
content = formatAsPlainText(title, messages, createdAt, labels)
filename = `${safeTitle}_${timestamp}.txt`
}
// 转换为 data URL 并保存
const dataUrl = `data:text/plain;charset=utf-8,${encodeURIComponent(content)}`
const result = await window.cacheApi.saveToDownloads(filename, dataUrl)
return result
}
+1
View File
@@ -4,3 +4,4 @@
export * from './dateFormat'
export * from './rankStyle'
export * from './snapCapture'
export * from './conversationExport'