Skip to content

Commit aa5f572

Browse files
SonAIengineclaude
andcommitted
feat: Workflow 시각화 편집 툴 — 브라우저 기반 드래그앤드롭 에디터
## 시각화 편집 툴 - 단일 HTML 파일, zero-dependency (graph-tool-call 철학 유지) - 다크 테마 UI, GitHub-style 디자인 - 드래그앤드롭으로 step 순서 변경 - 사이드바에서 도구 클릭으로 step 추가 - × 버튼으로 step 제거 - HTTP method 뱃지 (GET=파랑, POST=초록, DELETE=빨강) - primary action 주황 테두리 강조 ## Python API 연동 - plan.open_editor(tools=tg.tools) → 브라우저에서 에디터 자동 열기 - 현재 workflow + 전체 tool 목록이 에디터에 주입됨 ## JSON import/export - Import JSON: 에디터에 직접 JSON 붙여넣기 - Export JSON: 파일 다운로드 - Copy to Clipboard: 클립보드 복사 - Load from API: graph-tool-call 서버에서 직접 로드 (향후) ## 사용 흐름 1. tg.plan_workflow("환불 처리") → 자동 체인 생성 2. plan.open_editor(tools=tg.tools) → 브라우저에서 확인 3. 드래그앤드롭으로 순서 수정, 불필요한 step 제거 4. Export JSON → plan.save()로 저장 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f21d824 commit aa5f572

2 files changed

Lines changed: 438 additions & 0 deletions

File tree

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Workflow Editor — graph-tool-call</title>
7+
<style>
8+
* { margin: 0; padding: 0; box-sizing: border-box; }
9+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; min-height: 100vh; }
10+
11+
.header { padding: 16px 24px; border-bottom: 1px solid #21262d; display: flex; align-items: center; gap: 16px; }
12+
.header h1 { font-size: 18px; font-weight: 600; color: #f0f6fc; }
13+
.header .goal { flex: 1; }
14+
.header input { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 12px; border-radius: 6px; font-size: 14px; width: 100%; }
15+
16+
.toolbar { padding: 8px 24px; border-bottom: 1px solid #21262d; display: flex; gap: 8px; align-items: center; }
17+
.btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; }
18+
.btn:hover { background: #30363d; }
19+
.btn-primary { background: #238636; border-color: #2ea043; color: #fff; }
20+
.btn-primary:hover { background: #2ea043; }
21+
.btn-danger { background: #da3633; border-color: #f85149; color: #fff; }
22+
.btn-danger:hover { background: #f85149; }
23+
24+
.main { display: flex; height: calc(100vh - 100px); }
25+
26+
/* Left: flow canvas */
27+
.canvas { flex: 1; padding: 32px; overflow: auto; }
28+
.flow { display: flex; align-items: flex-start; gap: 0; min-width: max-content; }
29+
30+
.step-card {
31+
background: #161b22; border: 2px solid #30363d; border-radius: 12px;
32+
padding: 16px; min-width: 180px; max-width: 220px; cursor: grab;
33+
position: relative; transition: border-color 0.2s, transform 0.2s;
34+
}
35+
.step-card:hover { border-color: #58a6ff; }
36+
.step-card.dragging { opacity: 0.5; transform: scale(0.95); }
37+
.step-card.drag-over { border-color: #3fb950; border-style: dashed; }
38+
.step-card.primary { border-color: #f78166; }
39+
.step-card .step-num { font-size: 11px; color: #8b949e; margin-bottom: 4px; }
40+
.step-card .step-name { font-size: 15px; font-weight: 600; color: #f0f6fc; margin-bottom: 6px; word-break: break-all; }
41+
.step-card .step-desc { font-size: 12px; color: #8b949e; line-height: 1.4; margin-bottom: 8px; }
42+
.step-card .step-reason { font-size: 11px; color: #58a6ff; font-style: italic; }
43+
.step-card .step-method { font-size: 10px; padding: 2px 6px; border-radius: 4px; display: inline-block; margin-bottom: 6px; }
44+
.step-card .step-method.GET { background: #1f6feb33; color: #58a6ff; }
45+
.step-card .step-method.POST { background: #23863633; color: #3fb950; }
46+
.step-card .step-method.PUT, .step-card .step-method.PATCH { background: #9e6a0333; color: #d29922; }
47+
.step-card .step-method.DELETE { background: #da363333; color: #f85149; }
48+
.step-card .remove-btn {
49+
position: absolute; top: 6px; right: 8px; background: none; border: none;
50+
color: #8b949e; cursor: pointer; font-size: 16px; line-height: 1;
51+
}
52+
.step-card .remove-btn:hover { color: #f85149; }
53+
54+
.arrow { display: flex; align-items: center; padding: 0 4px; color: #30363d; font-size: 24px; user-select: none; }
55+
56+
/* Right: tool panel + params */
57+
.sidebar { width: 320px; border-left: 1px solid #21262d; display: flex; flex-direction: column; }
58+
.sidebar-section { padding: 12px 16px; border-bottom: 1px solid #21262d; }
59+
.sidebar-section h3 { font-size: 13px; color: #8b949e; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
60+
61+
.tool-list { flex: 1; overflow-y: auto; padding: 8px; }
62+
.tool-item {
63+
padding: 8px 12px; border-radius: 6px; cursor: pointer;
64+
font-size: 13px; margin-bottom: 2px; transition: background 0.15s;
65+
}
66+
.tool-item:hover { background: #21262d; }
67+
.tool-item .tool-name { font-weight: 500; color: #c9d1d9; }
68+
.tool-item .tool-desc { font-size: 11px; color: #8b949e; margin-top: 2px; }
69+
70+
.tool-search { width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 10px; border-radius: 6px; font-size: 13px; margin-bottom: 8px; }
71+
72+
.param-editor { padding: 12px 16px; }
73+
.param-row { display: flex; gap: 6px; margin-bottom: 6px; align-items: center; font-size: 12px; }
74+
.param-row input { flex: 1; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
75+
.param-row label { min-width: 60px; color: #8b949e; }
76+
77+
/* JSON modal */
78+
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; justify-content: center; align-items: center; }
79+
.modal.open { display: flex; }
80+
.modal-content { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 24px; width: 600px; max-height: 80vh; overflow: auto; }
81+
.modal-content h2 { font-size: 16px; margin-bottom: 12px; color: #f0f6fc; }
82+
.modal-content textarea { width: 100%; height: 300px; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 12px; border-radius: 6px; font-family: monospace; font-size: 13px; resize: vertical; }
83+
.modal-content .modal-btns { margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; }
84+
85+
.empty-state { text-align: center; padding: 64px 32px; color: #8b949e; }
86+
.empty-state h2 { font-size: 20px; margin-bottom: 8px; color: #c9d1d9; }
87+
</style>
88+
</head>
89+
<body>
90+
91+
<div class="header">
92+
<h1>Workflow Editor</h1>
93+
<div class="goal">
94+
<input type="text" id="goalInput" placeholder="Workflow goal (e.g., process a refund)" />
95+
</div>
96+
</div>
97+
98+
<div class="toolbar">
99+
<button class="btn" onclick="importJSON()">Import JSON</button>
100+
<button class="btn" onclick="exportJSON()">Export JSON</button>
101+
<button class="btn" onclick="copyJSON()">Copy to Clipboard</button>
102+
<button class="btn btn-primary" onclick="loadFromAPI()">Load from API</button>
103+
<span style="flex:1"></span>
104+
<button class="btn btn-danger" onclick="clearAll()">Clear All</button>
105+
</div>
106+
107+
<div class="main">
108+
<div class="canvas" id="canvas">
109+
<div class="flow" id="flow">
110+
<div class="empty-state" id="emptyState">
111+
<h2>No workflow steps</h2>
112+
<p>Add tools from the sidebar, import JSON, or load from API</p>
113+
</div>
114+
</div>
115+
</div>
116+
117+
<div class="sidebar">
118+
<div class="sidebar-section">
119+
<h3>Available Tools</h3>
120+
<input type="text" class="tool-search" id="toolSearch" placeholder="Search tools..." oninput="filterTools()" />
121+
</div>
122+
<div class="tool-list" id="toolList">
123+
<div style="padding: 16px; color: #8b949e; font-size: 13px;">
124+
Load workflow JSON to see available tools
125+
</div>
126+
</div>
127+
</div>
128+
</div>
129+
130+
<!-- JSON Modal -->
131+
<div class="modal" id="jsonModal">
132+
<div class="modal-content">
133+
<h2 id="modalTitle">Import JSON</h2>
134+
<textarea id="jsonTextarea" spellcheck="false"></textarea>
135+
<div class="modal-btns">
136+
<button class="btn" onclick="closeModal()">Cancel</button>
137+
<button class="btn btn-primary" id="modalAction" onclick="applyImport()">Import</button>
138+
</div>
139+
</div>
140+
</div>
141+
142+
<script>
143+
let steps = [];
144+
let allTools = {}; // {name: {description, method, params}}
145+
let dragSrcIndex = null;
146+
147+
// --- Rendering ---
148+
function render() {
149+
const flow = document.getElementById('flow');
150+
const empty = document.getElementById('emptyState');
151+
152+
if (steps.length === 0) {
153+
flow.innerHTML = '';
154+
flow.appendChild(empty);
155+
empty.style.display = '';
156+
return;
157+
}
158+
159+
flow.innerHTML = '';
160+
steps.forEach((step, i) => {
161+
if (i > 0) {
162+
const arrow = document.createElement('div');
163+
arrow.className = 'arrow';
164+
arrow.textContent = '→';
165+
flow.appendChild(arrow);
166+
}
167+
168+
const card = document.createElement('div');
169+
card.className = 'step-card' + (step.reason === 'primary action' ? ' primary' : '');
170+
card.draggable = true;
171+
card.dataset.index = i;
172+
173+
const method = allTools[step.tool]?.method || '';
174+
const desc = allTools[step.tool]?.description || '';
175+
const truncDesc = desc.length > 80 ? desc.slice(0, 77) + '...' : desc;
176+
177+
card.innerHTML = `
178+
<button class="remove-btn" onclick="removeStep(${i})" title="Remove">&times;</button>
179+
<div class="step-num">Step ${i + 1}</div>
180+
${method ? `<span class="step-method ${method}">${method}</span>` : ''}
181+
<div class="step-name">${step.tool}</div>
182+
<div class="step-desc">${truncDesc}</div>
183+
<div class="step-reason">${step.reason || ''}</div>
184+
`;
185+
186+
// Drag events
187+
card.addEventListener('dragstart', (e) => {
188+
dragSrcIndex = i;
189+
card.classList.add('dragging');
190+
e.dataTransfer.effectAllowed = 'move';
191+
});
192+
card.addEventListener('dragend', () => {
193+
card.classList.remove('dragging');
194+
dragSrcIndex = null;
195+
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
196+
});
197+
card.addEventListener('dragover', (e) => {
198+
e.preventDefault();
199+
e.dataTransfer.dropEffect = 'move';
200+
card.classList.add('drag-over');
201+
});
202+
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
203+
card.addEventListener('drop', (e) => {
204+
e.preventDefault();
205+
card.classList.remove('drag-over');
206+
if (dragSrcIndex !== null && dragSrcIndex !== i) {
207+
const moved = steps.splice(dragSrcIndex, 1)[0];
208+
steps.splice(i, 0, moved);
209+
renumber();
210+
render();
211+
}
212+
});
213+
214+
flow.appendChild(card);
215+
});
216+
}
217+
218+
function renumber() {
219+
steps.forEach((s, i) => s.order = i + 1);
220+
}
221+
222+
// --- Actions ---
223+
function removeStep(index) {
224+
steps.splice(index, 1);
225+
renumber();
226+
render();
227+
}
228+
229+
function addTool(toolName) {
230+
steps.push({
231+
order: steps.length + 1,
232+
tool: toolName,
233+
reason: steps.length === 0 ? 'primary action' : 'manually added',
234+
params_from: {}
235+
});
236+
render();
237+
}
238+
239+
function clearAll() {
240+
if (steps.length > 0 && !confirm('Clear all steps?')) return;
241+
steps = [];
242+
render();
243+
}
244+
245+
// --- Tool sidebar ---
246+
function renderToolList() {
247+
const list = document.getElementById('toolList');
248+
const names = Object.keys(allTools).sort();
249+
if (names.length === 0) {
250+
list.innerHTML = '<div style="padding:16px;color:#8b949e;font-size:13px;">No tools loaded</div>';
251+
return;
252+
}
253+
list.innerHTML = names.map(name => {
254+
const t = allTools[name];
255+
const desc = (t.description || '').slice(0, 60);
256+
return `<div class="tool-item" onclick="addTool('${name}')">
257+
<div class="tool-name">${name}</div>
258+
<div class="tool-desc">${desc}</div>
259+
</div>`;
260+
}).join('');
261+
}
262+
263+
function filterTools() {
264+
const q = document.getElementById('toolSearch').value.toLowerCase();
265+
document.querySelectorAll('.tool-item').forEach(el => {
266+
const name = el.querySelector('.tool-name').textContent.toLowerCase();
267+
const desc = el.querySelector('.tool-desc').textContent.toLowerCase();
268+
el.style.display = (name.includes(q) || desc.includes(q)) ? '' : 'none';
269+
});
270+
}
271+
272+
// --- JSON Import/Export ---
273+
function getWorkflowJSON() {
274+
return JSON.stringify({
275+
goal: document.getElementById('goalInput').value,
276+
confidence: 'manual',
277+
steps: steps,
278+
tools: allTools
279+
}, null, 2);
280+
}
281+
282+
function exportJSON() {
283+
document.getElementById('modalTitle').textContent = 'Export JSON';
284+
document.getElementById('jsonTextarea').value = getWorkflowJSON();
285+
document.getElementById('modalAction').textContent = 'Download';
286+
document.getElementById('modalAction').onclick = downloadJSON;
287+
document.getElementById('jsonModal').classList.add('open');
288+
}
289+
290+
function downloadJSON() {
291+
const blob = new Blob([document.getElementById('jsonTextarea').value], {type: 'application/json'});
292+
const a = document.createElement('a');
293+
a.href = URL.createObjectURL(blob);
294+
a.download = 'workflow.json';
295+
a.click();
296+
closeModal();
297+
}
298+
299+
function importJSON() {
300+
document.getElementById('modalTitle').textContent = 'Import JSON';
301+
document.getElementById('jsonTextarea').value = '';
302+
document.getElementById('modalAction').textContent = 'Import';
303+
document.getElementById('modalAction').onclick = applyImport;
304+
document.getElementById('jsonModal').classList.add('open');
305+
}
306+
307+
function applyImport() {
308+
try {
309+
const data = JSON.parse(document.getElementById('jsonTextarea').value);
310+
if (data.goal) document.getElementById('goalInput').value = data.goal;
311+
if (data.steps) steps = data.steps;
312+
if (data.tools) {
313+
allTools = data.tools;
314+
renderToolList();
315+
}
316+
renumber();
317+
render();
318+
closeModal();
319+
} catch (e) {
320+
alert('Invalid JSON: ' + e.message);
321+
}
322+
}
323+
324+
function copyJSON() {
325+
navigator.clipboard.writeText(getWorkflowJSON()).then(() => {
326+
const btn = event.target;
327+
btn.textContent = 'Copied!';
328+
setTimeout(() => btn.textContent = 'Copy to Clipboard', 1500);
329+
});
330+
}
331+
332+
function closeModal() {
333+
document.getElementById('jsonModal').classList.remove('open');
334+
}
335+
336+
// --- Load from API ---
337+
async function loadFromAPI() {
338+
const goal = document.getElementById('goalInput').value;
339+
if (!goal) { alert('Enter a goal first'); return; }
340+
341+
const host = prompt('graph-tool-call server URL:', 'http://localhost:8000');
342+
if (!host) return;
343+
344+
try {
345+
const resp = await fetch(`${host}/workflow`, {
346+
method: 'POST',
347+
headers: {'Content-Type': 'application/json'},
348+
body: JSON.stringify({goal, max_steps: 6})
349+
});
350+
const data = await resp.json();
351+
if (data.steps) steps = data.steps;
352+
if (data.tools) {
353+
allTools = data.tools;
354+
renderToolList();
355+
}
356+
renumber();
357+
render();
358+
} catch (e) {
359+
alert('Failed to connect: ' + e.message);
360+
}
361+
}
362+
363+
// --- Init ---
364+
// Check URL params for initial data
365+
const params = new URLSearchParams(location.search);
366+
if (params.get('data')) {
367+
try {
368+
const data = JSON.parse(decodeURIComponent(params.get('data')));
369+
if (data.goal) document.getElementById('goalInput').value = data.goal;
370+
if (data.steps) steps = data.steps;
371+
if (data.tools) { allTools = data.tools; renderToolList(); }
372+
} catch (e) {}
373+
}
374+
375+
render();
376+
377+
// ESC to close modal
378+
document.addEventListener('keydown', (e) => {
379+
if (e.key === 'Escape') closeModal();
380+
});
381+
</script>
382+
</body>
383+
</html>

0 commit comments

Comments
 (0)