fix(ui): preserve standard prompt imports
This commit is contained in:
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 }
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user