Files
opro_demo/frontend/opro.html
leehwui da30a0999c feat: implement session-based architecture for OPRO
- Add session layer above runs to group related optimization tasks
- Sessions use first task description as name instead of 'Session 1'
- Simplified sidebar: show sessions without expansion
- Add '+ 新建任务' button in header to create runs within session
- Fix: reload sessions after creating new run
- Add debugging logs for candidate generation
- Backend: auto-update session name with first task description
2025-12-06 21:26:24 +08:00

594 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 [sessions, setSessions] = useState([]);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [currentSessionRuns, setCurrentSessionRuns] = 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 sessions and models on mount
useEffect(() => {
loadSessions();
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 loadSessions() {
try {
const res = await fetch(`${API_BASE}/opro/sessions`);
const data = await res.json();
if (data.success) {
setSessions(data.data.sessions || []);
}
} catch (err) {
console.error('Failed to load sessions:', err);
}
}
async function loadSessionRuns(sessionId) {
try {
const res = await fetch(`${API_BASE}/opro/session/${sessionId}`);
const data = await res.json();
if (data.success) {
setCurrentSessionRuns(data.data.runs || []);
}
} catch (err) {
console.error('Failed to load session runs:', err);
}
}
async function createNewSession() {
try {
const res = await fetch(`${API_BASE}/opro/session/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Failed to create session');
}
const sessionId = data.data.session_id;
setCurrentSessionId(sessionId);
setCurrentSessionRuns([]);
setCurrentRunId(null);
setMessages([]);
// Reload sessions list
await loadSessions();
return sessionId;
} catch (err) {
alert('创建会话失败: ' + err.message);
return null;
}
}
async function createNewRun(taskDescription) {
setLoading(true);
try {
// Ensure we have a session
let sessionId = currentSessionId;
if (!sessionId) {
sessionId = await createNewSession();
if (!sessionId) return;
}
// Create run within session
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,
session_id: sessionId
})
});
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 sessions and session runs
await loadSessions();
await loadSessionRuns(sessionId);
} catch (err) {
alert('创建任务失败: ' + err.message);
console.error('Error creating run:', err);
} finally {
setLoading(false);
}
}
async function generateCandidates(runId) {
setLoading(true);
try {
console.log('Generating candidates for run:', runId);
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();
console.log('Generate candidates response:', data);
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);
console.error('Error generating candidates:', err);
} 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;
executeInstruction(instruction, '');
}
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() {
// Create new run within current session
setCurrentRunId(null);
setMessages([]);
setInputValue('');
}
async function handleNewSession() {
// Create completely new session
const sessionId = await createNewSession();
if (sessionId) {
setCurrentSessionId(sessionId);
setCurrentSessionRuns([]);
setCurrentRunId(null);
setMessages([]);
setInputValue('');
}
}
async function handleSelectSession(sessionId) {
setCurrentSessionId(sessionId);
setCurrentRunId(null);
setMessages([]);
await loadSessionRuns(sessionId);
}
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 session button (expanded)
React.createElement('button', {
onClick: handleNewSession,
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
sessions.length > 0 && React.createElement('div', { className: 'text-xs text-gray-500 mb-2 px-2' }, '会话列表'),
sessions.map(session =>
React.createElement('div', {
key: session.session_id,
onClick: () => handleSelectSession(session.session_id),
className: `p-3 mb-1 rounded-lg cursor-pointer transition-colors flex items-center gap-2 ${
currentSessionId === session.session_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' },
session.session_name
)
)
)
) : React.createElement('button', {
onClick: handleNewSession,
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 justify-between' },
React.createElement('div', { className: 'flex items-center gap-3' },
React.createElement('h1', { className: 'text-lg font-normal text-gray-800' },
'OPRO'
),
currentSessionId && React.createElement('div', { className: 'text-sm text-gray-500' },
sessions.find(s => s.session_id === currentSessionId)?.session_name || '当前会话'
)
),
currentSessionId && React.createElement('button', {
onClick: handleNewTask,
className: 'px-3 py-1.5 text-sm bg-white border border-gray-300 hover:bg-gray-50 rounded-lg transition-colors text-gray-700'
}, '+ 新建任务')
),
// 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' },
currentSessionId
? '输入任务描述后AI 将为你生成优化的系统指令'
: '点击左侧"新建会话"开始,或输入任务描述自动创建会话'
)
)
)
)
);
}
// Render App
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(App));
</script>
</body>
</html>