fix(ui): preserve standard prompt imports

This commit is contained in:
Iams4kura committed 2026-08-10 17:27:30 +08:00
1 parent 08b5531b39
commit 470a2985f6
8 files changed
+296 -16

No files matched your search

@@ -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<string, string> = {
};
// 导出预览数据
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,
@@ -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<Partial<ConversationMessage>>)
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,
@@ -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<string, unknown>).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<string, unknown> {
switch (format) {
case 'standard':
return data
return { ...data, format: STANDARD_PROMPT_FORMAT }
case 'openai': {
const openaiResult = this.converter.toOpenAI(data)
@@ -459,13 +459,16 @@ export class PromptDataConverter implements DataConverter {
// 私有方法:验证标准格式
private validateStandardFormat(data: unknown): ConversionResult<boolean> {
if (!data || typeof data !== 'object') {
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
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<string, unknown>
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 }
+2 -2
View File
@@ -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[]
}
}
}
@@ -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')
})
@@ -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()
@@ -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 }),
)
})
})