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:
chungyeong
2026-05-18 00:38:46 +09:00
parent 40ef833ad3
commit 6d371afadd
14 changed files with 1007 additions and 52 deletions

View File

@@ -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();
}
});