feat: Implement Pipeline Templates for Company Suite and refine orchestration logic
This commit is contained in:
+525
-19
@@ -827,6 +827,15 @@
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'companyPipelineTemplateContent': {
|
||||
const tpl = msg.value;
|
||||
if (!tpl) { showToast('템플릿을 찾을 수 없습니다.', 'warn'); break; }
|
||||
// 에디터를 미리 채워서 연다 — 사용자는 ID/name/지시문만 다듬으면 됨.
|
||||
if (typeof window.__openPipelineEditorWithTemplate === 'function') {
|
||||
window.__openPipelineEditorWithTemplate(tpl);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'upsertCompanyPipelineResult': {
|
||||
const v = msg.value || {};
|
||||
const errEl = document.getElementById('pipelineEditError');
|
||||
@@ -1627,18 +1636,350 @@
|
||||
};
|
||||
}
|
||||
|
||||
// ── Work Pipeline editor ──
|
||||
// ── Work Pipeline editor (card-based) ──
|
||||
const _activePipelineSel = document.getElementById('activePipelineSel');
|
||||
const _pipelineList = document.getElementById('companyPipelineList');
|
||||
const _addPipelineBtn = document.getElementById('addCompanyPipelineBtn');
|
||||
const _pipelineEditForm = document.getElementById('pipelineEditForm');
|
||||
const _pipelineEditId = document.getElementById('pipelineEditId');
|
||||
const _pipelineEditName = document.getElementById('pipelineEditName');
|
||||
const _pipelineEditStages = document.getElementById('pipelineEditStages');
|
||||
const _pipelineStageList = document.getElementById('pipelineStageList');
|
||||
const _addStageBtn = document.getElementById('addStageBtn');
|
||||
const _pipelineEditError = document.getElementById('pipelineEditError');
|
||||
const _cancelPipelineEditBtn = document.getElementById('cancelPipelineEditBtn');
|
||||
const _savePipelineEditBtn = document.getElementById('savePipelineEditBtn');
|
||||
|
||||
// pipeline 페이로드에 같이 오는 직군별 활성 에이전트 캐시.
|
||||
// 카드의 "직군 → 담당" cascading dropdown이 이걸 참조한다.
|
||||
let _activeAgentsByCategory = {};
|
||||
|
||||
// 현재 편집 중인 stages의 in-memory 표현. 카드 UI는 이 배열만 보고
|
||||
// 다시 그려지므로 텍스트 입력 외 모든 상태 변경(추가/삭제/순서변경/
|
||||
// 직군 변경)이 즉시 re-render를 통해 화면과 데이터를 동기화한다.
|
||||
let _editStages = [];
|
||||
// 드래그 중인 stage의 원본 인덱스. -1 = 드래그 중 아님.
|
||||
// 한 페이지에 에디터 인스턴스는 하나뿐이라 모듈 스코프로 충분.
|
||||
let _draggedStageIndex = -1;
|
||||
|
||||
const _genStageId = (taken) => {
|
||||
const used = new Set(taken);
|
||||
let n = 1;
|
||||
while (used.has(`stage-${n}`)) n++;
|
||||
return `stage-${n}`;
|
||||
};
|
||||
|
||||
const _emptyStage = () => ({
|
||||
id: _genStageId(_editStages.map((s) => s.id)),
|
||||
label: '',
|
||||
roleCategory: 'planner',
|
||||
agentId: '',
|
||||
modelOverride: '',
|
||||
requiresApproval: false,
|
||||
instructionTemplate: '',
|
||||
loopBackPattern: '',
|
||||
loopBackTo: '',
|
||||
maxIterations: 3,
|
||||
});
|
||||
|
||||
const _firstAgentOfCategory = (cat) => {
|
||||
const list = _activeAgentsByCategory[cat] || [];
|
||||
return list[0]?.id || '';
|
||||
};
|
||||
|
||||
const _buildStageCard = (stage, index, total) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'pipeline-stage-card';
|
||||
li.dataset.stageId = stage.id;
|
||||
li.dataset.index = String(index);
|
||||
|
||||
// ── head: number + label input + ↑/↓/🗑 ──
|
||||
const head = document.createElement('div');
|
||||
head.className = 'psc-head';
|
||||
const num = document.createElement('span');
|
||||
num.className = 'psc-num';
|
||||
num.textContent = String(index + 1);
|
||||
num.title = '드래그하여 순서 변경 (↑/↓ 버튼도 가능)';
|
||||
|
||||
// ── 드래그 핸들: 번호 칩만 드래그 시작점이 되도록.
|
||||
// textarea·input 영역에서 드래그가 시작되면 텍스트 선택과 충돌함.
|
||||
// li 자체는 드래그 가능하되 dragstart는 핸들 클릭 후에만 허용.
|
||||
li.draggable = false;
|
||||
let _dragArmed = false;
|
||||
num.addEventListener('mousedown', () => {
|
||||
li.draggable = true;
|
||||
_dragArmed = true;
|
||||
});
|
||||
// mouseup이 어디서든 일어나면 드래그 해제 (drop 후엔 dragend가 처리).
|
||||
const _disarm = () => {
|
||||
if (_dragArmed) { _dragArmed = false; li.draggable = false; }
|
||||
};
|
||||
li.addEventListener('mouseup', _disarm);
|
||||
li.addEventListener('mouseleave', _disarm);
|
||||
|
||||
li.addEventListener('dragstart', (e) => {
|
||||
if (!_dragArmed) { e.preventDefault(); return; }
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// 텍스트 페이로드는 필요 없지만 일부 브라우저가 비어 있으면 무시.
|
||||
try { e.dataTransfer.setData('text/plain', stage.id); } catch {}
|
||||
li.classList.add('dragging');
|
||||
// li reference를 모듈 스코프 변수에 기록 — dragover/drop이 참조.
|
||||
_draggedStageIndex = index;
|
||||
});
|
||||
li.addEventListener('dragend', () => {
|
||||
li.classList.remove('dragging');
|
||||
li.draggable = false;
|
||||
_dragArmed = false;
|
||||
_draggedStageIndex = -1;
|
||||
// 모든 카드의 drop indicator 제거.
|
||||
_pipelineStageList?.querySelectorAll('.drop-above, .drop-below')
|
||||
.forEach((el) => el.classList.remove('drop-above', 'drop-below'));
|
||||
});
|
||||
li.addEventListener('dragover', (e) => {
|
||||
if (_draggedStageIndex < 0 || _draggedStageIndex === index) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
// 카드의 위/아래 절반에 따라 indicator 위치 결정.
|
||||
const rect = li.getBoundingClientRect();
|
||||
const above = (e.clientY - rect.top) < rect.height / 2;
|
||||
li.classList.toggle('drop-above', above);
|
||||
li.classList.toggle('drop-below', !above);
|
||||
});
|
||||
li.addEventListener('dragleave', () => {
|
||||
li.classList.remove('drop-above', 'drop-below');
|
||||
});
|
||||
li.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
li.classList.remove('drop-above', 'drop-below');
|
||||
if (_draggedStageIndex < 0 || _draggedStageIndex === index) return;
|
||||
const rect = li.getBoundingClientRect();
|
||||
const above = (e.clientY - rect.top) < rect.height / 2;
|
||||
const from = _draggedStageIndex;
|
||||
let to = above ? index : index + 1;
|
||||
// splice 보정: from < to 인 경우 to를 한 칸 당겨야 의도한 위치.
|
||||
if (from < to) to -= 1;
|
||||
if (from === to) return;
|
||||
const [moved] = _editStages.splice(from, 1);
|
||||
_editStages.splice(to, 0, moved);
|
||||
_draggedStageIndex = -1;
|
||||
_renderStages();
|
||||
});
|
||||
const lbl = document.createElement('input');
|
||||
lbl.type = 'text';
|
||||
lbl.className = 'psc-label';
|
||||
lbl.placeholder = '단계 이름 (예: 기획 논의)';
|
||||
lbl.value = stage.label || '';
|
||||
lbl.oninput = () => { stage.label = lbl.value; };
|
||||
const ctrls = document.createElement('div');
|
||||
ctrls.className = 'psc-controls';
|
||||
const upBtn = document.createElement('button');
|
||||
upBtn.textContent = '↑'; upBtn.title = '위로'; upBtn.disabled = index === 0;
|
||||
upBtn.onclick = () => { if (index > 0) { [_editStages[index - 1], _editStages[index]] = [_editStages[index], _editStages[index - 1]]; _renderStages(); } };
|
||||
const downBtn = document.createElement('button');
|
||||
downBtn.textContent = '↓'; downBtn.title = '아래로'; downBtn.disabled = index === total - 1;
|
||||
downBtn.onclick = () => { if (index < _editStages.length - 1) { [_editStages[index + 1], _editStages[index]] = [_editStages[index], _editStages[index + 1]]; _renderStages(); } };
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'del'; delBtn.textContent = '🗑'; delBtn.title = '단계 삭제';
|
||||
delBtn.onclick = () => {
|
||||
if (!confirm(`'${stage.label || stage.id}' 단계를 삭제할까요?`)) return;
|
||||
_editStages.splice(index, 1);
|
||||
_renderStages();
|
||||
};
|
||||
ctrls.appendChild(upBtn); ctrls.appendChild(downBtn); ctrls.appendChild(delBtn);
|
||||
head.appendChild(num); head.appendChild(lbl); head.appendChild(ctrls);
|
||||
li.appendChild(head);
|
||||
|
||||
// ── body ──
|
||||
const body = document.createElement('div');
|
||||
body.className = 'psc-body';
|
||||
|
||||
// row: 직군 → 담당
|
||||
const row = document.createElement('div');
|
||||
row.className = 'psc-row';
|
||||
const roleLabel = document.createElement('label');
|
||||
roleLabel.textContent = '직군:';
|
||||
const roleSel = document.createElement('select');
|
||||
for (const cat of _roleCategoryOrder) {
|
||||
if (cat === 'ceo') continue; // stage agent로 CEO는 안 씀
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cat;
|
||||
opt.textContent = _roleCategoryLabels[cat] || cat;
|
||||
roleSel.appendChild(opt);
|
||||
}
|
||||
roleSel.value = stage.roleCategory || 'planner';
|
||||
const agentLabel = document.createElement('label');
|
||||
agentLabel.textContent = '담당:';
|
||||
const agentSel = document.createElement('select');
|
||||
const _refillAgentSel = () => {
|
||||
agentSel.innerHTML = '';
|
||||
const list = _activeAgentsByCategory[roleSel.value] || [];
|
||||
if (list.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = '(이 직군의 활성 에이전트 없음)';
|
||||
agentSel.appendChild(opt);
|
||||
agentSel.disabled = true;
|
||||
} else {
|
||||
agentSel.disabled = false;
|
||||
for (const a of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.emoji} ${a.name}`;
|
||||
agentSel.appendChild(opt);
|
||||
}
|
||||
// 현재 stage의 agentId가 이 직군에 속하면 유지, 아니면 첫 번째.
|
||||
const inList = list.some((a) => a.id === stage.agentId);
|
||||
agentSel.value = inList ? stage.agentId : list[0].id;
|
||||
stage.agentId = agentSel.value;
|
||||
}
|
||||
};
|
||||
_refillAgentSel();
|
||||
roleSel.onchange = () => {
|
||||
stage.roleCategory = roleSel.value;
|
||||
stage.agentId = _firstAgentOfCategory(roleSel.value);
|
||||
_refillAgentSel();
|
||||
};
|
||||
agentSel.onchange = () => { stage.agentId = agentSel.value; };
|
||||
row.appendChild(roleLabel); row.appendChild(roleSel);
|
||||
row.appendChild(agentLabel); row.appendChild(agentSel);
|
||||
|
||||
// 모델 select — 비워두면 에이전트의 모델 override → 글로벌 default 사용.
|
||||
const modelLbl = document.createElement('label'); modelLbl.textContent = '모델:';
|
||||
const stageModelSel = document.createElement('select');
|
||||
stageModelSel.title = '비워두면 담당 에이전트의 모델 설정(또는 글로벌 기본)을 사용';
|
||||
populateAgentModelSelect(stageModelSel, stage.modelOverride || '');
|
||||
// populateAgentModelSelect는 첫 옵션 라벨이 "default (global)"인데
|
||||
// stage 맥락에선 "기본 (에이전트 설정 사용)"이 더 정확. 첫 옵션 텍스트만 교체.
|
||||
if (stageModelSel.options.length > 0 && stageModelSel.options[0].value === '') {
|
||||
stageModelSel.options[0].text = '기본 (에이전트 설정)';
|
||||
}
|
||||
stageModelSel.onchange = () => { stage.modelOverride = stageModelSel.value || ''; };
|
||||
row.appendChild(modelLbl); row.appendChild(stageModelSel);
|
||||
|
||||
body.appendChild(row);
|
||||
|
||||
// ── 승인 게이트 체크박스 ──
|
||||
// 한 stage가 끝난 뒤 사용자가 직접 ✅승인 / ✎수정요청 / 🛑중단 을
|
||||
// 누를 때까지 dispatcher가 대기. 검토 stage(inspector)나 중요 결정
|
||||
// 직전에 켜두면 자동 진행을 막을 수 있다.
|
||||
const approvalWrap = document.createElement('label');
|
||||
approvalWrap.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:10px; color:var(--text-dim); cursor:pointer;';
|
||||
const approvalCb = document.createElement('input');
|
||||
approvalCb.type = 'checkbox';
|
||||
approvalCb.checked = !!stage.requiresApproval;
|
||||
approvalCb.onchange = () => { stage.requiresApproval = approvalCb.checked; };
|
||||
approvalWrap.appendChild(approvalCb);
|
||||
const approvalText = document.createElement('span');
|
||||
approvalText.textContent = '✋ 이 단계 후 내 승인 받기';
|
||||
approvalText.title = '체크하면 stage 완료 후 자동 진행하지 않고 승인/수정요청/중단 버튼이 채팅창에 뜸';
|
||||
approvalWrap.appendChild(approvalText);
|
||||
body.appendChild(approvalWrap);
|
||||
|
||||
// 지시 텍스트 + 토큰 버튼
|
||||
const instrLabelDiv = document.createElement('div');
|
||||
instrLabelDiv.className = 'psc-field-label';
|
||||
instrLabelDiv.textContent = '지시 내용 (담당자에게 전달될 메시지)';
|
||||
body.appendChild(instrLabelDiv);
|
||||
|
||||
const tokens = document.createElement('div');
|
||||
tokens.className = 'psc-tokens';
|
||||
const instr = document.createElement('textarea');
|
||||
instr.className = 'psc-instr';
|
||||
instr.placeholder = '예: {{userPrompt}} 에 대한 기획서 초안을 작성해주세요. 시장 조사 결과({{stage.research}})를 참고하세요.';
|
||||
instr.value = stage.instructionTemplate || '';
|
||||
instr.oninput = () => { stage.instructionTemplate = instr.value; };
|
||||
|
||||
const _insertToken = (token) => {
|
||||
const start = instr.selectionStart ?? instr.value.length;
|
||||
const end = instr.selectionEnd ?? instr.value.length;
|
||||
instr.value = instr.value.slice(0, start) + token + instr.value.slice(end);
|
||||
stage.instructionTemplate = instr.value;
|
||||
instr.focus();
|
||||
instr.selectionStart = instr.selectionEnd = start + token.length;
|
||||
};
|
||||
const mkTokenBtn = (label, token) => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = label;
|
||||
b.onclick = (e) => { e.preventDefault(); _insertToken(token); };
|
||||
return b;
|
||||
};
|
||||
tokens.appendChild(mkTokenBtn('+ 사용자 요청', '{{userPrompt}}'));
|
||||
tokens.appendChild(mkTokenBtn('+ CEO 브리프', '{{brief}}'));
|
||||
// 이전 stages의 출력을 참조하는 토큰들 — 자기 자신 이후는 제외.
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prev = _editStages[i];
|
||||
const label = prev.label || prev.id;
|
||||
tokens.appendChild(mkTokenBtn(`+ ${label}`, `{{stage.${prev.id}}}`));
|
||||
}
|
||||
body.appendChild(tokens);
|
||||
body.appendChild(instr);
|
||||
|
||||
// ── loop-back details ──
|
||||
const loop = document.createElement('details');
|
||||
loop.className = 'psc-loop';
|
||||
if (stage.loopBackPattern || stage.loopBackTo) loop.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
const hasLoop = !!(stage.loopBackPattern && stage.loopBackTo);
|
||||
summary.textContent = hasLoop
|
||||
? `🔁 재시도 활성: "${stage.loopBackPattern}" 발견 시 → ${_editStages.find((s) => s.id === stage.loopBackTo)?.label || stage.loopBackTo}`
|
||||
: '🔁 재시도 조건 설정하기 (선택)';
|
||||
loop.appendChild(summary);
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'psc-loop-grid';
|
||||
// condition
|
||||
const condLbl = document.createElement('label'); condLbl.textContent = '조건 (regex):';
|
||||
const condInput = document.createElement('input');
|
||||
condInput.type = 'text';
|
||||
condInput.placeholder = '예: 버그|오류|fail|재작업';
|
||||
condInput.value = stage.loopBackPattern || '';
|
||||
condInput.oninput = () => { stage.loopBackPattern = condInput.value; };
|
||||
// target
|
||||
const tgtLbl = document.createElement('label'); tgtLbl.textContent = '되돌아갈 단계:';
|
||||
const tgtSel = document.createElement('select');
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = ''; emptyOpt.textContent = '(선택 안 함)';
|
||||
tgtSel.appendChild(emptyOpt);
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prev = _editStages[i];
|
||||
const opt = document.createElement('option');
|
||||
opt.value = prev.id;
|
||||
opt.textContent = `${i + 1}. ${prev.label || prev.id}`;
|
||||
tgtSel.appendChild(opt);
|
||||
}
|
||||
tgtSel.value = (stage.loopBackTo && index > 0) ? stage.loopBackTo : '';
|
||||
tgtSel.onchange = () => { stage.loopBackTo = tgtSel.value; };
|
||||
// max
|
||||
const maxLbl = document.createElement('label'); maxLbl.textContent = '최대 반복:';
|
||||
const maxInput = document.createElement('input');
|
||||
maxInput.type = 'number'; maxInput.min = '1'; maxInput.max = '10';
|
||||
maxInput.value = String(stage.maxIterations || 3);
|
||||
maxInput.oninput = () => {
|
||||
const v = parseInt(maxInput.value, 10);
|
||||
stage.maxIterations = Number.isFinite(v) ? Math.max(1, Math.min(10, v)) : 3;
|
||||
};
|
||||
grid.appendChild(condLbl); grid.appendChild(condInput);
|
||||
grid.appendChild(tgtLbl); grid.appendChild(tgtSel);
|
||||
grid.appendChild(maxLbl); grid.appendChild(maxInput);
|
||||
if (index === 0) {
|
||||
const note = document.createElement('div');
|
||||
note.style.cssText = 'grid-column:1/-1; font-style:italic; opacity:0.7;';
|
||||
note.textContent = '첫 단계는 되돌아갈 곳이 없어 재시도 설정이 적용되지 않습니다.';
|
||||
grid.appendChild(note);
|
||||
}
|
||||
loop.appendChild(grid);
|
||||
body.appendChild(loop);
|
||||
|
||||
li.appendChild(body);
|
||||
return li;
|
||||
};
|
||||
|
||||
const _renderStages = () => {
|
||||
if (!_pipelineStageList) return;
|
||||
_pipelineStageList.innerHTML = '';
|
||||
for (let i = 0; i < _editStages.length; i++) {
|
||||
_pipelineStageList.appendChild(_buildStageCard(_editStages[i], i, _editStages.length));
|
||||
}
|
||||
};
|
||||
|
||||
const _openPipelineEditor = (pipeline) => {
|
||||
if (!_pipelineEditForm) return;
|
||||
_pipelineEditForm.setAttribute('data-open', 'true');
|
||||
@@ -1646,17 +1987,39 @@
|
||||
if (pipeline) {
|
||||
if (_pipelineEditId) { _pipelineEditId.value = pipeline.id; _pipelineEditId.disabled = true; }
|
||||
if (_pipelineEditName) _pipelineEditName.value = pipeline.name || '';
|
||||
if (_pipelineEditStages) _pipelineEditStages.value = JSON.stringify(pipeline.stages || [], null, 2);
|
||||
// stages 데이터를 깊은 복사 — 사용자 수정이 cancel 시 안 새도록.
|
||||
_editStages = (pipeline.stages || []).map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label || '',
|
||||
// 기존 데이터에 roleCategory가 없으면 담당 agentId의 직군으로 추정.
|
||||
roleCategory: s.roleCategory || _deriveRoleFromAgent(s.agentId) || 'planner',
|
||||
agentId: s.agentId || '',
|
||||
modelOverride: s.modelOverride || '',
|
||||
requiresApproval: !!s.requiresApproval,
|
||||
instructionTemplate: s.instructionTemplate || '',
|
||||
loopBackPattern: s.loopBackPattern || '',
|
||||
loopBackTo: s.loopBackTo || '',
|
||||
maxIterations: s.maxIterations || 3,
|
||||
}));
|
||||
} else {
|
||||
if (_pipelineEditId) { _pipelineEditId.value = ''; _pipelineEditId.disabled = false; }
|
||||
if (_pipelineEditName) _pipelineEditName.value = '';
|
||||
if (_pipelineEditStages) _pipelineEditStages.value = '[]';
|
||||
_editStages = [];
|
||||
}
|
||||
_renderStages();
|
||||
};
|
||||
|
||||
// 빌트인 + 커스텀 에이전트의 직군 매핑 캐시 (lastCompanyAgentsPayload 활용)
|
||||
const _deriveRoleFromAgent = (agentId) => {
|
||||
if (!agentId || !_lastCompanyAgentsPayload) return null;
|
||||
const agent = (_lastCompanyAgentsPayload.agents || []).find((a) => a.id === agentId);
|
||||
return agent?.roleCategory || null;
|
||||
};
|
||||
|
||||
const _closePipelineEditor = () => {
|
||||
if (_pipelineEditForm) _pipelineEditForm.setAttribute('data-open', 'false');
|
||||
if (_pipelineEditId) _pipelineEditId.disabled = false;
|
||||
_editStages = [];
|
||||
};
|
||||
|
||||
if (_addPipelineBtn) {
|
||||
@@ -1665,31 +2028,75 @@
|
||||
_pipelineEditId?.focus();
|
||||
};
|
||||
}
|
||||
|
||||
// 템플릿에서 새 pipeline 만들기. 선택 시 백엔드에 stages를 요청 →
|
||||
// companyPipelineTemplateContent 응답이 오면 에디터를 미리 채워 연다.
|
||||
const _pipelineTemplateSel = document.getElementById('pipelineTemplateSel');
|
||||
if (_pipelineTemplateSel) {
|
||||
_pipelineTemplateSel.onchange = () => {
|
||||
const tplId = _pipelineTemplateSel.value;
|
||||
if (!tplId) return;
|
||||
vscode.postMessage({ type: 'getCompanyPipelineTemplate', templateId: tplId });
|
||||
// 선택 후 즉시 초기화 — 사용자가 같은 템플릿을 다시 찍어도 onchange가 다시 발화하도록.
|
||||
_pipelineTemplateSel.value = '';
|
||||
};
|
||||
}
|
||||
if (_addStageBtn) {
|
||||
_addStageBtn.onclick = () => {
|
||||
const ns = _emptyStage();
|
||||
ns.agentId = _firstAgentOfCategory(ns.roleCategory);
|
||||
_editStages.push(ns);
|
||||
_renderStages();
|
||||
// 새로 추가한 카드의 라벨 입력으로 포커스 — 이름부터 적게 유도.
|
||||
const newCard = _pipelineStageList?.lastElementChild;
|
||||
newCard?.querySelector('.psc-label')?.focus();
|
||||
};
|
||||
}
|
||||
if (_cancelPipelineEditBtn) {
|
||||
_cancelPipelineEditBtn.onclick = _closePipelineEditor;
|
||||
}
|
||||
if (_savePipelineEditBtn) {
|
||||
_savePipelineEditBtn.onclick = () => {
|
||||
let stages;
|
||||
try {
|
||||
stages = JSON.parse(_pipelineEditStages?.value || '[]');
|
||||
} catch (e) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = `Stages JSON 파싱 실패: ${e.message}`;
|
||||
const id = (_pipelineEditId?.value || '').trim().toLowerCase();
|
||||
if (!id) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자로 시작, /-/_).';
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(stages)) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = 'Stages는 배열이어야 합니다.';
|
||||
return;
|
||||
// 각 stage 검증: 라벨 + 담당 에이전트 필수.
|
||||
for (let i = 0; i < _editStages.length; i++) {
|
||||
const s = _editStages[i];
|
||||
if (!s.label?.trim()) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 이름이 비어 있습니다.`;
|
||||
return;
|
||||
}
|
||||
if (!s.agentId) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당 에이전트를 지정하세요. 해당 직군에 활성 에이전트가 없으면 관리 패널에서 활성화 먼저.`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const def = {
|
||||
id: (_pipelineEditId?.value || '').trim().toLowerCase(),
|
||||
name: (_pipelineEditName?.value || '').trim(),
|
||||
stages,
|
||||
id,
|
||||
name: (_pipelineEditName?.value || '').trim() || id,
|
||||
stages: _editStages.map((s) => {
|
||||
const out = {
|
||||
id: s.id,
|
||||
label: s.label.trim(),
|
||||
agentId: s.agentId,
|
||||
roleCategory: s.roleCategory,
|
||||
instructionTemplate: s.instructionTemplate || '',
|
||||
};
|
||||
if (s.modelOverride && s.modelOverride.trim()) {
|
||||
out.modelOverride = s.modelOverride.trim();
|
||||
}
|
||||
if (s.requiresApproval) out.requiresApproval = true;
|
||||
if (s.loopBackPattern && s.loopBackTo) {
|
||||
out.loopBackPattern = s.loopBackPattern;
|
||||
out.loopBackTo = s.loopBackTo;
|
||||
out.maxIterations = s.maxIterations || 3;
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
};
|
||||
if (!def.id) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자/숫자/-/_).';
|
||||
return;
|
||||
}
|
||||
vscode.postMessage({ type: 'upsertCompanyPipeline', def });
|
||||
};
|
||||
}
|
||||
@@ -1702,10 +2109,35 @@
|
||||
};
|
||||
}
|
||||
|
||||
let _lastCompanyPipelinesPayload = null;
|
||||
const renderCompanyPipelines = (payload) => {
|
||||
if (!_pipelineList || !_activePipelineSel) return;
|
||||
_lastCompanyPipelinesPayload = payload || null;
|
||||
const pipelines = (payload && payload.pipelines && typeof payload.pipelines === 'object') ? payload.pipelines : {};
|
||||
const activeId = payload && payload.activePipelineId ? payload.activePipelineId : '';
|
||||
// 직군별 에이전트 + 라벨 캐시 갱신. 에디터가 열려 있는 동안
|
||||
// 새 페이로드가 오면 카드의 담당 dropdown도 새 목록 반영.
|
||||
if (payload && payload.activeAgentsByCategory) {
|
||||
_activeAgentsByCategory = payload.activeAgentsByCategory;
|
||||
}
|
||||
if (payload && payload.roleCategoryLabels) _roleCategoryLabels = payload.roleCategoryLabels;
|
||||
if (payload && Array.isArray(payload.roleCategoryOrder)) _roleCategoryOrder = payload.roleCategoryOrder;
|
||||
// 템플릿 드롭다운 채우기.
|
||||
const tplSel = document.getElementById('pipelineTemplateSel');
|
||||
if (tplSel && payload && Array.isArray(payload.templates)) {
|
||||
tplSel.innerHTML = '<option value="">📋 템플릿에서…</option>';
|
||||
for (const t of payload.templates) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.templateId;
|
||||
opt.textContent = `${t.name} (${t.stageCount}단계)`;
|
||||
opt.title = t.description || '';
|
||||
tplSel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
// 에디터가 열려 있으면 stages 다시 그려서 새로운 담당 옵션 반영.
|
||||
if (_pipelineEditForm?.getAttribute('data-open') === 'true' && _editStages.length > 0) {
|
||||
_renderStages();
|
||||
}
|
||||
// active dropdown
|
||||
_activePipelineSel.innerHTML = '<option value="">기본 (CEO 자유 분배)</option>';
|
||||
for (const p of Object.values(pipelines)) {
|
||||
@@ -1759,6 +2191,25 @@
|
||||
// expose for the message handler below
|
||||
window.__renderCompanyPipelines = renderCompanyPipelines;
|
||||
window.__closePipelineEditor = _closePipelineEditor;
|
||||
// 템플릿 stamp 시 호출 — id/name 제안값 + stages를 카드 에디터에 미리 채움.
|
||||
window.__openPipelineEditorWithTemplate = (tpl) => {
|
||||
if (!tpl) return;
|
||||
// suggested id가 이미 존재하면 -2, -3 식으로 충돌 회피.
|
||||
const taken = new Set(Object.keys((_lastCompanyPipelinesPayload?.pipelines) || {}));
|
||||
let id = tpl.suggestedPipelineId || 'pipeline';
|
||||
if (taken.has(id)) {
|
||||
let n = 2;
|
||||
while (taken.has(`${id}-${n}`)) n++;
|
||||
id = `${id}-${n}`;
|
||||
}
|
||||
_openPipelineEditor({
|
||||
id,
|
||||
name: tpl.suggestedPipelineName || id,
|
||||
stages: tpl.stages || [],
|
||||
});
|
||||
// suggested id는 새로 만드는 것이므로 잠가두지 않고 사용자가 바꿀 수 있게.
|
||||
if (_pipelineEditId) _pipelineEditId.disabled = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the last payload around so we can re-render whenever the
|
||||
@@ -2172,6 +2623,61 @@
|
||||
} else if (ev.phase === 'stage-loop') {
|
||||
card.innerHTML = `<div class="cph-head">🔁 Stage 재시도</div>
|
||||
<div class="cph-meta">${escAttr(ev.from)} → ${escAttr(ev.to)} (반복 ${ev.iteration}회)</div>`;
|
||||
} else if (ev.phase === 'awaiting-approval') {
|
||||
// 승인 게이트 카드 — 사용자가 클릭할 때까지 대기.
|
||||
card.className += ' approval';
|
||||
card.dataset.stageId = ev.stageId;
|
||||
const head = document.createElement('div');
|
||||
head.className = 'cph-head';
|
||||
head.innerHTML = `✋ <strong>${escAttr(ev.stageLabel || ev.stageId)}</strong> 완료 — 검토 후 승인해 주세요`;
|
||||
const actions = document.createElement('div');
|
||||
actions.style.cssText = 'display:flex; gap:6px; margin-top:8px; flex-wrap:wrap;';
|
||||
const approveBtn = document.createElement('button');
|
||||
approveBtn.className = 'send-btn';
|
||||
approveBtn.textContent = '✅ 승인 (다음으로)';
|
||||
approveBtn.onclick = () => {
|
||||
vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'approve' });
|
||||
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||
approveBtn.textContent = '✅ 승인됨';
|
||||
};
|
||||
const reviseBtn = document.createElement('button');
|
||||
reviseBtn.className = 'secondary-btn';
|
||||
reviseBtn.textContent = '✎ 수정 요청';
|
||||
reviseBtn.onclick = () => {
|
||||
const comment = prompt('어떤 부분을 수정하면 좋을까요? (담당 에이전트에게 전달됩니다)', '');
|
||||
if (comment === null) return; // 취소
|
||||
vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'revise', comment });
|
||||
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||
reviseBtn.textContent = '✎ 수정 요청 전송됨';
|
||||
};
|
||||
const abortBtn = document.createElement('button');
|
||||
abortBtn.className = 'secondary-btn';
|
||||
abortBtn.textContent = '🛑 중단';
|
||||
abortBtn.title = '여기서 파이프라인 중단';
|
||||
abortBtn.onclick = () => {
|
||||
if (!confirm('이 라운드를 여기서 중단할까요? 이미 완료된 stage 결과는 보존됩니다.')) return;
|
||||
vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'abort' });
|
||||
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||
abortBtn.textContent = '🛑 중단 요청';
|
||||
};
|
||||
actions.appendChild(approveBtn);
|
||||
actions.appendChild(reviseBtn);
|
||||
actions.appendChild(abortBtn);
|
||||
card.innerHTML = '';
|
||||
card.appendChild(head);
|
||||
card.appendChild(actions);
|
||||
} else if (ev.phase === 'approval-resolved') {
|
||||
// 이전에 그려둔 awaiting-approval 카드를 갱신 — 별도 카드를 또 만들지 않음.
|
||||
const prev = chatEl.querySelector(`.company-phase-card.approval[data-stage-id="${CSS.escape(ev.stageId)}"]`);
|
||||
if (prev) {
|
||||
const tail = document.createElement('div');
|
||||
tail.className = 'cph-meta';
|
||||
tail.style.marginTop = '6px';
|
||||
const label = ev.decision === 'approve' ? '✅ 승인됨' : ev.decision === 'revise' ? '✎ 수정 요청됨' : '🛑 사용자가 중단';
|
||||
tail.textContent = label;
|
||||
prev.appendChild(tail);
|
||||
}
|
||||
return; // 새 카드 안 만듦
|
||||
} else if (ev.phase === 'report-start') {
|
||||
card.innerHTML = '<div class="cph-head">🧭 CEO 종합 보고서 작성 중…</div>';
|
||||
} else if (ev.phase === 'report-done') {
|
||||
|
||||
Reference in New Issue
Block a user