mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-19 21:00:25 +08:00
feat: 新增指定格式导入
This commit is contained in:
@@ -73,6 +73,15 @@ export function getSupportedFormats(): FormatFeature[] {
|
||||
return sniffer.getSupportedFormats()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据格式 ID 获取格式特征
|
||||
* 用于手动指定格式时跳过嗅探
|
||||
*/
|
||||
export function getFormatFeatureById(formatId: string): FormatFeature | null {
|
||||
const all = sniffer.getSupportedFormats()
|
||||
return all.find((f) => f.id === formatId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取格式的预处理器(如果有)
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
streamParseFile,
|
||||
detectFormat,
|
||||
detectAllFormats,
|
||||
getFormatFeatureById,
|
||||
getPreprocessor,
|
||||
needsPreprocess,
|
||||
type ParsedMeta,
|
||||
@@ -144,6 +145,16 @@ export async function streamImport(
|
||||
requestId: string,
|
||||
formatOptions?: Record<string, unknown>
|
||||
): Promise<StreamImportResult> {
|
||||
// 用户手动指定格式时,跳过自动检测直接使用指定的 Parser
|
||||
if (formatOptions?.formatId) {
|
||||
const formatId = formatOptions.formatId as string
|
||||
const feature = getFormatFeatureById(formatId)
|
||||
if (!feature) {
|
||||
return { success: false, error: 'error.unknown_format_id' }
|
||||
}
|
||||
return streamImportSingle(filePath, requestId, feature, formatOptions)
|
||||
}
|
||||
|
||||
// 检测所有匹配的格式(按优先级排序)
|
||||
const candidates = detectAllFormats(filePath)
|
||||
if (candidates.length === 0) {
|
||||
|
||||
@@ -226,7 +226,9 @@ export const chatApi = {
|
||||
/**
|
||||
* 获取支持的格式列表
|
||||
*/
|
||||
getSupportedFormats: (): Promise<Array<{ name: string; platform: string }>> => {
|
||||
getSupportedFormats: (): Promise<
|
||||
Array<{ id: string; name: string; platform: string; extensions: string[] }>
|
||||
> => {
|
||||
return ipcRenderer.invoke('chat:getSupportedFormats')
|
||||
},
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -135,7 +135,7 @@ interface ChatApi {
|
||||
) => Promise<Array<{ type: MessageType; count: number }>>
|
||||
getTimeRange: (sessionId: string) => Promise<{ start: number; end: number } | null>
|
||||
getDbDirectory: () => Promise<string | null>
|
||||
getSupportedFormats: () => Promise<Array<{ name: string; platform: string }>>
|
||||
getSupportedFormats: () => Promise<Array<{ id: string; name: string; platform: string; extensions: string[] }>>
|
||||
onImportProgress: (callback: (progress: ImportProgress) => void) => () => void
|
||||
getCatchphraseAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<CatchphraseAnalysis>
|
||||
getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MentionAnalysis>
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
"import": "Import ({count})",
|
||||
"noChats": "No chats found"
|
||||
},
|
||||
"formatSelector": {
|
||||
"title": "Select Chat Format",
|
||||
"hint": "Unable to auto-detect the file format. Please manually select the corresponding chat format.",
|
||||
"manualSelect": "Select format manually",
|
||||
"confirm": "Import with this format"
|
||||
},
|
||||
"footer": {
|
||||
"website": "Website",
|
||||
"terms": "Terms of Use"
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
"import": "インポート ({count})",
|
||||
"noChats": "チャットが見つかりません"
|
||||
},
|
||||
"formatSelector": {
|
||||
"title": "チャット形式を選択",
|
||||
"hint": "ファイル形式を自動検出できませんでした。対応するチャット形式を手動で選択してください。",
|
||||
"manualSelect": "形式を手動で選択",
|
||||
"confirm": "この形式でインポート"
|
||||
},
|
||||
"footer": {
|
||||
"website": "公式サイト",
|
||||
"terms": "利用規約"
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
"import": "导入 ({count})",
|
||||
"noChats": "未发现任何聊天"
|
||||
},
|
||||
"formatSelector": {
|
||||
"title": "选择聊天记录格式",
|
||||
"hint": "无法自动识别此文件的格式,请手动选择对应的聊天记录格式。",
|
||||
"manualSelect": "手动选择格式",
|
||||
"confirm": "使用此格式导入"
|
||||
},
|
||||
"footer": {
|
||||
"website": "官网",
|
||||
"terms": "使用条款"
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
"import": "匯入 ({count})",
|
||||
"noChats": "未發現任何聊天"
|
||||
},
|
||||
"formatSelector": {
|
||||
"title": "選擇聊天記錄格式",
|
||||
"hint": "無法自動識別此檔案的格式,請手動選擇對應的聊天記錄格式。",
|
||||
"manualSelect": "手動選擇格式",
|
||||
"confirm": "使用此格式匯入"
|
||||
},
|
||||
"footer": {
|
||||
"website": "官網",
|
||||
"terms": "使用條款"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 格式选择器弹窗
|
||||
* 当自动检测无法识别文件格式时,让用户手动选择对应的聊天记录格式。
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface FormatInfo {
|
||||
id: string
|
||||
name: string
|
||||
platform: string
|
||||
extensions: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** 导入失败的文件路径(用于显示) */
|
||||
filePath?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
select: [formatId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value),
|
||||
})
|
||||
|
||||
const formats = ref<FormatInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedFormatId = ref<string | null>(null)
|
||||
|
||||
/** 只展示可手动选择的平台(避免用户面对过多无关格式) */
|
||||
const MANUAL_SELECTABLE_PLATFORMS = new Set(['whatsapp', 'line', 'unknown'])
|
||||
|
||||
/** 按平台分组(仅展示可手动选择的格式) */
|
||||
const groupedFormats = computed(() => {
|
||||
const map = new Map<string, FormatInfo[]>()
|
||||
for (const f of formats.value) {
|
||||
if (!MANUAL_SELECTABLE_PLATFORMS.has(f.platform)) continue
|
||||
const group = map.get(f.platform) ?? []
|
||||
group.push(f)
|
||||
map.set(f.platform, group)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/** 平台图标映射 */
|
||||
function getPlatformIcon(platform: string): string {
|
||||
const p = platform.toLowerCase()
|
||||
if (p.includes('whatsapp')) return 'i-simple-icons-whatsapp'
|
||||
if (p.includes('telegram')) return 'i-simple-icons-telegram'
|
||||
if (p.includes('line')) return 'i-simple-icons-line'
|
||||
if (p.includes('qq')) return 'i-simple-icons-tencentqq'
|
||||
if (p.includes('wechat') || p.includes('weixin')) return 'i-simple-icons-wechat'
|
||||
if (p.includes('discord')) return 'i-simple-icons-discord'
|
||||
if (p.includes('instagram')) return 'i-simple-icons-instagram'
|
||||
if (p.includes('chatlab')) return 'i-heroicons-chat-bubble-left-right'
|
||||
return 'i-heroicons-chat-bubble-left-right'
|
||||
}
|
||||
|
||||
async function loadFormats() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await window.chatApi.getSupportedFormats()
|
||||
formats.value = result
|
||||
} catch {
|
||||
formats.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
selectedFormatId.value = null
|
||||
loadFormats()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function confirmSelection() {
|
||||
if (selectedFormatId.value) {
|
||||
isOpen.value = false
|
||||
emit('select', selectedFormatId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :title="t('home.formatSelector.title')">
|
||||
<template #body>
|
||||
<div class="min-h-[200px]">
|
||||
<!-- 提示 -->
|
||||
<p class="mb-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('home.formatSelector.hint') }}
|
||||
</p>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
|
||||
</div>
|
||||
|
||||
<!-- 格式列表(按平台分组) -->
|
||||
<div v-else class="max-h-[400px] space-y-3 overflow-y-auto pr-1">
|
||||
<div v-for="[platform, items] in groupedFormats" :key="platform">
|
||||
<div class="mb-1 flex items-center gap-1.5 px-1">
|
||||
<UIcon :name="getPlatformIcon(platform)" class="h-3.5 w-3.5 text-gray-400" />
|
||||
<span class="text-xs font-medium text-gray-400 uppercase">{{ platform }}</span>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<div
|
||||
v-for="format in items"
|
||||
:key="format.id"
|
||||
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 transition-colors"
|
||||
:class="
|
||||
selectedFormatId === format.id
|
||||
? 'bg-pink-50 ring-1 ring-pink-200 dark:bg-pink-500/10 dark:ring-pink-500/30'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
"
|
||||
@click="selectedFormatId = format.id"
|
||||
>
|
||||
<div
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors"
|
||||
:class="
|
||||
selectedFormatId === format.id
|
||||
? 'border-pink-500 bg-pink-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
"
|
||||
>
|
||||
<UIcon
|
||||
v-if="selectedFormatId === format.id"
|
||||
name="i-heroicons-check-20-solid"
|
||||
class="h-3 w-3 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ format.name }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
{{ format.extensions.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<UButton variant="ghost" color="neutral" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
<UButton :disabled="!selectedFormatId" @click="confirmSelection">
|
||||
{{ t('home.formatSelector.confirm') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { FileDropZone } from '@/components/UI'
|
||||
import FileListItem from './FileListItem.vue'
|
||||
import ChatSelector, { type ChatInfo } from './ChatSelector.vue'
|
||||
import FormatSelectorModal from './FormatSelectorModal.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -29,6 +30,10 @@ const {
|
||||
const showChatSelector = ref(false)
|
||||
const chatSelectorFilePath = ref('')
|
||||
|
||||
// 格式选择器状态(自动检测失败时的手动兜底)
|
||||
const showFormatSelector = ref(false)
|
||||
const formatSelectorFilePath = ref('')
|
||||
|
||||
// 自动生成会话索引(与 importFileFromPath 保持一致)
|
||||
async function autoGenerateSessionIndex(sessionId: string) {
|
||||
try {
|
||||
@@ -163,6 +168,10 @@ async function processFilePaths(paths: string[]) {
|
||||
const result = await sessionStore.importFileFromPath(paths[0])
|
||||
if (!result.success && result.error) {
|
||||
importError.value = translateError(result.error)
|
||||
// 格式无法识别时,记住文件路径以便手动选择格式
|
||||
if (result.error === 'error.unrecognized_format') {
|
||||
formatSelectorFilePath.value = paths[0]
|
||||
}
|
||||
if (result.diagnosisSuggestion) {
|
||||
diagnosisSuggestion.value = result.diagnosisSuggestion
|
||||
}
|
||||
@@ -194,6 +203,49 @@ async function processFilePaths(paths: string[]) {
|
||||
await sessionStore.mergeImportFiles(paths)
|
||||
}
|
||||
|
||||
// 手动格式选择后的导入处理
|
||||
async function handleFormatSelect(formatId: string) {
|
||||
const filePath = formatSelectorFilePath.value
|
||||
if (!filePath) return
|
||||
|
||||
importError.value = null
|
||||
diagnosisSuggestion.value = null
|
||||
importDiagnostics.value = null
|
||||
isImporting.value = true
|
||||
importProgress.value = { stage: 'detecting', progress: 0, message: '' }
|
||||
|
||||
const unsubscribe = window.chatApi.onImportProgress((progress) => {
|
||||
if (progress.stage === 'done') return
|
||||
importProgress.value = progress
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.chatApi.importWithOptions(filePath, { formatId })
|
||||
unsubscribe()
|
||||
|
||||
if (importProgress.value) {
|
||||
importProgress.value.progress = 100
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
if (result.success && result.sessionId) {
|
||||
await sessionStore.loadSessions()
|
||||
sessionStore.selectSession(result.sessionId)
|
||||
await autoGenerateSessionIndex(result.sessionId)
|
||||
await navigateToSession(result.sessionId)
|
||||
} else {
|
||||
importError.value = translateError(result.error || 'error.import_failed')
|
||||
}
|
||||
} catch (error) {
|
||||
importError.value = String(error)
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
setTimeout(() => {
|
||||
importProgress.value = null
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天选择后的导入处理(通用,适用于 Telegram 等多聊天格式)
|
||||
async function handleChatSelect(selectedChats: ChatInfo[]) {
|
||||
if (selectedChats.length === 0) return
|
||||
@@ -743,7 +795,18 @@ const getMergeFileProgressText = (file: MergeFileInfo) =>
|
||||
<span>{{ diagnosisSuggestion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<UButton v-if="hasImportLog" size="xs" @click="openLatestImportLog">{{ t('home.import.viewLog') }}</UButton>
|
||||
<div class="flex gap-2">
|
||||
<UButton v-if="hasImportLog" size="xs" @click="openLatestImportLog">{{ t('home.import.viewLog') }}</UButton>
|
||||
<UButton
|
||||
v-if="formatSelectorFilePath"
|
||||
size="xs"
|
||||
variant="soft"
|
||||
icon="i-heroicons-list-bullet"
|
||||
@click="showFormatSelector = true"
|
||||
>
|
||||
{{ t('home.formatSelector.manualSelect') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton :to="tutorialUrl" target="_blank" trailing-icon="i-heroicons-chevron-right-20-solid">
|
||||
@@ -752,5 +815,12 @@ const getMergeFileProgressText = (file: MergeFileInfo) =>
|
||||
|
||||
<!-- 聊天选择器(多聊天格式通用) -->
|
||||
<ChatSelector v-model:open="showChatSelector" :file-path="chatSelectorFilePath" @select="handleChatSelect" />
|
||||
|
||||
<!-- 格式选择器(自动检测失败时的手动兜底) -->
|
||||
<FormatSelectorModal
|
||||
v-model:open="showFormatSelector"
|
||||
:file-path="formatSelectorFilePath"
|
||||
@select="handleFormatSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user