diff --git a/packages/ui/src/components/context-mode/ImportExportDialog.vue b/packages/ui/src/components/context-mode/ImportExportDialog.vue index b60adf76..e765a855 100644 --- a/packages/ui/src/components/context-mode/ImportExportDialog.vue +++ b/packages/ui/src/components/context-mode/ImportExportDialog.vue @@ -240,6 +240,7 @@ import { NText, } from "naive-ui"; import { useContextEditor } from '../../composables/context/useContextEditor'; +import { STANDARD_PROMPT_FORMAT } from '../../services/DataImportExportManager'; import type { ConversationMessage, ToolDefinition } from "@prompt-optimizer/core"; import type { StandardPromptData } from "../../types"; @@ -354,7 +355,8 @@ const IMPORT_JSON_SNIPPETS: Record = { }; // 导出预览数据 -const buildExportPayload = (): StandardPromptData => ({ +const buildExportPayload = (): StandardPromptData & { format: typeof STANDARD_PROMPT_FORMAT } => ({ + format: STANDARD_PROMPT_FORMAT, messages: props.messages.map((msg) => ({ role: msg.role, content: msg.content, diff --git a/packages/ui/src/composables/context/useContextEditor.ts b/packages/ui/src/composables/context/useContextEditor.ts index cbc0fe34..ac09e8f7 100644 --- a/packages/ui/src/composables/context/useContextEditor.ts +++ b/packages/ui/src/composables/context/useContextEditor.ts @@ -51,6 +51,8 @@ export function useContextEditor() { return t('contextEditor.feedback.formatLabels.openai') case 'conversation': return t('contextEditor.feedback.formatLabels.conversation') + case 'standard': + return t('contextEditor.feedback.formatLabels.conversation') default: return format.toUpperCase() } @@ -179,6 +181,13 @@ export function useContextEditor() { case 'conversation': result = converter.fromConversationMessages(data as Array>) break + case 'standard': { + const validation = converter.validate(data, 'standard') + result = validation.success + ? { success: true, data: data as StandardPromptData } + : { success: false, error: validation.error } + break + } default: result = { success: false, diff --git a/packages/ui/src/services/DataImportExportManager.ts b/packages/ui/src/services/DataImportExportManager.ts index cc84c287..83b9272d 100644 --- a/packages/ui/src/services/DataImportExportManager.ts +++ b/packages/ui/src/services/DataImportExportManager.ts @@ -6,6 +6,8 @@ import type { DataImportExport, StandardPromptData, ConversionResult, Conversati import { PromptDataConverter } from './PromptDataConverter' import { scanVariableNames } from '../utils/prompt-variables' +export const STANDARD_PROMPT_FORMAT = 'prompt-optimizer-standard' + export class DataImportExportManager implements DataImportExport { private converter = new PromptDataConverter() @@ -159,7 +161,7 @@ export class DataImportExportManager implements DataImportExport { /** * 自动检测数据格式 */ - detectFormat(data: unknown): 'langfuse' | 'openai' | 'conversation' | 'unknown' { + detectFormat(data: unknown): 'standard' | 'langfuse' | 'openai' | 'conversation' | 'unknown' { if (!data || typeof data !== 'object') { return 'unknown' } @@ -172,8 +174,21 @@ export class DataImportExportManager implements DataImportExport { return 'langfuse' } + const hasMessages = Array.isArray(dataObj.messages) + const metadata = dataObj.metadata + const isInternalStandard = metadata !== null && typeof metadata === 'object' && + !Array.isArray(metadata) && + (metadata as Record).origin === 'import_export_dialog' + + // StandardPromptData is an OpenAI-compatible superset. Preserve payloads + // carrying our export marker instead of converting them and losing metadata. + if (hasMessages && + (!dataObj.model || dataObj.format === STANDARD_PROMPT_FORMAT || isInternalStandard)) { + return 'standard' + } + // 检测OpenAI格式 - if (dataObj.messages && Array.isArray(dataObj.messages) && dataObj.model) { + if (hasMessages && dataObj.model) { return 'openai' } @@ -185,12 +200,6 @@ export class DataImportExportManager implements DataImportExport { return 'conversation' } - // 检测标准格式 - if (dataObj.messages && Array.isArray(dataObj.messages) && - (!dataObj.model || typeof dataObj.model === 'string')) { - return 'openai' // 当作OpenAI格式处理 - } - return 'unknown' } @@ -214,6 +223,14 @@ export class DataImportExportManager implements DataImportExport { detected_format: 'conversation' }) + case 'standard': { + const validation = this.converter.validate(jsonData, 'standard') + if (!validation.success) { + return { success: false, error: validation.error } + } + return { success: true, data: jsonData as StandardPromptData } + } + default: return { success: false, @@ -226,7 +243,7 @@ export class DataImportExportManager implements DataImportExport { private prepareExportData(data: StandardPromptData, format: 'standard' | 'openai' | 'template'): StandardPromptData | Record { switch (format) { case 'standard': - return data + return { ...data, format: STANDARD_PROMPT_FORMAT } case 'openai': { const openaiResult = this.converter.toOpenAI(data) diff --git a/packages/ui/src/services/PromptDataConverter.ts b/packages/ui/src/services/PromptDataConverter.ts index 4ac457cc..0ff78c58 100644 --- a/packages/ui/src/services/PromptDataConverter.ts +++ b/packages/ui/src/services/PromptDataConverter.ts @@ -459,13 +459,16 @@ export class PromptDataConverter implements DataConverter { // 私有方法:验证标准格式 private validateStandardFormat(data: unknown): ConversionResult { - if (!data || typeof data !== 'object') { + const isPlainObject = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value) + + if (!isPlainObject(data)) { return { success: false, error: 'Data must be an object' } } - const payload = data as { messages?: unknown } + const payload = data - if (!payload.messages || !Array.isArray(payload.messages)) { + if (!Array.isArray(payload.messages)) { return { success: false, error: 'Messages must be an array' } } @@ -474,7 +477,7 @@ export class PromptDataConverter implements DataConverter { return { success: false, error: `Invalid message at index ${index}` } } - const typedMessage = message as { role?: unknown; content?: unknown } + const typedMessage = message as Record if (!typedMessage.role || !['system', 'user', 'assistant', 'tool'].includes(String(typedMessage.role))) { return { success: false, error: `Invalid role in message ${index}` } @@ -482,6 +485,97 @@ export class PromptDataConverter implements DataConverter { if (typeof typedMessage.content !== 'string') { return { success: false, error: `Invalid content in message ${index}` } } + if (typedMessage.name !== undefined && typeof typedMessage.name !== 'string') { + return { success: false, error: `Invalid name in message ${index}` } + } + if (typedMessage.tool_call_id !== undefined && typeof typedMessage.tool_call_id !== 'string') { + return { success: false, error: `Invalid tool_call_id in message ${index}` } + } + if (typedMessage.tool_calls !== undefined) { + if (!Array.isArray(typedMessage.tool_calls)) { + return { success: false, error: `Invalid tool_calls in message ${index}` } + } + + for (const toolCall of typedMessage.tool_calls) { + if (!isPlainObject(toolCall) || typeof toolCall.id !== 'string' || + toolCall.type !== 'function' || !isPlainObject(toolCall.function) || + typeof toolCall.function.name !== 'string' || + typeof toolCall.function.arguments !== 'string') { + return { success: false, error: `Invalid tool call in message ${index}` } + } + } + } + } + + if (payload.tools !== undefined) { + if (!Array.isArray(payload.tools)) { + return { success: false, error: 'Tools must be an array' } + } + + for (const [index, tool] of payload.tools.entries()) { + if (!isPlainObject(tool) || tool.type !== 'function' || + !isPlainObject(tool.function) || typeof tool.function.name !== 'string' || + (tool.function.description !== undefined && typeof tool.function.description !== 'string') || + (tool.function.parameters !== undefined && + (tool.function.parameters === null || typeof tool.function.parameters !== 'object'))) { + return { success: false, error: `Invalid tool at index ${index}` } + } + } + } + + if (payload.model !== undefined && typeof payload.model !== 'string') { + return { success: false, error: 'Model must be a string' } + } + + for (const field of ['temperature', 'max_tokens', 'top_p', 'frequency_penalty', 'presence_penalty']) { + const value = payload[field] + if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value))) { + return { success: false, error: `${field} must be a finite number` } + } + } + + if (payload.stop !== undefined && + typeof payload.stop !== 'string' && + (!Array.isArray(payload.stop) || !payload.stop.every(item => typeof item === 'string'))) { + return { success: false, error: 'Stop must be a string or an array of strings' } + } + + if (payload.stream !== undefined && typeof payload.stream !== 'boolean') { + return { success: false, error: 'Stream must be a boolean' } + } + + if (payload.metadata !== undefined) { + if (!isPlainObject(payload.metadata)) { + return { success: false, error: 'Metadata must be an object' } + } + + const metadata = payload.metadata + const metadataSources = ['langfuse', 'openai', 'conversation', 'manual'] + if (metadata.source !== undefined && + (typeof metadata.source !== 'string' || !metadataSources.includes(metadata.source))) { + return { success: false, error: 'Invalid metadata source' } + } + if (metadata.timestamp !== undefined && typeof metadata.timestamp !== 'string') { + return { success: false, error: 'Metadata timestamp must be a string' } + } + if (metadata.template_info !== undefined) { + if (!isPlainObject(metadata.template_info)) { + return { success: false, error: 'Metadata template_info must be an object' } + } + + const templateInfo = metadata.template_info + if (templateInfo.name !== undefined && typeof templateInfo.name !== 'string') { + return { success: false, error: 'Template name must be a string' } + } + if (templateInfo.version !== undefined && typeof templateInfo.version !== 'string') { + return { success: false, error: 'Template version must be a string' } + } + if (templateInfo.variables !== undefined && + (!Array.isArray(templateInfo.variables) || + !templateInfo.variables.every(variable => typeof variable === 'string'))) { + return { success: false, error: 'Template variables must be an array of strings' } + } + } } return { success: true, data: true } diff --git a/packages/ui/src/types/data-converter.ts b/packages/ui/src/types/data-converter.ts index 9fcb7e9e..537f8030 100644 --- a/packages/ui/src/types/data-converter.ts +++ b/packages/ui/src/types/data-converter.ts @@ -127,7 +127,7 @@ export interface DataImportExport { /** * 自动检测数据格式 */ - detectFormat(data: unknown): 'langfuse' | 'openai' | 'conversation' | 'unknown' + detectFormat(data: unknown): 'standard' | 'langfuse' | 'openai' | 'conversation' | 'unknown' } // 模板化相关接口 @@ -166,4 +166,4 @@ export interface TemplateProcessor { missingVariables: string[] unusedVariables: string[] } -} \ No newline at end of file +} diff --git a/packages/ui/tests/unit/components/ImportExportDialog.spec.ts b/packages/ui/tests/unit/components/ImportExportDialog.spec.ts index 36dcc363..d31a6a54 100644 --- a/packages/ui/tests/unit/components/ImportExportDialog.spec.ts +++ b/packages/ui/tests/unit/components/ImportExportDialog.spec.ts @@ -150,6 +150,7 @@ describe('ImportExportDialog', () => { const preview = JSON.parse(wrapper.vm.exportPreviewData) expect(preview.messages).toHaveLength(1) expect(preview.tools).toHaveLength(1) + expect(preview.format).toBe('prompt-optimizer-standard') expect(preview.metadata.origin).toBe('import_export_dialog') }) diff --git a/packages/ui/tests/unit/composables/useContextEditor.i18n.spec.ts b/packages/ui/tests/unit/composables/useContextEditor.i18n.spec.ts index b4064a61..679a5b80 100644 --- a/packages/ui/tests/unit/composables/useContextEditor.i18n.spec.ts +++ b/packages/ui/tests/unit/composables/useContextEditor.i18n.spec.ts @@ -11,6 +11,7 @@ const serviceMocks = vi.hoisted(() => ({ fromLangFuse: vi.fn(), fromOpenAI: vi.fn(), fromConversationMessages: vi.fn(), + validate: vi.fn(), }, variableExtractor: { extractVariable: vi.fn(), @@ -95,6 +96,25 @@ describe('useContextEditor i18n feedback', () => { expect(toastSpies.success).not.toHaveBeenCalledWith('LANGFUSE data imported successfully') }) + it('smart-imports the internal standard format without treating it as OpenAI', () => { + const data = { + messages: [{ role: 'user' as const, content: 'hello' }], + tools: [], + metadata: { source: 'manual' as const, origin: 'import_export_dialog' }, + } + serviceMocks.importExportManager.detectFormat.mockReturnValue('standard') + serviceMocks.converter.validate.mockReturnValue({ success: true, data: true }) + + const editor = useContextEditor() + const result = editor.smartImport(data) + + expect(result).toEqual({ success: true, data }) + expect(editor.currentData.value).toEqual(data) + expect(toastSpies.success).toHaveBeenCalledWith( + 'contextEditor.feedback.importSuccess:{"format":"contextEditor.feedback.formatLabels.conversation"}', + ) + }) + it('uses localized no-data-export feedback', async () => { const editor = useContextEditor() diff --git a/packages/ui/tests/unit/services/DataImportExportManager.spec.ts b/packages/ui/tests/unit/services/DataImportExportManager.spec.ts new file mode 100644 index 00000000..f3a6f37b --- /dev/null +++ b/packages/ui/tests/unit/services/DataImportExportManager.spec.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import { DataImportExportManager } from '../../../src/services/DataImportExportManager' + +describe('DataImportExportManager', () => { + it('imports the internal standard format from clipboard and file without dropping fields', async () => { + const data = { + messages: [{ role: 'user' as const, content: 'hello' }], + tools: [ + { + type: 'function' as const, + function: { name: 'lookup', parameters: { type: 'object' } }, + }, + ], + model: 'gpt-test', + metadata: { + source: 'manual' as const, + origin: 'import_export_dialog', + nested: { keep: true }, + }, + } + const manager = new DataImportExportManager() + + expect(manager.detectFormat(data)).toBe('standard') + expect(manager.importFromClipboard(JSON.stringify(data))).toEqual({ + success: true, + data, + }) + await expect( + manager.importFromFile(new File([JSON.stringify(data)], 'standard-prompt.json', { type: 'application/json' })), + ).resolves.toEqual({ success: true, data }) + }) + + it('keeps model requests without internal metadata on the OpenAI path', () => { + const manager = new DataImportExportManager() + const data = { + model: 'gpt-test', + messages: [{ role: 'user', content: 'hello' }], + } + + expect(manager.detectFormat(data)).toBe('openai') + expect(manager.importFromClipboard(JSON.stringify(data)).success).toBe(true) + }) + + it('marks standard exports so model and metadata round-trip unambiguously', async () => { + const manager = new DataImportExportManager() + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const data = { + model: 'gpt-test', + messages: [{ role: 'user' as const, content: 'hello' }], + metadata: { source: 'manual' as const, custom: 'keep' }, + } + + await expect(manager.exportToClipboard(data, 'standard')).resolves.toBe(true) + const exported = JSON.parse(writeText.mock.calls[0][0]) + + expect(exported).toEqual({ ...data, format: 'prompt-optimizer-standard' }) + expect(manager.detectFormat(exported)).toBe('standard') + expect(manager.importFromClipboard(JSON.stringify(exported))).toEqual({ + success: true, + data: exported, + }) + }) + + it.each([ + { request_id: 'request-1' }, + null, + ])('keeps OpenAI requests with metadata on the OpenAI path: $metadata', (metadata) => { + const manager = new DataImportExportManager() + const data = { + model: 'gpt-test', + messages: [{ role: 'user', content: 'hello' }], + metadata, + } + + expect(manager.detectFormat(data)).toBe('openai') + expect(manager.importFromClipboard(JSON.stringify(data)).success).toBe(true) + }) + + it('accepts array tool parameters supported by the existing editor contract', () => { + const manager = new DataImportExportManager() + const data = { + messages: [{ role: 'user' as const, content: 'hello' }], + tools: [{ type: 'function' as const, function: { name: 'lookup', parameters: [] } }], + } + + expect(manager.importFromClipboard(JSON.stringify(data))).toEqual({ success: true, data }) + }) + + it('accepts an empty internal workspace', () => { + const manager = new DataImportExportManager() + const data = { messages: [], metadata: { source: 'manual' as const } } + + expect(manager.importFromClipboard(JSON.stringify(data))).toEqual({ success: true, data }) + }) + + it.each([ + { messages: [{ role: 'invalid', content: 'hello' }] }, + { messages: [{ role: 'user', content: 42 }] }, + ])('rejects malformed internal messages: $messages', (data) => { + const manager = new DataImportExportManager() + + expect(manager.importFromClipboard(JSON.stringify(data))).toEqual( + expect.objectContaining({ success: false }), + ) + }) + + it.each([ + { tools: { invalid: true } }, + { tools: [{ type: 'function', function: { parameters: {} } }] }, + { metadata: 'invalid' }, + { metadata: { source: 123 } }, + { metadata: { source: 'invalid' } }, + { metadata: { timestamp: 42 } }, + { metadata: { template_info: 'invalid' } }, + { metadata: { template_info: { name: 42 } } }, + { metadata: { template_info: { version: 42 } } }, + { metadata: { template_info: { variables: [1] } } }, + { model: null }, + { temperature: 'hot' }, + { max_tokens: 'many' }, + { top_p: 'high' }, + { frequency_penalty: 'high' }, + { presence_penalty: 'high' }, + { stop: [42] }, + { stream: 'yes' }, + ])('rejects malformed internal fields: $tools $metadata $model', (fields) => { + const manager = new DataImportExportManager() + const data = { + messages: [{ role: 'user', content: 'hello' }], + ...fields, + } + + expect(manager.importFromClipboard(JSON.stringify(data))).toEqual( + expect.objectContaining({ success: false }), + ) + }) +})