mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-18 20:39:01 +08:00
feat: AI对话支持导出
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -138,6 +138,11 @@
|
||||
"title": "AI上下文限制",
|
||||
"description": "每次对话保留最近的对话轮数(1轮 = 用户提问 + AI回复),防止上下文过长消耗 Token"
|
||||
},
|
||||
"exportFormat": {
|
||||
"title": "对话导出格式",
|
||||
"description": "导出 AI 对话时使用的文件格式",
|
||||
"txtLabel": "TXT"
|
||||
},
|
||||
"presets": {
|
||||
"title": "系统提示词",
|
||||
"import": "导入预设"
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
export * from './dateFormat'
|
||||
export * from './rankStyle'
|
||||
export * from './snapCapture'
|
||||
export * from './conversationExport'
|
||||
|
||||
Reference in New Issue
Block a user