feat: implement true OPRO with Gemini-style UI
- Add true OPRO system instruction optimization (vs query rewriting) - Implement iterative optimization with performance trajectory - Add new OPRO API endpoints (/opro/create, /opro/generate_and_evaluate, /opro/execute) - Create modern Gemini-style chat UI (frontend/opro.html) - Optimize performance: reduce candidates from 20 to 10 (2x faster) - Add model selector in UI toolbar - Add collapsible sidebar with session management - Add copy button for instructions - Ensure all generated prompts use simplified Chinese - Update README with comprehensive documentation - Add .gitignore for local_docs folder
This commit is contained in:
507
frontend/opro.html
Normal file
507
frontend/opro.html
Normal file
@@ -0,0 +1,507 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>OPRO - System Instruction Optimizer</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Google Sans', 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.chat-container { height: 100vh; display: flex; }
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.sidebar-collapsed { width: 60px; }
|
||||
.sidebar-expanded { width: 260px; }
|
||||
.instruction-card {
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid #e8eaed;
|
||||
}
|
||||
.instruction-card:hover {
|
||||
border-color: #dadce0;
|
||||
box-shadow: 0 1px 3px rgba(60,64,67,0.15);
|
||||
}
|
||||
.loading-dots::after {
|
||||
content: '...';
|
||||
animation: dots 1.5s steps(4, end) infinite;
|
||||
}
|
||||
@keyframes dots {
|
||||
0%, 20% { content: '.'; }
|
||||
40% { content: '..'; }
|
||||
60%, 100% { content: '...'; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script>
|
||||
const { useState, useEffect, useRef } = React;
|
||||
const API_BASE = 'http://127.0.0.1:8010';
|
||||
|
||||
// Main App Component
|
||||
function App() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [currentRunId, setCurrentRunId] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [models, setModels] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const chatEndRef = useRef(null);
|
||||
|
||||
// Load runs and models on mount
|
||||
useEffect(() => {
|
||||
loadRuns();
|
||||
loadModels();
|
||||
}, []);
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/models`);
|
||||
const data = await res.json();
|
||||
if (data.success && data.data.models) {
|
||||
setModels(data.data.models);
|
||||
if (data.data.models.length > 0) {
|
||||
setSelectedModel(data.data.models[0]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load models:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll chat
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
async function loadRuns() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/opro/runs`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setRuns(data.data.runs || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load runs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewRun(taskDescription) {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Create run
|
||||
const res = await fetch(`${API_BASE}/opro/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
task_description: taskDescription,
|
||||
test_cases: [],
|
||||
model_name: selectedModel || undefined
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to create run');
|
||||
}
|
||||
|
||||
const runId = data.data.run_id;
|
||||
setCurrentRunId(runId);
|
||||
|
||||
// Add user message
|
||||
setMessages([{ role: 'user', content: taskDescription }]);
|
||||
|
||||
// Generate and evaluate candidates
|
||||
await generateCandidates(runId);
|
||||
|
||||
// Reload runs list
|
||||
await loadRuns();
|
||||
} catch (err) {
|
||||
alert('创建任务失败: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCandidates(runId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/opro/generate_and_evaluate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
run_id: runId,
|
||||
top_k: 5,
|
||||
auto_evaluate: false // Use diversity-based selection
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to generate candidates');
|
||||
}
|
||||
|
||||
// Add assistant message with candidates
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
type: 'candidates',
|
||||
candidates: data.data.candidates,
|
||||
iteration: data.data.iteration
|
||||
}]);
|
||||
} catch (err) {
|
||||
alert('生成候选指令失败: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeInstruction(instruction, userInput) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/opro/execute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
instruction: instruction,
|
||||
user_input: userInput || '请执行任务',
|
||||
model_name: selectedModel || undefined
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to execute');
|
||||
}
|
||||
|
||||
// Add execution result
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
type: 'execution',
|
||||
instruction: instruction,
|
||||
response: data.data.response
|
||||
}]);
|
||||
} catch (err) {
|
||||
alert('执行失败: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendMessage() {
|
||||
const msg = inputValue.trim();
|
||||
if (!msg || loading) return;
|
||||
|
||||
setInputValue('');
|
||||
|
||||
if (!currentRunId) {
|
||||
// Create new run with task description
|
||||
createNewRun(msg);
|
||||
} else {
|
||||
// Continue optimization or execute
|
||||
// For now, just show message
|
||||
setMessages(prev => [...prev, { role: 'user', content: msg }]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContinueOptimize() {
|
||||
if (!currentRunId || loading) return;
|
||||
generateCandidates(currentRunId);
|
||||
}
|
||||
|
||||
function handleExecute(instruction) {
|
||||
if (loading) return;
|
||||
const userInput = prompt('请输入要处理的内容(可选):');
|
||||
executeInstruction(instruction, userInput);
|
||||
}
|
||||
|
||||
function handleCopyInstruction(instruction) {
|
||||
navigator.clipboard.writeText(instruction).then(() => {
|
||||
// Could add a toast notification here
|
||||
console.log('Instruction copied to clipboard');
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function handleNewTask() {
|
||||
setCurrentRunId(null);
|
||||
setMessages([]);
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
async function loadRun(runId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/opro/run/${runId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to load run');
|
||||
}
|
||||
|
||||
const run = data.data;
|
||||
setCurrentRunId(runId);
|
||||
|
||||
// Reconstruct messages from run data
|
||||
const msgs = [
|
||||
{ role: 'user', content: run.task_description }
|
||||
];
|
||||
|
||||
if (run.current_candidates && run.current_candidates.length > 0) {
|
||||
msgs.push({
|
||||
role: 'assistant',
|
||||
type: 'candidates',
|
||||
candidates: run.current_candidates.map(c => ({ instruction: c, score: null })),
|
||||
iteration: run.iteration
|
||||
});
|
||||
}
|
||||
|
||||
setMessages(msgs);
|
||||
} catch (err) {
|
||||
alert('加载任务失败: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return React.createElement('div', { className: 'chat-container' },
|
||||
// Sidebar
|
||||
React.createElement('div', {
|
||||
className: `bg-white border-r border-gray-200 transition-all duration-300 flex flex-col ${sidebarOpen ? 'sidebar-expanded' : 'sidebar-collapsed'}`
|
||||
},
|
||||
// Header area - Collapse button only
|
||||
React.createElement('div', { className: 'p-3 border-b border-gray-200 flex items-center justify-between' },
|
||||
sidebarOpen ? React.createElement('button', {
|
||||
onClick: () => setSidebarOpen(false),
|
||||
className: 'p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors'
|
||||
},
|
||||
React.createElement('svg', { width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2' },
|
||||
React.createElement('path', { d: 'M15 18l-6-6 6-6' })
|
||||
)
|
||||
) : React.createElement('button', {
|
||||
onClick: () => setSidebarOpen(true),
|
||||
className: 'w-full p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors flex items-center justify-center'
|
||||
},
|
||||
React.createElement('svg', { width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2' },
|
||||
React.createElement('path', { d: 'M3 12h18M3 6h18M3 18h18' })
|
||||
)
|
||||
)
|
||||
),
|
||||
// Content area
|
||||
React.createElement('div', { className: 'flex-1 overflow-y-auto scrollbar-hide p-2 flex flex-col' },
|
||||
sidebarOpen ? React.createElement(React.Fragment, null,
|
||||
// New task button (expanded)
|
||||
React.createElement('button', {
|
||||
onClick: handleNewTask,
|
||||
className: 'mb-3 px-4 py-2.5 bg-white border border-gray-300 hover:bg-gray-50 rounded-lg transition-colors flex items-center justify-center gap-2 text-gray-700 font-medium'
|
||||
},
|
||||
React.createElement('span', { className: 'text-lg' }, '+'),
|
||||
React.createElement('span', null, '新建会话')
|
||||
),
|
||||
// Sessions list
|
||||
runs.length > 0 && React.createElement('div', { className: 'text-xs text-gray-500 mb-2 px-2' }, '会话列表'),
|
||||
runs.map(run =>
|
||||
React.createElement('div', {
|
||||
key: run.run_id,
|
||||
onClick: () => loadRun(run.run_id),
|
||||
className: `p-3 mb-1 rounded-lg cursor-pointer transition-colors flex items-center gap-2 ${
|
||||
currentRunId === run.run_id ? 'bg-gray-100' : 'hover:bg-gray-50'
|
||||
}`
|
||||
},
|
||||
React.createElement('svg', {
|
||||
width: '16',
|
||||
height: '16',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: '2',
|
||||
className: 'flex-shrink-0 text-gray-500'
|
||||
},
|
||||
React.createElement('path', { d: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z' })
|
||||
),
|
||||
React.createElement('div', { className: 'text-sm text-gray-800 truncate flex-1' },
|
||||
run.task_description
|
||||
)
|
||||
)
|
||||
)
|
||||
) : React.createElement('button', {
|
||||
onClick: handleNewTask,
|
||||
className: 'p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors flex items-center justify-center',
|
||||
title: '新建会话'
|
||||
},
|
||||
React.createElement('svg', { width: '24', height: '24', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2' },
|
||||
React.createElement('path', { d: 'M12 5v14M5 12h14' })
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Main Chat Area
|
||||
React.createElement('div', { className: 'flex-1 flex flex-col bg-white' },
|
||||
// Header
|
||||
React.createElement('div', { className: 'px-4 py-3 border-b border-gray-200 bg-white flex items-center gap-3' },
|
||||
React.createElement('h1', { className: 'text-lg font-normal text-gray-800' },
|
||||
'OPRO'
|
||||
)
|
||||
),
|
||||
|
||||
// Chat Messages
|
||||
React.createElement('div', { className: 'flex-1 overflow-y-auto scrollbar-hide p-6 space-y-6 max-w-4xl mx-auto w-full' },
|
||||
messages.map((msg, idx) => {
|
||||
if (msg.role === 'user') {
|
||||
return React.createElement('div', { key: idx, className: 'flex justify-end' },
|
||||
React.createElement('div', { className: 'max-w-2xl bg-gray-100 text-gray-800 rounded-2xl px-5 py-3' },
|
||||
msg.content
|
||||
)
|
||||
);
|
||||
} else if (msg.type === 'candidates') {
|
||||
return React.createElement('div', { key: idx, className: 'flex justify-start' },
|
||||
React.createElement('div', { className: 'w-full' },
|
||||
React.createElement('div', { className: 'mb-3' },
|
||||
React.createElement('div', { className: 'text-sm text-gray-600' },
|
||||
`优化后的提示词(第 ${msg.iteration} 轮)`
|
||||
),
|
||||
),
|
||||
msg.candidates.map((cand, cidx) =>
|
||||
React.createElement('div', {
|
||||
key: cidx,
|
||||
className: 'instruction-card bg-white rounded-xl p-5 mb-3'
|
||||
},
|
||||
React.createElement('div', { className: 'flex items-start gap-3' },
|
||||
React.createElement('div', { className: 'flex-shrink-0 w-7 h-7 bg-gray-200 text-gray-700 rounded-full flex items-center justify-center text-sm font-medium' },
|
||||
cidx + 1
|
||||
),
|
||||
React.createElement('div', { className: 'flex-1' },
|
||||
React.createElement('div', { className: 'text-gray-800 mb-4 whitespace-pre-wrap leading-relaxed' },
|
||||
cand.instruction
|
||||
),
|
||||
cand.score !== null && React.createElement('div', { className: 'text-xs text-gray-500 mb-3' },
|
||||
`评分: ${cand.score.toFixed(4)}`
|
||||
),
|
||||
React.createElement('div', { className: 'flex gap-2' },
|
||||
React.createElement('button', {
|
||||
onClick: handleContinueOptimize,
|
||||
disabled: loading,
|
||||
className: 'px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed transition-colors text-sm font-medium'
|
||||
}, '继续优化'),
|
||||
React.createElement('button', {
|
||||
onClick: () => handleCopyInstruction(cand.instruction),
|
||||
className: 'px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium flex items-center gap-1'
|
||||
},
|
||||
React.createElement('svg', { width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2' },
|
||||
React.createElement('rect', { x: '9', y: '9', width: '13', height: '13', rx: '2', ry: '2' }),
|
||||
React.createElement('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' })
|
||||
),
|
||||
'复制'
|
||||
),
|
||||
React.createElement('button', {
|
||||
onClick: () => handleExecute(cand.instruction),
|
||||
disabled: loading,
|
||||
className: 'px-4 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors text-sm font-medium'
|
||||
}, '执行此指令')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
} else if (msg.type === 'execution') {
|
||||
return React.createElement('div', { key: idx, className: 'flex justify-start' },
|
||||
React.createElement('div', { className: 'max-w-2xl bg-gray-50 border border-gray-200 rounded-2xl p-5' },
|
||||
React.createElement('div', { className: 'text-xs text-gray-600 mb-2 font-medium' },
|
||||
'执行结果'
|
||||
),
|
||||
React.createElement('div', { className: 'text-gray-800 whitespace-pre-wrap leading-relaxed' },
|
||||
msg.response
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}),
|
||||
loading && React.createElement('div', { className: 'flex justify-start' },
|
||||
React.createElement('div', { className: 'bg-gray-100 rounded-2xl px-5 py-3 text-gray-600' },
|
||||
React.createElement('span', { className: 'loading-dots' }, '思考中')
|
||||
)
|
||||
),
|
||||
React.createElement('div', { ref: chatEndRef })
|
||||
),
|
||||
|
||||
// Input Area
|
||||
React.createElement('div', { className: 'p-6 bg-white max-w-4xl mx-auto w-full' },
|
||||
React.createElement('div', { className: 'relative' },
|
||||
React.createElement('div', { className: 'bg-white border border-gray-300 rounded-3xl shadow-sm hover:shadow-md transition-shadow focus-within:shadow-md focus-within:border-gray-400' },
|
||||
// Textarea
|
||||
React.createElement('textarea', {
|
||||
value: inputValue,
|
||||
onChange: (e) => setInputValue(e.target.value),
|
||||
onKeyPress: (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
},
|
||||
placeholder: currentRunId ? '输入消息...' : '在此输入提示词',
|
||||
disabled: loading,
|
||||
rows: 3,
|
||||
className: 'w-full px-5 pt-4 pb-2 bg-transparent focus:outline-none disabled:bg-transparent text-gray-800 placeholder-gray-500 resize-none'
|
||||
}),
|
||||
// Toolbar
|
||||
React.createElement('div', { className: 'flex items-center justify-between px-4 pb-3 pt-1 border-t border-gray-100' },
|
||||
// Left side - Model selector
|
||||
React.createElement('div', { className: 'flex items-center gap-2' },
|
||||
React.createElement('label', { className: 'text-xs text-gray-600' }, '模型:'),
|
||||
React.createElement('select', {
|
||||
value: selectedModel,
|
||||
onChange: (e) => setSelectedModel(e.target.value),
|
||||
className: 'text-sm px-2 py-1 border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:border-gray-400 cursor-pointer'
|
||||
},
|
||||
models.map(model =>
|
||||
React.createElement('option', { key: model, value: model }, model)
|
||||
)
|
||||
)
|
||||
),
|
||||
// Right side - Send button
|
||||
React.createElement('button', {
|
||||
onClick: handleSendMessage,
|
||||
disabled: loading || !inputValue.trim(),
|
||||
className: 'p-2.5 bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 disabled:bg-gray-50 disabled:text-gray-300 disabled:cursor-not-allowed transition-colors flex items-center justify-center'
|
||||
},
|
||||
React.createElement('svg', {
|
||||
width: '20',
|
||||
height: '20',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor'
|
||||
},
|
||||
React.createElement('path', { d: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z' })
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
!currentRunId && React.createElement('div', { className: 'text-xs text-gray-500 mt-3 px-4' },
|
||||
'输入任务描述后,AI 将为你生成优化的系统指令'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Render App
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(React.createElement(App));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user