feat(my-deepagent): v0.4 — workflow generator UI + hot-reload + UX polish
브라우저에서 YAML 안 쓰고도 새 워크플로우 템플릿 만들기 + 즉시 등록. + /new.html / index.html / new-workflow.html / runs.html / conversation.html 의 nav·copy·empty-state 정비. A. /new.html UX: - 제목 "새 Run" → "워크플로우 실행 (고급)" - 상단 info-box: "자유 대화는 여기가 아닙니다 → 메인 페이지" - 모든 필드에 한 줄 hint - Persona 오버라이드 <details> 접힘 B. Nav 재정렬 (5 페이지): - "대화" nav-primary, 나머지 nav-secondary (작고 dim) C. 메인 안내 + CSS: - 메인 / 에 "👋 my-deepagent" info-box 추가 - .info-box / .nav-primary / .nav-secondary / .wf-* 신규 스타일 D. Workflow hot-reload: - api/deps.py get_workflows 가 매 요청 mtime 튜플 검사 후 변경 시 reload - lifespan 도 user dir 포함하도록 _load_workflows_combined E. Workflow generator: - POST /api/workflows: CreateWorkflowRequest → WorkflowTemplate validate → <data_dir>/workflows/<name>@<version>.yaml 저장. 중복 409, validation 422. - static/new-workflow.html: 기본 정보 / Roles / Phases / YAML preview - app.js bootstrapWorkflowGenerator: capability chip 토글, role select 동적, 실시간 YAML preview, XSS 정책 유지 테스트 (test_workflow_generator.py, 7 신규): - 페이지 200 + 마크업 - POST happy / 422 (empty roles) / 422 (unknown role) / 409 (dup) - GET hot-reload after POST - GET hot-reload after external file drop 게이트: - ruff / format / mypy: PASS (142 source files) - pytest -q --ignore=tests/integration/test_e2e_workflow.py --ignore=tests/integration/test_openrouter_smoke.py: 709 passed (+7 신규) - 라이브 smoke: / / new.html / new-workflow.html 모두 200, screenshot OK Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -734,6 +734,284 @@ async function renderSessionsList() {
|
||||
}
|
||||
}
|
||||
|
||||
// =============== new-workflow.html (v0.4 generator) ===============
|
||||
|
||||
const _CAPABILITIES = [
|
||||
"spec_write", "code_review", "evidence_check", "log_analysis", "decision",
|
||||
"command_execute", "security_audit", "code_edit", "plan", "verify",
|
||||
];
|
||||
const _BACKENDS = ["openrouter", "anthropic", "ollama_local"];
|
||||
const _RISKS = ["low", "medium", "high"];
|
||||
|
||||
const WF_STATE = {
|
||||
roles: /** @type {Array<{id:string,capabilities:string[],backends:string[],fallbacks:string[]}>} */ ([]),
|
||||
phases: /** @type {Array<{key:string,title:string,risk:string,role:string,instructions:string,artifactPath:string,artifactSchema:string,gates:string,timeout:string,budget:string}>} */ ([]),
|
||||
};
|
||||
|
||||
function _wfFreshRole() {
|
||||
return { id: "", capabilities: [], backends: [], fallbacks: [] };
|
||||
}
|
||||
function _wfFreshPhase() {
|
||||
return {
|
||||
key: "", title: "", risk: "medium", role: "",
|
||||
instructions: "", artifactPath: "", artifactSchema: "",
|
||||
gates: "", timeout: "", budget: "",
|
||||
};
|
||||
}
|
||||
|
||||
function _wfChip(label, checked, onChange) {
|
||||
const lbl = document.createElement("label");
|
||||
lbl.className = "wf-chip";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.checked = checked;
|
||||
cb.addEventListener("change", () => onChange(cb.checked));
|
||||
const span = document.createElement("span");
|
||||
span.textContent = label;
|
||||
lbl.appendChild(cb);
|
||||
lbl.appendChild(span);
|
||||
return lbl;
|
||||
}
|
||||
|
||||
function _wfTextInput(value, placeholder, onChange, type = "text") {
|
||||
const i = document.createElement("input");
|
||||
i.type = type;
|
||||
i.value = value;
|
||||
i.placeholder = placeholder;
|
||||
i.addEventListener("input", () => onChange(i.value));
|
||||
return i;
|
||||
}
|
||||
|
||||
function _wfTextArea(value, placeholder, onChange, rows = 3) {
|
||||
const t = document.createElement("textarea");
|
||||
t.value = value;
|
||||
t.placeholder = placeholder;
|
||||
t.rows = rows;
|
||||
t.addEventListener("input", () => onChange(t.value));
|
||||
return t;
|
||||
}
|
||||
|
||||
function _wfSelect(value, options, onChange) {
|
||||
const s = document.createElement("select");
|
||||
for (const o of options) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = o;
|
||||
opt.textContent = o;
|
||||
if (o === value) opt.selected = true;
|
||||
s.appendChild(opt);
|
||||
}
|
||||
s.addEventListener("change", () => onChange(s.value));
|
||||
return s;
|
||||
}
|
||||
|
||||
function renderRolesList() {
|
||||
const container = $("#roles-list");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
WF_STATE.roles.forEach((role, idx) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wf-row-card";
|
||||
const header = document.createElement("div");
|
||||
header.className = "wf-row-header";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = `Role #${idx + 1}`;
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "button-link";
|
||||
del.textContent = "삭제";
|
||||
del.addEventListener("click", () => { WF_STATE.roles.splice(idx, 1); renderRolesList(); renderPreview(); });
|
||||
header.append(title, del);
|
||||
card.appendChild(header);
|
||||
|
||||
const idRow = document.createElement("div");
|
||||
idRow.className = "form-row";
|
||||
const idLbl = document.createElement("label");
|
||||
idLbl.innerHTML = "id <span class='hint'>— phase 가 참조할 키. <code>writer</code> 같은 소문자/숫자/언더스코어</span>";
|
||||
idRow.append(idLbl, _wfTextInput(role.id, "writer", (v) => { role.id = v; renderPreview(); }));
|
||||
card.appendChild(idRow);
|
||||
|
||||
const capRow = document.createElement("div");
|
||||
capRow.className = "form-row";
|
||||
const capLbl = document.createElement("label");
|
||||
capLbl.innerHTML = "required_capabilities <span class='hint'>— persona 가 가져야 할 능력 (최소 1)</span>";
|
||||
const chips = document.createElement("div");
|
||||
chips.className = "chips";
|
||||
for (const c of _CAPABILITIES) {
|
||||
chips.appendChild(_wfChip(c, role.capabilities.includes(c), (on) => {
|
||||
if (on && !role.capabilities.includes(c)) role.capabilities.push(c);
|
||||
else if (!on) role.capabilities = role.capabilities.filter((x) => x !== c);
|
||||
renderPreview();
|
||||
}));
|
||||
}
|
||||
capRow.append(capLbl, chips);
|
||||
card.appendChild(capRow);
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
if (WF_STATE.roles.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "hint";
|
||||
empty.textContent = "Role 이 1개 이상 필요합니다.";
|
||||
container.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPhasesList() {
|
||||
const container = $("#phases-list");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
const roleIds = WF_STATE.roles.map((r) => r.id).filter(Boolean);
|
||||
WF_STATE.phases.forEach((phase, idx) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wf-row-card";
|
||||
const header = document.createElement("div");
|
||||
header.className = "wf-row-header";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = `Phase #${idx + 1}`;
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "button-link";
|
||||
del.textContent = "삭제";
|
||||
del.addEventListener("click", () => { WF_STATE.phases.splice(idx, 1); renderPhasesList(); renderPreview(); });
|
||||
header.append(title, del);
|
||||
card.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "form-grid";
|
||||
for (const [label, key, ph] of [
|
||||
["key — 영문 소문자/숫자/언더스코어", "key", "spec"],
|
||||
["title — 표시용 한 줄", "title", "명세 작성"],
|
||||
]) {
|
||||
const r = document.createElement("div");
|
||||
r.className = "form-row";
|
||||
const l = document.createElement("label");
|
||||
l.textContent = label;
|
||||
r.append(l, _wfTextInput(phase[key], ph, (v) => { phase[key] = v; renderPreview(); }));
|
||||
grid.appendChild(r);
|
||||
}
|
||||
card.appendChild(grid);
|
||||
|
||||
const grid2 = document.createElement("div");
|
||||
grid2.className = "form-grid";
|
||||
const riskRow = document.createElement("div");
|
||||
riskRow.className = "form-row";
|
||||
const riskLbl = document.createElement("label");
|
||||
riskLbl.innerHTML = "risk <span class='hint'>— 단계 위험 등급</span>";
|
||||
riskRow.append(riskLbl, _wfSelect(phase.risk, _RISKS, (v) => { phase.risk = v; renderPreview(); }));
|
||||
grid2.appendChild(riskRow);
|
||||
const roleRow = document.createElement("div");
|
||||
roleRow.className = "form-row";
|
||||
const roleLbl = document.createElement("label");
|
||||
roleLbl.innerHTML = "role <span class='hint'>— 위에서 정의한 role id 중 하나</span>";
|
||||
const opts = roleIds.length > 0 ? roleIds : ["(role 을 먼저 정의)"];
|
||||
roleRow.append(roleLbl, _wfSelect(phase.role, opts, (v) => { phase.role = v; renderPreview(); }));
|
||||
grid2.appendChild(roleRow);
|
||||
card.appendChild(grid2);
|
||||
|
||||
const insRow = document.createElement("div");
|
||||
insRow.className = "form-row";
|
||||
const insLbl = document.createElement("label");
|
||||
insLbl.innerHTML = "instructions <span class='hint'>— 최소 10자. 이 phase 가 무엇을 해야 하는지</span>";
|
||||
insRow.append(insLbl, _wfTextArea(phase.instructions,
|
||||
"예: requirements.md 를 읽고 spec.md 를 작성하세요. 한국어 권장.",
|
||||
(v) => { phase.instructions = v; renderPreview(); }, 4));
|
||||
card.appendChild(insRow);
|
||||
|
||||
const grid3 = document.createElement("div");
|
||||
grid3.className = "form-grid";
|
||||
for (const [label, key, ph] of [
|
||||
["expected_artifact.path (선택)", "artifactPath", "artifacts/spec.md"],
|
||||
["expected_artifact.schema (선택)", "artifactSchema", "spec-v1"],
|
||||
]) {
|
||||
const r = document.createElement("div");
|
||||
r.className = "form-row";
|
||||
const l = document.createElement("label");
|
||||
l.textContent = label;
|
||||
r.append(l, _wfTextInput(phase[key], ph, (v) => { phase[key] = v; renderPreview(); }));
|
||||
grid3.appendChild(r);
|
||||
}
|
||||
card.appendChild(grid3);
|
||||
container.appendChild(card);
|
||||
});
|
||||
if (WF_STATE.phases.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "hint";
|
||||
empty.textContent = "Phase 가 1개 이상 필요합니다.";
|
||||
container.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
function _wfBuildRequest() {
|
||||
const name = $("#wf-name").value.trim();
|
||||
const version = parseInt($("#wf-version").value, 10);
|
||||
const description = $("#wf-description").value.trim();
|
||||
const roles = WF_STATE.roles.filter((r) => r.id).map((r) => ({
|
||||
id: r.id,
|
||||
required_capabilities: r.capabilities,
|
||||
preferred_backends: r.backends,
|
||||
fallback_personas: r.fallbacks,
|
||||
}));
|
||||
const phases = WF_STATE.phases.filter((p) => p.key).map((p) => {
|
||||
const out = {
|
||||
key: p.key,
|
||||
title: p.title || p.key,
|
||||
risk: p.risk,
|
||||
role: p.role,
|
||||
instructions: p.instructions || "(no instructions)",
|
||||
gates: [],
|
||||
};
|
||||
if (p.artifactPath || p.artifactSchema) {
|
||||
out.expected_artifact = {
|
||||
path: p.artifactPath || "artifacts/output.md",
|
||||
schema: p.artifactSchema || "text",
|
||||
};
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const req = { name, version: isNaN(version) ? 1 : version, roles, phases, default_gates: [] };
|
||||
if (description) req.description = description;
|
||||
return req;
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const pre = $("#wf-preview");
|
||||
if (!pre) return;
|
||||
pre.textContent = JSON.stringify(_wfBuildRequest(), null, 2);
|
||||
}
|
||||
|
||||
async function submitWorkflow(ev) {
|
||||
ev.preventDefault();
|
||||
setError("");
|
||||
$("#success").style.display = "none";
|
||||
const req = _wfBuildRequest();
|
||||
try {
|
||||
const ack = await jsonFetch("/workflows", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
const okBox = $("#success");
|
||||
okBox.textContent = `✅ 저장 완료 → ${ack.path}. 워크플로우 실행 페이지에서 바로 보입니다.`;
|
||||
okBox.style.display = "block";
|
||||
} catch (e) {
|
||||
setError(`저장 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapWorkflowGenerator() {
|
||||
WF_STATE.roles = [_wfFreshRole()];
|
||||
WF_STATE.phases = [_wfFreshPhase()];
|
||||
renderRolesList();
|
||||
renderPhasesList();
|
||||
renderPreview();
|
||||
$("#add-role").addEventListener("click", () => { WF_STATE.roles.push(_wfFreshRole()); renderRolesList(); renderPreview(); });
|
||||
$("#add-phase").addEventListener("click", () => { WF_STATE.phases.push(_wfFreshPhase()); renderPhasesList(); renderPreview(); });
|
||||
$("#wf-name").addEventListener("input", renderPreview);
|
||||
$("#wf-version").addEventListener("input", renderPreview);
|
||||
$("#wf-description").addEventListener("input", renderPreview);
|
||||
$("#wf-form").addEventListener("submit", submitWorkflow);
|
||||
}
|
||||
|
||||
// =============== bootstrap ===============
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -751,5 +1029,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
$("#resume-btn").addEventListener("click", resumeRun);
|
||||
} else if (page === "conversation") {
|
||||
bootstrapConversationPage();
|
||||
} else if (page === "new-workflow") {
|
||||
bootstrapWorkflowGenerator();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user