Merge pull request #345 from ArkciaTheDragon/fix/template-import-message-content

fix(template): preserve advanced template imports
This commit is contained in:
且炼时光 authored and GitHub committed 2026-08-26 15:01:59 +08:00
commit ed9ef9416d
7 files changed
+370 -103

No files matched your search

+6 -2
View File
@@ -10,7 +10,7 @@ import {
DataInvalidFormatError,
DataInvalidJsonError,
} from './errors';
import { toErrorWithCode } from '../../utils/error';
import { stripErrorCodeMarkers, toErrorWithCode } from '../../utils/error';
/**
* 数据导入导出管理器
@@ -142,7 +142,11 @@ export class DataManager implements IDataManager {
await service.importData(dataToImport[dataKey]);
console.log(`Successfully imported ${dataKey}`);
} catch (error) {
const errorMessage = `Failed to import ${dataKey}: ${error instanceof Error ? error.message : String(error)}`;
// Nested structured errors may contain markers outside the leading position.
const detail = stripErrorCodeMarkers(
error instanceof Error ? error.message : String(error)
);
const errorMessage = `Failed to import ${dataKey}: ${detail}`;
errors.push(errorMessage);
console.error(errorMessage, error);
}
+95 -73
View File
@@ -7,6 +7,7 @@ import { BuiltinTemplateLanguage, ITemplateLanguageService } from './languageSer
import { CORE_SERVICE_KEYS } from '../../constants/storage-keys';
import { ImportExportError } from '../../interfaces/import-export';
import { IMPORT_EXPORT_ERROR_CODES, TEMPLATE_ERROR_CODES } from '../../constants/error-codes';
import { stripErrorCodeMarkers } from '../../utils/error';
@@ -332,11 +333,67 @@ export class TemplateManager implements ITemplateManager {
}
}
/**
* Normalize and validate a template from imported data.
*/
private normalizeImportedTemplate(
item: unknown,
builtinTemplateIds: ReadonlySet<string> = new Set<string>()
): Template {
if (typeof item !== 'object' || item === null) {
throw new TemplateValidationError('Template must be an object');
}
const template = item as Partial<Template>;
if (typeof template.id !== 'string' || typeof template.name !== 'string') {
throw new TemplateValidationError('Template id and name must be strings');
}
// Reject corrupt shapes before applying defaults to legacy metadata.
const rawMetadata = template.metadata;
if (
rawMetadata !== undefined &&
(typeof rawMetadata !== 'object' || rawMetadata === null || Array.isArray(rawMetadata))
) {
throw new TemplateValidationError('Template metadata must be a non-array object when present');
}
const metadata: Partial<Template['metadata']> = rawMetadata ?? {};
let finalTemplateId = template.id;
let finalTemplateName = template.name;
if (builtinTemplateIds.has(finalTemplateId)) {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
finalTemplateId = `user-${finalTemplateId}-${timestamp}-${random}`;
finalTemplateName = `${finalTemplateName} (Imported copy)`;
console.warn(`Detected conflict with built-in template ID: ${template.id}, renamed to: ${finalTemplateId}`);
}
const userTemplate: Template = {
...template,
id: finalTemplateId,
name: finalTemplateName,
isBuiltin: false,
metadata: {
version: metadata.version ?? '1.0.0',
lastModified: Date.now(),
templateType: metadata.templateType ?? 'optimize',
author: metadata.author ?? 'User',
...(metadata.description !== undefined && { description: metadata.description }),
...(metadata.language !== undefined && { language: metadata.language })
}
} as Template;
this.validateTemplateSchema(userTemplate);
this.validateTemplateId(userTemplate.id);
return userTemplate;
}
/**
* 导入用户模板
*/
async importData(data: any): Promise<void> {
// 基本格式验证:必须是数组
if (!Array.isArray(data)) {
throw new ImportExportError(
'Invalid template data format: data must be an array of template objects',
@@ -346,77 +403,44 @@ export class TemplateManager implements ITemplateManager {
);
}
const templates = data as Template[];
// Get existing user templates to clean up (替换模式)
const existingTemplates = await this.listTemplates();
const userTemplateIds = existingTemplates
.filter(template => !template.isBuiltin)
.map(template => template.id);
const builtinTemplateIds = new Set(
existingTemplates
.filter(template => template.isBuiltin)
.map(template => template.id)
);
const importedTemplates: Template[] = [];
// Delete all existing user templates
for (const id of userTemplateIds) {
// Validate and normalize the complete replacement before mutating storage.
// This prevents malformed backups from deleting existing user templates.
for (const [index, item] of data.entries()) {
try {
await this.deleteTemplate(id);
const userTemplate = this.normalizeImportedTemplate(item, builtinTemplateIds);
// Match saveTemplate's duplicate-ID behavior: the last entry wins.
const duplicateIndex = importedTemplates.findIndex(
importedTemplate => importedTemplate.id === userTemplate.id
);
if (duplicateIndex >= 0) {
importedTemplates[duplicateIndex] = userTemplate;
} else {
importedTemplates.push(userTemplate);
}
} catch (error) {
console.warn(`Failed to delete template ${id}:`, error);
const message = stripErrorCodeMarkers(
error instanceof Error ? error.message : String(error)
);
throw new ImportExportError(
`Invalid template data at index ${index}: ${message}`,
await this.getDataType(),
error as Error,
IMPORT_EXPORT_ERROR_CODES.VALIDATION_ERROR,
);
}
}
const failedTemplates: { template: Template; error: Error }[] = [];
// Import each template individually, capturing failures
for (const template of templates) {
try {
// 使用 validateData 验证单个模板
if (!this.validateSingleTemplate(template)) {
console.warn(`Skipping invalid template configuration:`, template);
failedTemplates.push({ template, error: new Error('Invalid template configuration') });
continue;
}
// 检查是否与内置模板ID冲突
const builtinTemplate = existingTemplates.find(t => t.id === template.id && t.isBuiltin);
let finalTemplateId = template.id;
let finalTemplateName = template.name;
if (builtinTemplate) {
// 为冲突的模板生成新的ID和名称
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 6);
finalTemplateId = `user-${template.id}-${timestamp}-${random}`;
finalTemplateName = `${template.name} (Imported copy)`;
console.warn(`Detected conflict with built-in template ID: ${template.id}, renamed to: ${finalTemplateId}`);
}
// 确保导入的模板标记为用户模板,并为缺失字段提供默认值
const userTemplate: Template = {
...template,
id: finalTemplateId,
name: finalTemplateName,
isBuiltin: false,
metadata: {
version: template.metadata?.version || '1.0.0',
lastModified: Date.now(), // 更新为当前时间
templateType: template.metadata?.templateType || 'optimize', // 为旧版本数据提供默认类型
author: template.metadata?.author || 'User', // 导入的模板标记为用户创建
...(template.metadata?.description && { description: template.metadata.description }),
...(template.metadata?.language && { language: template.metadata.language }) // 只在原本有language字段时才保留
}
};
await this.saveTemplate(userTemplate);
console.log(`Imported template: ${finalTemplateId} (${finalTemplateName})`);
} catch (error) {
console.warn('Failed to import template:', error);
failedTemplates.push({ template, error: error as Error });
}
}
if (failedTemplates.length > 0) {
console.warn(`Failed to import ${failedTemplates.length} templates`);
// 不抛出错误,允许部分成功的导入
}
// User templates are stored under one key, so replace them in one write.
await this.persistUserTemplates(importedTemplates);
}
/**
@@ -441,14 +465,12 @@ export class TemplateManager implements ITemplateManager {
* 验证单个模板配置
*/
private validateSingleTemplate(item: any): boolean {
return typeof item === 'object' &&
item !== null &&
typeof item.id === 'string' &&
typeof item.name === 'string' &&
typeof item.content === 'string' &&
typeof item.isBuiltin === 'boolean' &&
typeof item.metadata === 'object' &&
item.metadata !== null;
try {
this.normalizeImportedTemplate(item);
return true;
} catch {
return false;
}
}
}
+7
View File
@@ -18,6 +18,13 @@ export function isStructuredErrorLike(value: unknown): value is StructuredErrorL
return isRecord(value) && typeof value.code === 'string'
}
const ERROR_CODE_MARKER_PATTERN = /\[error\.[^\]]+\]\s*/g
/** Remove structured error-code markers before nesting a message in user-facing details. */
export function stripErrorCodeMarkers(message: string): string {
return message.replace(ERROR_CODE_MARKER_PATTERN, '')
}
/**
* Normalize unknown values into an Error instance while preserving `{ code, params }`
* when available (e.g., errors crossing IPC boundaries).
@@ -8,6 +8,7 @@ import { IPreferenceService } from '../../../src/services/preference/types';
import { ContextRepo } from '../../../src/services/context/types';
import { MemoryStorageProvider } from '../../../src/services/storage/memoryStorageProvider';
import { DATA_ERROR_CODES } from '../../../src/constants/error-codes';
import { TemplateStorageError, TemplateValidationError } from '../../../src/services/template/errors';
describe('DataManager', () => {
let dataManager: DataManager;
@@ -214,6 +215,38 @@ describe('DataManager', () => {
.rejects.toMatchObject({ code: DATA_ERROR_CODES.INVALID_JSON });
});
it('should not leak inner error-code placeholders into the aggregate details', async () => {
// Cover markers at both the leading and nested positions.
const leadingMarker = new TemplateStorageError('Failed to save user templates: Storage error');
expect(leadingMarker.message).toMatch(/^\[error\.template\.storage\]/);
const nestedMarker = new Error(
`Invalid template data at index 0: ${new TemplateValidationError('Template validation failed: content: Too small').message}`
);
expect(nestedMarker.message).toContain('[error.template.validation]');
expect(nestedMarker.message).not.toMatch(/^\[error\./);
const importData = {
version: 1,
data: {
userTemplates: [{ id: 'tpl1', name: 'T', content: 'c', isBuiltin: false, metadata: { templateType: 'optimize', version: '1.0', lastModified: 0 } }],
},
};
const payload = JSON.stringify(importData);
for (const innerError of [leadingMarker, nestedMarker]) {
vi.mocked(mockTemplateManager.importData).mockRejectedValue(innerError);
const error = await dataManager.importAllData(payload).catch((reason: unknown) => reason) as {
code?: string;
params?: { details?: string };
};
expect(error.code).toBe(DATA_ERROR_CODES.IMPORT_PARTIAL_FAILED);
expect(error.params?.details).toContain('Failed to import userTemplates');
expect(error.params?.details).not.toContain('[error.');
}
});
it('should throw an error for data without a "data" property in new format', async () => {
await expect(dataManager.importAllData(JSON.stringify({ version: 1 })))
.rejects.toMatchObject({ code: DATA_ERROR_CODES.INVALID_FORMAT });
@@ -4,6 +4,7 @@ import { Template } from '../../../src/services/template/types';
import { MemoryStorageProvider } from '../../../src/services/storage/memoryStorageProvider';
import { TemplateLanguageService } from '../../../src/services/template/languageService';
import { PreferenceService } from '../../../src/services/preference/service';
import { IMPORT_EXPORT_ERROR_CODES } from '../../../src/constants/error-codes';
describe('TemplateManager Import/Export', () => {
let templateManager: TemplateManager;
@@ -244,12 +245,164 @@ describe('TemplateManager Import/Export', () => {
expect(importedTemplate?.metadata.lastModified).toBeGreaterThan(0); // 应该被设置
});
it('should skip invalid templates', async () => {
it('should reject corrupt metadata types instead of silently defaulting them', async () => {
// Invalid metadata containers must not be normalized into valid defaults.
const existingTemplate: Template = {
id: 'existing-template',
name: 'Existing Template',
content: 'Existing content',
isBuiltin: false,
metadata: {
version: '1.0.0',
lastModified: Date.now(),
templateType: 'optimize',
author: 'User'
}
};
await templateManager.saveTemplate(existingTemplate);
const corruptShapes: Array<[string, unknown]> = [
['null', null],
['string', 'corrupt'],
['array', []],
['number', 42],
['boolean', true],
];
for (const [label, metadata] of corruptShapes) {
const item = {
id: 'corrupt-template',
name: `Corrupt metadata (${label})`,
content: 'Corrupt content',
isBuiltin: false,
metadata
};
expect(await templateManager.validateData([item])).toBe(false);
await expect(templateManager.importData([item])).rejects.toMatchObject({
code: IMPORT_EXPORT_ERROR_CODES.VALIDATION_ERROR,
message: expect.stringContaining(
'Template metadata must be a non-array object when present'
)
});
}
// The corrupt batches must not have replaced the existing user templates.
const userTemplates = (await templateManager.listTemplates())
.filter(template => !template.isBuiltin);
expect(userTemplates).toHaveLength(1);
expect(userTemplates[0].id).toBe('existing-template');
});
it('should treat absent metadata exactly like an empty metadata object', async () => {
const absent = { id: 'absent-metadata', name: 'Absent', content: 'Content', isBuiltin: false };
const empty = {
id: 'empty-metadata',
name: 'Empty',
content: 'Content',
isBuiltin: false,
metadata: {}
};
expect(await templateManager.validateData([absent])).toBe(true);
expect(await templateManager.validateData([empty])).toBe(true);
await templateManager.importData([absent, empty]);
const imported = (await templateManager.listTemplates())
.filter(template => !template.isBuiltin);
expect(imported).toHaveLength(2);
const [a, e] = ['absent-metadata', 'empty-metadata']
.map(id => imported.find(template => template.id === id));
for (const template of [a, e]) {
expect(template?.metadata.version).toBe('1.0.0');
expect(template?.metadata.templateType).toBe('optimize');
expect(template?.metadata.author).toBe('User');
expect(template?.metadata.lastModified).toBeGreaterThan(0);
}
expect(Object.keys(a!.metadata).sort()).toEqual(Object.keys(e!.metadata).sort());
});
it('should align preflight validation with import normalization and ID rules', async () => {
const incompleteMetadata = [
{
id: 'legacy-template',
name: 'Legacy Template',
content: 'Legacy content',
isBuiltin: false,
metadata: {
author: 'User'
}
}
];
const invalidId = [
{
id: 'INVALID-ID',
name: 'Invalid ID Template',
content: 'Invalid content',
isBuiltin: false,
metadata: {
version: '1.0.0',
lastModified: Date.now(),
templateType: 'optimize',
author: 'User'
}
}
];
expect(await templateManager.validateData(incompleteMetadata)).toBe(true);
expect(await templateManager.validateData(invalidId)).toBe(false);
});
it('should import advanced templates with message-array content', async () => {
const messages: Template['content'] = [
{ role: 'system', content: 'You are a prompt optimizer.' },
{ role: 'user', content: '{{originalPrompt}}' }
];
const importData: Template[] = [
{
id: 'advanced-template',
name: 'Advanced Template',
content: messages,
isBuiltin: false,
metadata: {
version: '1.0.0',
lastModified: Date.now(),
templateType: 'optimize',
author: 'User'
}
}
];
expect(await templateManager.validateData(importData)).toBe(true);
await templateManager.importData(importData);
const importedTemplate = (await templateManager.listTemplates())
.find(template => template.id === 'advanced-template');
expect(importedTemplate?.content).toEqual(messages);
});
it('should reject invalid templates without deleting existing templates', async () => {
const existingTemplate: Template = {
id: 'existing-template',
name: 'Existing Template',
content: 'Existing content',
isBuiltin: false,
metadata: {
version: '1.0.0',
lastModified: Date.now(),
templateType: 'optimize',
author: 'User'
}
};
await templateManager.saveTemplate(existingTemplate);
const importData = [
{
// 缺少id字段
name: 'Invalid Template 1',
content: 'Invalid content 1',
// Missing id field.
name: 'Invalid Template',
content: 'Invalid content',
isBuiltin: false,
metadata: {
version: '1.0.0',
@@ -272,22 +425,21 @@ describe('TemplateManager Import/Export', () => {
}
];
// 应该不抛出错误,只是跳过无效模板
await expect(templateManager.importData(importData)).resolves.not.toThrow();
await expect(templateManager.importData(importData))
.rejects.toThrow('Invalid template data at index 0');
// 验证有效模板被导入
const afterImport = await templateManager.listTemplates();
const userTemplates = afterImport.filter(t => !t.isBuiltin);
expect(userTemplates.length).toBe(1);
expect(userTemplates[0].id).toBe('valid-template');
const userTemplates = (await templateManager.listTemplates())
.filter(template => !template.isBuiltin);
expect(userTemplates).toHaveLength(1);
expect(userTemplates[0].id).toBe('existing-template');
});
it('should handle import errors gracefully', async () => {
const importData: Template[] = [
it('should not embed inner error-code markers in the rejection details', async () => {
const importData = [
{
id: 'error-template',
name: 'Error Template',
content: 'Error content',
id: 'schema-fail',
name: 'Schema Fail',
content: '', // fails templateSchema: content must be non-empty
isBuiltin: false,
metadata: {
version: '1.0.0',
@@ -298,11 +450,59 @@ describe('TemplateManager Import/Export', () => {
}
];
// 模拟saveTemplate错误
vi.spyOn(templateManager, 'saveTemplate').mockRejectedValue(new Error('Save template error'));
const error = await templateManager.importData(importData)
.catch((reason: unknown) => reason) as {
code?: string;
message?: string;
params?: { details?: string };
};
expect(error.code).toBe(IMPORT_EXPORT_ERROR_CODES.VALIDATION_ERROR);
expect(error.message).toContain('Invalid template data at index 0');
expect(error.message).toContain('Template validation failed');
expect(error.message).not.toContain('[error.');
expect(error.params?.details).not.toContain('[error.');
});
// 应该不抛出错误,只是记录失败
await expect(templateManager.importData(importData)).resolves.not.toThrow();
it('should preserve existing templates when the replacement write fails', async () => {
const existingTemplate: Template = {
id: 'existing-template',
name: 'Existing Template',
content: 'Existing content',
isBuiltin: false,
metadata: {
version: '1.0.0',
lastModified: Date.now(),
templateType: 'optimize',
author: 'User'
}
};
await templateManager.saveTemplate(existingTemplate);
const importData: Template[] = [
{
id: 'replacement-template',
name: 'Replacement Template',
content: 'Replacement content',
isBuiltin: false,
metadata: {
version: '1.0.0',
lastModified: Date.now(),
templateType: 'optimize',
author: 'User'
}
}
];
const setItemSpy = vi.spyOn(storageProvider, 'setItem')
.mockRejectedValueOnce(new Error('Storage error'));
await expect(templateManager.importData(importData))
.rejects.toThrow('Failed to save user templates');
setItemSpy.mockRestore();
const userTemplates = (await templateManager.listTemplates())
.filter(template => !template.isBuiltin);
expect(userTemplates).toHaveLength(1);
expect(userTemplates[0].id).toBe('existing-template');
});
});
+7 -7
View File
@@ -402,7 +402,7 @@ function setupPreferenceHandlers() {
ipcMain.handle('preference-getDataType', async (event) => {
try {
const result = preferenceService.getDataType();
const result = await preferenceService.getDataType();
return createSuccessResponse(result);
} catch (error) {
return createErrorResponse(error);
@@ -1453,7 +1453,7 @@ function setupIPC() {
ipcMain.handle('model-getDataType', async (event) => {
try {
const result = modelManager.getDataType();
const result = await modelManager.getDataType();
return createSuccessResponse(result);
} catch (error) {
return createErrorResponse(error);
@@ -1575,7 +1575,7 @@ function setupIPC() {
ipcMain.handle('template-getDataType', async (event) => {
try {
const result = templateManager.getDataType();
const result = await templateManager.getDataType();
return createSuccessResponse(result);
} catch (error) {
return createErrorResponse(error);
@@ -1586,7 +1586,7 @@ function setupIPC() {
try {
// 清理Vue响应式对象,防止IPC序列化错误
const safeData = safeSerialize(data);
const result = templateManager.validateData(safeData);
const result = await templateManager.validateData(safeData);
return createSuccessResponse(result);
} catch (error) {
return createErrorResponse(error);
@@ -1623,7 +1623,7 @@ function setupIPC() {
ipcMain.handle('template-getSupportedLanguages', async (event, template) => {
try {
const result = templateManager.getSupportedLanguages(template);
const result = await templateManager.getSupportedLanguages(template);
return createSuccessResponse(result);
} catch (error) {
return createErrorResponse(error);
@@ -1751,7 +1751,7 @@ function setupIPC() {
ipcMain.handle('history-getDataType', async (event) => {
try {
const result = historyManager.getDataType();
const result = await historyManager.getDataType();
return createSuccessResponse(result);
} catch (error) {
return createErrorResponse(error);
@@ -1904,7 +1904,7 @@ function setupIPC() {
ipcMain.handle('context-getDataType', async (event) => {
try {
const result = contextRepo.getDataType();
const result = await contextRepo.getDataType();
return createSuccessResponse(result);
} catch (error) {
return createErrorResponse(error);
+2 -1
View File
@@ -826,6 +826,7 @@ import {
} from '../utils/remote-snapshot-backup'
import { recordDataBackupCompleted } from '../utils/data-backup-reminder'
import { openExternalUrl } from '../utils/open-external-url'
import { formatErrorSummary } from '../utils/error'
interface Props {
show: boolean
@@ -1949,7 +1950,7 @@ const handleImport = async () => {
clearSelectedFile()
} catch (error) {
console.error('Import failed:', error)
toast.error(`${t('dataManager.import.failed')}: ${(error as Error).message}`)
toast.error(formatErrorSummary(t('dataManager.import.failed'), error))
} finally {
isImporting.value = false
}