feat: 支持更详细的格式诊断

This commit is contained in:
digua
2026-01-08 22:21:34 +08:00
committed by digua
parent b227078591
commit ae0b1efc07
6 changed files with 280 additions and 10 deletions
+31 -6
View File
@@ -6,7 +6,7 @@ import { ipcMain, app, dialog } from 'electron'
import * as databaseCore from '../database/core'
import * as worker from '../worker/workerManager'
import * as parser from '../parser'
import { detectFormat, type ParseProgress } from '../parser'
import { detectFormat, diagnoseFormat, type ParseProgress } from '../parser'
import type { IpcContext } from './types'
import { CURRENT_SCHEMA_VERSION, getPendingMigrationInfos, type MigrationInfo } from '../database/migrations'
@@ -74,14 +74,24 @@ export function registerChatHandlers(ctx: IpcContext): void {
}
const filePath = filePaths[0]
console.log('[IpcMain] File selected:', filePath)
// 检测文件格式(使用流式检测,只读取文件开头)
const formatFeature = detectFormat(filePath)
const format = formatFeature?.name || null
console.log('[IpcMain] Detected format:', format)
if (!format) {
return { error: 'error.unrecognized_format' }
// 使用诊断功能获取详细的错误信息
const diagnosis = diagnoseFormat(filePath)
// 返回详细的错误信息
return {
error: 'error.unrecognized_format',
diagnosis: {
suggestion: diagnosis.suggestion,
partialMatches: diagnosis.partialMatches.map((m) => ({
formatName: m.formatName,
missingFields: m.missingFields,
})),
},
}
}
return { filePath, format }
@@ -95,8 +105,6 @@ export function registerChatHandlers(ctx: IpcContext): void {
* 导入聊天记录(流式版本)
*/
ipcMain.handle('chat:import', async (_, filePath: string) => {
console.log('[IpcMain] chat:import called with:', filePath)
try {
// Send progress: detecting format (message not used by frontend, stage-based translation)
win.webContents.send('chat:importProgress', {
@@ -128,6 +136,23 @@ export function registerChatHandlers(ctx: IpcContext): void {
progress: 0,
message: result.error,
})
// 如果是格式不识别错误,提供诊断信息
if (result.error === 'error.unrecognized_format') {
const diagnosis = diagnoseFormat(filePath)
return {
success: false,
error: result.error,
diagnosis: {
suggestion: diagnosis.suggestion,
partialMatches: diagnosis.partialMatches.map((m) => ({
formatName: m.formatName,
missingFields: m.missingFields,
})),
},
}
}
return { success: false, error: result.error }
}
} catch (error) {
+12
View File
@@ -16,6 +16,7 @@ import type {
ParsedMeta,
ParsedMember,
ParsedMessage,
FormatDiagnosis,
} from './types'
// ==================== 全局嗅探器实例 ====================
@@ -34,6 +35,16 @@ export function detectFormat(filePath: string): FormatFeature | null {
return sniffer.sniff(filePath)
}
/**
* 诊断文件格式
* 当检测失败时,返回详细的诊断信息,帮助用户了解问题所在
* @param filePath 文件路径
* @returns 诊断结果,包含每个格式的匹配详情和建议
*/
export function diagnoseFormat(filePath: string): FormatDiagnosis {
return sniffer.diagnose(filePath)
}
/**
* 获取文件对应的解析器
* @param filePath 文件路径
@@ -199,6 +210,7 @@ export type {
ParsedMeta,
ParsedMember,
ParsedMessage,
FormatDiagnosis,
}
// ==================== 导出嗅探器(高级用法) ====================
+153 -1
View File
@@ -5,7 +5,7 @@
import * as fs from 'fs'
import * as path from 'path'
import type { FormatFeature, FormatModule, Parser } from './types'
import type { FormatFeature, FormatModule, Parser, FormatMatchCheck, FormatDiagnosis } from './types'
/** 文件头检测大小 (8KB) */
const HEAD_SIZE = 8 * 1024
@@ -47,6 +47,29 @@ function matchRequiredFields(headContent: string, fields: string[]): boolean {
})
}
/**
* 检查必需字段并返回详细结果
*/
function checkRequiredFieldsDetail(
headContent: string,
fields: string[]
): { allMatch: boolean; missing: string[] } {
const missing: string[] = []
for (const field of fields) {
const pattern = new RegExp(`"${field.replace('.', '"\\s*:\\s*.*"')}"\\s*:`)
const found = pattern.test(headContent) || headContent.includes(`"${field}"`)
if (!found) {
missing.push(field)
}
}
return {
allMatch: missing.length === 0,
missing,
}
}
/**
* 格式嗅探器
* 管理所有格式特征,负责检测文件格式
@@ -123,6 +146,135 @@ export class FormatSniffer {
return this.formats.map((m) => m.feature)
}
/**
* 诊断文件格式
* 返回详细的匹配信息,用于提供更好的错误提示
* @param filePath 文件路径
* @returns 诊断结果,包含每个格式的匹配详情
*/
diagnose(filePath: string): FormatDiagnosis {
const ext = getExtension(filePath)
const headContent = readFileHead(filePath)
const checks: FormatMatchCheck[] = []
const partialMatches: FormatMatchCheck[] = []
let matchedFormat: FormatFeature | null = null
for (const { feature } of this.formats) {
const check = this.checkFeatureDetail(feature, ext, headContent)
checks.push(check)
if (check.fullMatch && !matchedFormat) {
matchedFormat = feature
} else if (check.extensionMatch && !check.fullMatch) {
partialMatches.push(check)
}
}
// 生成诊断建议
const suggestion = this.generateSuggestion(ext, partialMatches, headContent)
return {
recognized: matchedFormat !== null,
matchedFormat,
checks,
partialMatches,
suggestion,
}
}
/**
* 检查单个格式的匹配详情
*/
private checkFeatureDetail(feature: FormatFeature, ext: string, headContent: string): FormatMatchCheck {
const result: FormatMatchCheck = {
formatId: feature.id,
formatName: feature.name,
extensionMatch: feature.extensions.includes(ext),
headSignatureMatch: null,
requiredFieldsMatch: null,
missingFields: [],
fullMatch: false,
}
// 扩展名不匹配,直接返回
if (!result.extensionMatch) {
return result
}
const { signatures } = feature
// 检查文件头签名
if (signatures.head && signatures.head.length > 0) {
result.headSignatureMatch = matchHeadSignatures(headContent, signatures.head)
}
// 检查必需字段
if (signatures.requiredFields && signatures.requiredFields.length > 0) {
const { allMatch, missing } = checkRequiredFieldsDetail(headContent, signatures.requiredFields)
result.requiredFieldsMatch = allMatch
result.missingFields = missing
}
// 检查字段值模式
let fieldPatternsMatch = true
if (signatures.fieldPatterns) {
for (const [, pattern] of Object.entries(signatures.fieldPatterns)) {
if (!pattern.test(headContent)) {
fieldPatternsMatch = false
break
}
}
}
// 判断是否完全匹配
result.fullMatch =
result.extensionMatch &&
(result.headSignatureMatch === null || result.headSignatureMatch) &&
(result.requiredFieldsMatch === null || result.requiredFieldsMatch) &&
fieldPatternsMatch
return result
}
/**
* 生成诊断建议信息
*/
private generateSuggestion(ext: string, partialMatches: FormatMatchCheck[], headContent: string): string {
if (partialMatches.length === 0) {
return `没有找到匹配扩展名 "${ext}" 的格式,请检查文件类型是否正确`
}
// 找到最可能的格式(按优先级排序后的第一个部分匹配)
const mostLikely = partialMatches[0]
// 构建详细的建议信息
const issues: string[] = []
if (mostLikely.headSignatureMatch === false) {
issues.push('文件头签名不匹配')
}
if (mostLikely.missingFields.length > 0) {
issues.push(`缺少必需字段: ${mostLikely.missingFields.join(', ')}`)
}
if (issues.length > 0) {
return `文件疑似 ${mostLikely.formatName} 格式,但存在以下问题:${issues.join('')}`
}
// 如果是 JSON 文件,提供额外提示
if (ext === '.json') {
// 检查文件头是否能看到有效的 JSON 结构
const trimmed = headContent.trim()
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return '文件内容不是有效的 JSON 格式'
}
}
return `扩展名匹配 ${mostLikely.formatName} 格式,但内容结构不符合预期`
}
/**
* 检查特征是否匹配
*/
+38
View File
@@ -147,6 +147,44 @@ export interface FormatModule {
preprocessor?: Preprocessor
}
// ==================== 诊断结果类型 ====================
/**
* 单个格式的匹配检查结果
*/
export interface FormatMatchCheck {
/** 格式 ID */
formatId: string
/** 格式显示名称 */
formatName: string
/** 扩展名是否匹配 */
extensionMatch: boolean
/** 文件头签名是否匹配(如果定义了) */
headSignatureMatch: boolean | null
/** 必需字段是否匹配(如果定义了) */
requiredFieldsMatch: boolean | null
/** 缺失的必需字段(如果有) */
missingFields: string[]
/** 是否完全匹配 */
fullMatch: boolean
}
/**
* 格式诊断结果
*/
export interface FormatDiagnosis {
/** 是否成功识别到格式 */
recognized: boolean
/** 识别到的格式(如果有) */
matchedFormat: FormatFeature | null
/** 所有格式的检查详情 */
checks: FormatMatchCheck[]
/** 部分匹配的格式(扩展名匹配但内容不匹配) */
partialMatches: FormatMatchCheck[]
/** 诊断建议信息 */
suggestion: string
}
// ==================== 工具类型 ====================
/**
+21 -2
View File
@@ -41,9 +41,28 @@ interface MigrationCheckResult {
pendingMigrations: MigrationInfo[]
}
// 格式诊断信息(简化版,用于前端显示)
interface FormatDiagnosisSimple {
suggestion: string
partialMatches: Array<{
formatName: string
missingFields: string[]
}>
}
interface ChatApi {
selectFile: () => Promise<{ filePath?: string; format?: string; error?: string } | null>
import: (filePath: string) => Promise<{ success: boolean; sessionId?: string; error?: string }>
selectFile: () => Promise<{
filePath?: string
format?: string
error?: string
diagnosis?: FormatDiagnosisSimple
} | null>
import: (filePath: string) => Promise<{
success: boolean
sessionId?: string
error?: string
diagnosis?: FormatDiagnosisSimple
}>
getSessions: () => Promise<AnalysisSession[]>
getSession: (sessionId: string) => Promise<AnalysisSession | null>
deleteSession: (sessionId: string) => Promise<boolean>
+25 -1
View File
@@ -11,6 +11,7 @@ const sessionStore = useSessionStore()
const { isImporting, importProgress } = storeToRefs(sessionStore)
const importError = ref<string | null>(null)
const diagnosisSuggestion = ref<string | null>(null)
const hasImportLog = ref(false)
const router = useRouter()
@@ -48,11 +49,16 @@ async function checkImportLog() {
// 处理文件选择(点击选择)
async function handleClickImport() {
importError.value = null
diagnosisSuggestion.value = null
hasImportLog.value = false
const result = await sessionStore.importFile()
// Skip showing error if user just cancelled the file dialog
if (!result.success && result.error && result.error !== 'error.no_file_selected') {
importError.value = translateError(result.error)
// 保存诊断建议(如果有)
if (result.diagnosisSuggestion) {
diagnosisSuggestion.value = result.diagnosisSuggestion
}
await checkImportLog()
} else if (result.success && sessionStore.currentSessionId) {
await navigateToSession(sessionStore.currentSessionId)
@@ -67,10 +73,15 @@ async function handleFileDrop({ paths }: { files: File[]; paths: string[] }) {
}
importError.value = null
diagnosisSuggestion.value = null
hasImportLog.value = false
const result = await sessionStore.importFileFromPath(paths[0])
if (!result.success && result.error) {
importError.value = translateError(result.error)
// 保存诊断建议(如果有)
if (result.diagnosisSuggestion) {
diagnosisSuggestion.value = result.diagnosisSuggestion
}
await checkImportLog()
} else if (result.success && sessionStore.currentSessionId) {
await navigateToSession(sessionStore.currentSessionId)
@@ -187,11 +198,24 @@ function getProgressDetail(): string {
</FileDropZone>
<!-- Error Message -->
<div v-if="importError" class="flex flex-col items-center gap-3 rounded-lg bg-red-50 px-4 py-4 dark:bg-red-900/20">
<div
v-if="importError"
class="flex max-w-lg flex-col items-center gap-3 rounded-lg bg-red-50 px-4 py-4 dark:bg-red-900/20"
>
<div class="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<UIcon name="i-heroicons-exclamation-circle" class="h-5 w-5 shrink-0" />
<span>{{ importError }}</span>
</div>
<!-- 诊断建议如果有 -->
<div
v-if="diagnosisSuggestion"
class="w-full rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-200"
>
<div class="flex items-start gap-2">
<UIcon name="i-heroicons-light-bulb" class="mt-0.5 h-4 w-4 shrink-0" />
<span>{{ diagnosisSuggestion }}</span>
</div>
</div>
<UButton v-if="hasImportLog" size="xs" @click="openLatestImportLog">{{ t('home.import.viewLog') }}</UButton>
</div>