mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-06 13:06:09 +08:00
feat: 支持更详细的格式诊断
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
// ==================== 导出嗅探器(高级用法) ====================
|
||||
|
||||
@@ -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} 格式,但内容结构不符合预期`
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查特征是否匹配
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ==================== 工具类型 ====================
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+21
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user