const vscode = acquireVsCodeApi(); const chat = document.getElementById('chat'); const input = document.getElementById('input'); // [State Persistence - Tier 0] 즉시 복원 (Instant Restore from WebView State) const previousState = vscode.getState(); if (previousState && previousState.history && previousState.history.length > 0) { console.log('[Astra] Restoring from Webview State...'); renderHistory(previousState.history); } function saveWebviewState(history) { const current = vscode.getState() || {}; vscode.setState({ ...current, history }); } function saveUiState() { const current = vscode.getState() || {}; vscode.setState({ ...current, secondBrainTraceEnabled, secondBrainTraceDebug }); } function renderHistory(history) { if (!history || history.length === 0) return; chat.innerHTML = ''; history.forEach(m => { if (!m) return; // Only skip truly internal system messages, keep assistant thoughts if (m.role === 'system' && m.internal) return; addMsg(m.content, m.role === 'assistant' ? 'assistant' : 'user', m.rationale); }); chat.scrollTop = chat.scrollHeight; } const sendBtn = document.getElementById('sendBtn'); const stopBtn = document.getElementById('stopBtn'); const cancelBtn = document.getElementById('cancelBtn'); const toastNotif = document.getElementById('toastNotif'); const thinkingBar = document.getElementById('thinkingBar'); const statusLabel = document.getElementById('statusLabel'); const stepper = document.getElementById('stepper'); // --- Draft State Management --- let isDraftActive = false; let _toastTimer = null; function showToast(msg, type = 'info') { toastNotif.textContent = msg; toastNotif.className = 'toast-notif toast-' + type + ' toast-visible'; if (_toastTimer) clearTimeout(_toastTimer); _toastTimer = setTimeout(() => { toastNotif.classList.remove('toast-visible'); }, 2500); } function setDraftActive(active) { isDraftActive = active; cancelBtn.style.display = active ? 'inline-flex' : 'none'; } // 생성 중/완료 시 Send ⇔ Stop 전환 function setGenerating(generating) { if (generating) { sendBtn.style.display = 'none'; stopBtn.style.display = 'inline-flex'; // 생성 중에는 Clear 버튼 숨김 cancelBtn.style.display = 'none'; } else { stopBtn.style.display = 'none'; sendBtn.style.display = 'inline-flex'; sendBtn.disabled = false; // Draft 상태에 따라 Clear 버튼 복원 if (isDraftActive) cancelBtn.style.display = 'inline-flex'; } } function clearDraft() { // Step 1: 상태 초기화 (Draft State Reset) setDraftActive(false); // Step 2: UI 반영 (Input + Attachments 초기화) input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments(); input.focus(); // Step 3: Toast 알림으로 즉각적 피드백 showToast('✕ 작성 내용이 초기화되었습니다.', 'warn'); Sound.warn(); } // --- Sound Manager --- const Sound = { ctx: null, init() { if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)(); }, play(freq, type, dur) { try { this.init(); const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, this.ctx.currentTime); gain.gain.setValueAtTime(0.05, this.ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(); osc.stop(this.ctx.currentTime + dur); } catch(e) {} }, success() { this.play(880, 'sine', 0.1); setTimeout(() => this.play(1109, 'sine', 0.15), 80); }, warn() { this.play(440, 'triangle', 0.3); } }; function setStep(stepId, state = 'active') { stepper.classList.add('active'); const step = document.getElementById('step-' + stepId); if (step) { if (state === 'active') { document.querySelectorAll('.step').forEach(s => s.classList.remove('active')); step.classList.add('active'); } else if (state === 'complete') { step.classList.remove('active'); step.classList.add('complete'); } } } function resetStepper() { stepper.classList.remove('active'); document.querySelectorAll('.step').forEach(s => { s.classList.remove('active'); s.classList.remove('complete'); }); } const modelSel = document.getElementById('modelSel'); const brainSel = document.getElementById('brainSel'); const historyOverlay = document.getElementById('historyOverlay'); const historyList = document.getElementById('historyList'); const statusDot = document.getElementById('statusDot'); const engineStatusText = document.getElementById('engineStatusText'); const attachBtn = document.getElementById('attachBtn'); const fileInput = document.getElementById('fileInput'); const attachPreview = document.getElementById('attachPreview'); const agentSel = document.getElementById('agentSel'); const designerSel = document.getElementById('designerSel'); const chronicleRecordSel = document.getElementById('chronicleRecordSel'); const editAgentBtn = document.getElementById('editAgentBtn'); const addAgentBtn = document.getElementById('addAgentBtn'); const deleteAgentBtn = document.getElementById('deleteAgentBtn'); const knowledgeScopeSel = document.getElementById('knowledgeScopeSel'); const editKnowledgeMapBtn = document.getElementById('editKnowledgeMapBtn'); const reloadKnowledgeMapBtn = document.getElementById('reloadKnowledgeMapBtn'); const addBrainBtn = document.getElementById('addBrainBtn'); const editBrainBtn = document.getElementById('editBrainBtn'); const deleteBrainBtn = document.getElementById('deleteBrainBtn'); const saveWikiRawBtn = document.getElementById('saveWikiRawBtn'); const agentConfigPanel = document.getElementById('agentConfigPanel'); const agentPrompt = document.getElementById('agentPrompt'); const negativePrompt = document.getElementById('negativePrompt'); const updateAgentBtn = document.getElementById('updateAgentBtn'); const agentMapOverlay = document.getElementById('agentMapOverlay'); const closeAgentMapBtn = document.getElementById('closeAgentMapBtn'); const cancelAgentMapBtn = document.getElementById('cancelAgentMapBtn'); const saveAgentMapBtn = document.getElementById('saveAgentMapBtn'); const editAgentMapJsonBtn = document.getElementById('editAgentMapJsonBtn'); const addKnowledgeFolderBtn = document.getElementById('addKnowledgeFolderBtn'); const addSkillFolderBtn = document.getElementById('addSkillFolderBtn'); const addSkillFileBtn = document.getElementById('addSkillFileBtn'); const knowledgeFolderList = document.getElementById('knowledgeFolderList'); const skillFolderList = document.getElementById('skillFolderList'); const agentMapAgentName = document.getElementById('agentMapAgentName'); const agentMapStatus = document.getElementById('agentMapStatus'); const readyBar = document.getElementById('readyBar'); const rbDot = document.getElementById('rbDot'); const rbContent = document.getElementById('rbContent'); const ctxBadge = document.getElementById('ctxBadge'); const ctxBrainName = document.getElementById('ctxBrainName'); const ctxAgentName = document.getElementById('ctxAgentName'); const ctxProjectName = document.getElementById('ctxProjectName'); const recordsLatest = document.getElementById('recordsLatest'); function escAttr(t) { return String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } function fmtMixHint(w) { return `모델 ${100 - w}% · 두뇌 ${w}%`; } /** Compact relative-time formatter for chips (e.g. "2m ago", "just now"). */ function formatRelativeTime(iso) { try { const then = new Date(iso).getTime(); if (!Number.isFinite(then)) return iso; const diffSec = Math.max(0, Math.floor((Date.now() - then) / 1000)); if (diffSec < 45) return 'just now'; if (diffSec < 90) return '1m ago'; const m = Math.floor(diffSec / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); return `${d}d ago`; } catch { return iso; } } function fmtK(n) { if (typeof n !== 'number' || !isFinite(n)) return '?'; if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k'; return String(n); } function shortModel(m) { m = String(m || ''); const i = m.lastIndexOf('/'); return i >= 0 ? m.slice(i + 1) : m; } function selText(sel) { try { return sel && sel.selectedIndex >= 0 ? (sel.options[sel.selectedIndex].text || '').trim() : ''; } catch { return ''; } } function truncMid(s, n) { s = String(s || ''); if (s.length <= n) return s; const h = Math.max(4, Math.floor((n - 1) / 2)); return s.slice(0, h) + '…' + s.slice(-h); } // ── Context Bar (Brain / Agent / Project summary) + Records line ────── function syncContextBar() { if (ctxBrainName) ctxBrainName.textContent = selText(brainSel) || '—'; if (ctxAgentName) { const t = selText(agentSel); ctxAgentName.textContent = (!t || /no agent/i.test(t)) ? '기본' : t; } if (ctxProjectName) ctxProjectName.textContent = selText(designerSel) || '—'; // welcome 패널이 떠 있으면 두뇌/프로젝트 변경이 즉시 반영되도록 같이 호출. try { _renderWelcome(); } catch { /* 초기화 전 호출은 무시 */ } } // ────────────────────────────────────────────────────────────────────── // Welcome panel — 빈 채팅 상태에서 보이는 동적 시작 가이드. // // 첫 사용자(두뇌 미설정·모델 미선택)에게는 "시작하기 3단계" 체크리스트를, // 이미 준비가 끝난 사용자에게는 예시 질문 chip을 보여준다. 같은 슬롯에서 // 상태에 따라 내용만 바뀌므로 노이즈가 안 쌓인다. 첫 메시지가 가면 // sender(`if (document.querySelector('.welcome')) ... .remove()`)가 // 패널 자체를 제거하므로 dismiss 로직은 별도로 둘 필요 없음. // ────────────────────────────────────────────────────────────────────── const _SAMPLE_PROMPTS = [ { emoji: '📋', text: '지금 열린 프로젝트의 구조를 분석하고 핵심 모듈을 알려줘' }, { emoji: '🐞', text: '이 코드에서 잠재적인 버그·엣지 케이스가 있는지 검토해줘' }, { emoji: '✍️', text: '이 함수에 사용자 입장에서 이해하기 쉬운 주석을 달아줘' }, { emoji: '🧭', text: '오늘 무엇부터 하면 좋을지 우선순위를 짜줘 (1인 기업 모드 추천)' }, ]; function _renderWelcome() { const panel = document.getElementById('welcomePanel'); if (!panel) return; // 패널이 한 번 제거된 상태(첫 메시지 이후)면 다시 만들지 않는다. if (!panel.isConnected) return; const brainName = (ctxBrainName && ctxBrainName.textContent || '').trim(); const hasBrain = brainName && brainName !== '—'; const modelVal = (modelSel && modelSel.value || '').trim() || (document.getElementById('modelInlineSel') && document.getElementById('modelInlineSel').value || '').trim(); const hasModel = !!modelVal; const ready = hasBrain && hasModel; panel.innerHTML = ''; const logo = document.createElement('div'); logo.className = 'welcome-logo'; logo.textContent = '✦'; const title = document.createElement('div'); title.className = 'welcome-title'; title.textContent = ready ? '준비 완료. 무엇을 도와드릴까요?' : 'Astra에 오신 것을 환영합니다'; const lead = document.createElement('p'); lead.className = 'welcome-lead'; lead.textContent = ready ? '아래 예시 중 하나를 눌러 시작하거나, 입력창에 직접 적어보세요.' : '로컬에서 동작하는 개인 AI 비서입니다. 시작하기 전에 두 가지만 확인해주세요.'; panel.appendChild(logo); panel.appendChild(title); panel.appendChild(lead); if (!ready) { // ── 시작 체크리스트 (3단계) ── const steps = document.createElement('div'); steps.className = 'welcome-checklist'; const mkStep = (n, done, label, hint, actionLabel, onAction) => { const row = document.createElement('div'); row.className = 'wc-step' + (done ? ' done' : ''); const bullet = document.createElement('div'); bullet.className = 'wc-bullet'; bullet.textContent = done ? '✓' : String(n); const txt = document.createElement('div'); txt.className = 'wc-text'; const t1 = document.createElement('div'); t1.className = 'wc-label'; t1.textContent = label; const t2 = document.createElement('div'); t2.className = 'wc-hint'; t2.textContent = hint; txt.appendChild(t1); txt.appendChild(t2); row.appendChild(bullet); row.appendChild(txt); if (!done && actionLabel && onAction) { const btn = document.createElement('button'); btn.className = 'wc-action'; btn.textContent = actionLabel; btn.onclick = onAction; row.appendChild(btn); } return row; }; steps.appendChild(mkStep( 1, hasBrain, '두뇌(지식 폴더) 연결', '자주 쓰는 노트·문서를 모아둔 로컬 폴더입니다. Astra가 답변할 때 이 폴더의 내용을 참고합니다.', '두뇌 추가', () => { const editBtn = document.getElementById('contextEditBtn'); if (editBtn) editBtn.click(); const addBrainBtn = document.getElementById('addBrainBtn'); if (addBrainBtn) setTimeout(() => addBrainBtn.click(), 120); }, )); steps.appendChild(mkStep( 2, hasModel, '사용할 모델 선택', 'LM Studio 또는 Ollama에 로드되어 있는 로컬 모델을 고릅니다.', '모델 열기', () => { const editBtn = document.getElementById('contextEditBtn'); if (editBtn) editBtn.click(); setTimeout(() => { if (modelSel) modelSel.focus(); }, 120); }, )); steps.appendChild(mkStep( 3, false, '첫 질문 적어보기', '아래 입력창에 자연어로 무엇이든 적어보세요. 코드·문서·아이디어 모두 가능합니다.', '입력창으로', () => { try { input && input.focus(); } catch {} }, )); panel.appendChild(steps); return; } // ── 준비 완료 상태: 예시 질문 chip ── const chips = document.createElement('div'); chips.className = 'welcome-chips'; for (const p of _SAMPLE_PROMPTS) { const chip = document.createElement('button'); chip.className = 'welcome-chip'; chip.innerHTML = `${p.emoji}`; chip.querySelector('.welcome-chip-text').textContent = p.text; chip.title = '클릭하면 입력창에 채워집니다 (자동 전송 안 함).'; chip.onclick = () => { if (!input) return; input.value = p.text; input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; input.focus(); }; chips.appendChild(chip); } panel.appendChild(chips); // ── 하단 부드러운 안내 (1인 기업 모드, 단축키) ── const tips = document.createElement('div'); tips.className = 'welcome-tips'; tips.innerHTML = '복잡한 작업은 헤더의 [기업 모드]로 여러 전문 에이전트에게 분배할 수 있습니다. · 입력 후 Cmd/Ctrl + Enter로 전송.'; panel.appendChild(tips); } // 모델 selector가 바뀌면 welcome도 즉시 갱신. document.addEventListener('change', (e) => { if (e.target && (e.target.id === 'modelSel' || e.target.id === 'modelInlineSel')) { try { _renderWelcome(); } catch {} } }); function syncRecordsLine() { if (!recordsLatest) return; const opt = chronicleRecordSel && chronicleRecordSel.value ? selText(chronicleRecordSel) : ''; recordsLatest.textContent = opt ? '· ' + truncMid(opt, 38) : ''; } // ── Ready-status bar (Engine / Model / Brain count / Context / Memory) ── let readyState = {}; function renderReadyBar() { if (!readyBar || !rbContent) return; const s = readyState; const segs = []; if (s.engine) { const on = s.engine.online; const tag = on === true ? 'Online' : on === false ? 'Offline' : '확인 중'; segs.push(`${escAttr(tag)}`); } if (s.model && s.model.name) { const loaded = s.model.loaded; segs.push(`${escAttr(shortModel(s.model.name))}`); } if (s.brain && typeof s.brain.files === 'number') { segs.push(`Brain ${s.brain.files}`); } if (typeof s.contextLength === 'number') { if (s.cappedForSmallModel) { segs.push(`ctx ${fmtK(s.contextLength)} · 소형모델 제한`); } else { segs.push(`ctx ${fmtK(s.contextLength)}`); } } segs.push(`메모리 ${s.memory ? 'On' : 'Off'}`); if (s.multiAgent) segs.push(`멀티에이전트`); rbContent.innerHTML = segs.join('·'); if (rbDot) { const on = s.engine && s.engine.online; rbDot.className = 'rb-dot ' + (on === true ? 'ok' : on === false ? 'bad' : 'warn'); } } // ── Context-budget badge (직전 요청 기준) ──────────────────────────── function renderCtxBadge(b) { if (!ctxBadge) return; if (!b || typeof b.inputTokens !== 'number') { ctxBadge.textContent = ''; ctxBadge.className = 'ctx-badge'; ctxBadge.title = ''; return; } const parts = [`≈${fmtK(b.inputTokens)} in / ${fmtK(b.maxOutputTokens)} out`]; if (typeof b.contextLength === 'number') { parts.push(b.cappedForSmallModel ? `ctx ${fmtK(b.contextLength)}↓` : `ctx ${fmtK(b.contextLength)}`); } if (typeof b.brainFiles === 'number' && b.brainFiles > 0) parts.push(`Brain ${b.brainFiles}`); if (b.includesOpenFile) parts.push('📄 열린 파일'); if (b.imageCount > 0) parts.push(`🖼 ${b.imageCount}`); if (b.droppedHistory > 0) parts.push(`기록 −${b.droppedHistory}`); if (b.systemTruncated) parts.push('컨텍스트 일부 생략'); if (b.cappedForSmallModel) parts.push('🔻 작은 모델 모드'); if (b.tight) parts.push('⚠ 컨텍스트 거의 가득'); const warn = b.tight || b.systemTruncated; ctxBadge.textContent = parts.join(' · '); ctxBadge.className = 'ctx-badge' + (warn ? ' warn' : ' ok'); ctxBadge.title = `model: ${b.model || ''}${b.paramB != null ? ' (~' + b.paramB + 'B)' : ''}\n입력 ≈ ${b.inputTokens} tokens (시스템 ${b.systemTokens}, 기록 ${b.historyKept}개)\n출력 상한 ${b.maxOutputTokens} tokens / 유효 context window ${b.contextLength} tokens${b.cappedForSmallModel ? ' (작은 모델용 축소; 설정값 ' + b.nominalContextLength + ')' : ''}`; } if (readyBar) { readyBar.addEventListener('click', e => { const t = e.target; if (t && t.dataset && t.dataset.act === 'map') vscode.postMessage({ type: 'editKnowledgeMap' }); }); } // ── "Record a lesson?" prompt (after a rollback / rejected change / repeated complaint) ── function renderLessonCandidate(v) { const t = v && v.trigger; const titleText = t === 'rejected' ? '⚠ 방금 변경을 거부하셨네요 — 같은 실수를 반복하지 않게 교훈으로 기록할까요?' : t === 'qa-feedback' ? '⚠ 같은 문제가 반복되는 것 같습니다 — 교훈으로 기록해두면 다음 작업 전에 자동으로 체크합니다.' : '⚠ 방금 작업이 롤백됐습니다 — 같은 실수를 반복하지 않게 교훈으로 기록할까요?'; const reasonLine = v && v.reason ? `
사유: ${escAttr(String(v.reason))}
` : ''; // Reuse the active (or last) assistant bubble as the anchor; fall back to appending to the chat. let anchor = streamBody && streamBody._parent; if (!anchor) { const all = chat.querySelectorAll('.msg.msg-ai'); anchor = all[all.length - 1]; } const old = (anchor || chat).querySelector('.lesson-candidate-box'); if (old) old.remove(); const box = document.createElement('div'); box.className = 'lesson-candidate-box'; box.innerHTML = `
${escAttr(titleText)}
` + reasonLine + `
`; box.querySelector('.lc-rec').onclick = () => { vscode.postMessage({ type: 'createLessonFromConversation' }); box.remove(); }; box.querySelector('.lc-skip').onclick = () => box.remove(); if (anchor) { const actions = anchor.querySelector('.msg-actions'); if (actions) anchor.insertBefore(box, actions); else anchor.appendChild(box); } else { chat.appendChild(box); } chat.scrollTop = chat.scrollHeight; } // ── Per-answer "scope used" footer ────────────────────────────────── const MEMORY_LAYER_LABELS = { 'long-term-memory': '장기기억', 'project-memory': '프로젝트기억', 'procedural-memory': '절차기억', 'episodic-memory': '에피소드기억', 'project-scan': '프로젝트스캔', 'recent-knowledge': '최근지식', }; function dirOf(rel) { const i = Math.max(rel.lastIndexOf('/'), rel.lastIndexOf('\\')); return i > 0 ? rel.slice(0, i) : '(루트)'; } function renderScopeFooter(target, v) { if (!target) return; const old = target.querySelector('.msg-scope-footer'); if (old) old.remove(); const footer = document.createElement('div'); footer.className = 'msg-scope-footer'; const files = Array.isArray(v.usedBrainFiles) ? v.usedBrainFiles : []; const layers = (Array.isArray(v.usedMemoryLayers) ? v.usedMemoryLayers : []).map(s => MEMORY_LAYER_LABELS[s] || s); const lessons = Array.isArray(v.lessonFiles) ? v.lessonFiles : []; if (files.length === 0 && layers.length === 0 && lessons.length === 0) { footer.innerHTML = `🔎 참조 지식 없음 — 모델 자체 지식으로 답변`; } else { const dirs = Array.from(new Set(files.map(dirOf))); let scopeLabel; if (v.scoped && Array.isArray(v.configuredFolders) && v.configuredFolders.length) { scopeLabel = v.configuredFolders.join(', '); } else if (dirs.length) { scopeLabel = dirs.slice(0, 4).join(', ') + (dirs.length > 4 ? ` 외 ${dirs.length - 4}` : ''); } else if (files.length === 0) { scopeLabel = '브레인 파일 없음'; } else { scopeLabel = '전체 브레인'; } const agentTag = v.agentName ? `[${escAttr(v.agentName)}] ` : ''; const fileTag = files.length ? ` · 파일 ${files.length}` : ''; const layerTag = layers.length ? ` · 메모리 ${escAttr(layers.join('·'))}` : ''; const lessonTag = lessons.length ? ` · ⚠ 교훈 ${lessons.length}` : ''; const titleAttr = files.length ? `사용된 브레인 파일:\n${files.join('\n')}` : '에이전트↔지식 매핑 편집'; footer.innerHTML = `🔎 참조: ${agentTag}${escAttr(scopeLabel)}${fileTag}${lessonTag}${layerTag}`; } // Knowledge Mix indicator — shows the policy that actually drove this turn so the // user can see *why* the answer leaned the way it did. if (v.knowledgeMix && typeof v.knowledgeMix.weight === 'number') { const w = Math.max(0, Math.min(100, v.knowledgeMix.weight)); const src = v.knowledgeMix.source; const srcLabel = src === 'agent' ? `agent: ${v.knowledgeMix.agent || v.agentName || ''}` : src === 'global' ? 'global' : 'default'; const mix = document.createElement('div'); mix.className = 'scope-mix'; mix.innerHTML = `🎚 Knowledge Mix · Model ${100 - w}% / Brain ${w}% (${escAttr(srcLabel)})`; footer.appendChild(mix); } // Non-blocking flag: lesson Prevention-Checklist items the answer doesn't visibly address. const unaddressed = Array.isArray(v.unaddressedChecklist) ? v.unaddressedChecklist : []; if (unaddressed.length) { const list = unaddressed.map(it => `· ${escAttr(it)}`).join('
'); const w = document.createElement('div'); w.className = 'scope-unaddressed'; w.innerHTML = `⚠ 답변에서 안 보이는 교훈 체크리스트 항목:
${list}`; footer.appendChild(w); } footer.addEventListener('click', e => { const act = e.target && e.target.dataset && e.target.dataset.act; if (act === 'map') vscode.postMessage({ type: 'editKnowledgeMap' }); else if (act === 'lessons') vscode.postMessage({ type: 'manageLessons' }); }); const actions = target.querySelector('.msg-actions'); if (actions) target.insertBefore(footer, actions); else target.appendChild(footer); } // `model: ''` means "Use current model" (i.e. no per-agent override). // `secondBrainWeight: null` means "Use global setting"; a number 0–100 overrides it. let agentMapDraft = { agentPath: '', name: '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null }; /** * Sync the per-agent Knowledge Mix UI (checkbox + slider + hint) to whatever * is currently in `agentMapDraft.secondBrainWeight`. Called whenever the * modal opens or the backend ships fresh data. */ function syncAgentMapMixUi() { const cb = document.getElementById('agentMapMixUseGlobal'); const slider = document.getElementById('agentMapMixSlider'); const hint = document.getElementById('agentMapMixHint'); if (!cb || !slider || !hint) return; const useGlobal = agentMapDraft.secondBrainWeight === null || agentMapDraft.secondBrainWeight === undefined; cb.checked = useGlobal; slider.disabled = useGlobal; const value = useGlobal ? (parseInt((document.getElementById('knowledgeMixSlider') || {}).value, 10) || 50) : Math.max(0, Math.min(100, agentMapDraft.secondBrainWeight | 0)); slider.value = String(value); hint.textContent = useGlobal ? `전역 설정 사용 · ${fmtMixHint(value)}` : fmtMixHint(value); } /** * Rebuild the per-agent model dropdown using whatever model list the top-bar * #modelSel currently has. Called whenever the modal opens OR the model list * is refreshed by the extension host. Preserves the current draft selection. */ function refreshAgentMapModelOptions() { const sel = document.getElementById('agentMapModelSel'); if (!sel) return; const desired = agentMapDraft.model || ''; sel.innerHTML = ''; const useDefault = document.createElement('option'); useDefault.value = ''; useDefault.innerText = 'Use current model'; sel.appendChild(useDefault); const seen = new Set(); // Source the available models from the populated top-bar dropdown so we don't // need an additional round-trip; if a model is selected for this agent but // is no longer in the list, we still surface it so the user sees the value. for (const opt of modelSel.options) { if (!opt.value || seen.has(opt.value)) continue; seen.add(opt.value); const o = document.createElement('option'); o.value = opt.value; o.innerText = opt.innerText; sel.appendChild(o); } if (desired && !seen.has(desired)) { const o = document.createElement('option'); o.value = desired; o.innerText = `${desired} (saved)`; sel.appendChild(o); } sel.value = desired; } function renderAgentMapLists() { const renderList = (listEl, items, kind) => { listEl.innerHTML = ''; items.forEach((p, idx) => { const li = document.createElement('li'); li.className = 'map-item'; const icon = document.createElement('span'); icon.className = 'map-item-icon'; icon.textContent = kind === 'knowledge' ? '📁' : (p.endsWith('.md') || p.endsWith('.markdown') ? '📄' : '📁'); const pathEl = document.createElement('span'); pathEl.className = 'map-item-path'; pathEl.textContent = p; pathEl.title = p; const removeBtn = document.createElement('button'); removeBtn.className = 'map-item-remove'; removeBtn.textContent = '✕'; removeBtn.title = '연결 해제'; removeBtn.onclick = () => { items.splice(idx, 1); renderAgentMapLists(); }; li.appendChild(icon); li.appendChild(pathEl); li.appendChild(removeBtn); listEl.appendChild(li); }); }; renderList(knowledgeFolderList, agentMapDraft.knowledgeFolders, 'knowledge'); renderList(skillFolderList, agentMapDraft.skillFolders, 'skill'); } function openAgentMapModal() { if (!agentSel || agentSel.value === 'none' || !agentSel.value) { showToast('에이전트를 먼저 선택하세요.'); return; } agentMapStatus.className = 'map-status'; agentMapStatus.textContent = '불러오는 중...'; agentMapDraft = { agentPath: agentSel.value, name: agentSel.options[agentSel.selectedIndex]?.text || '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null }; agentMapAgentName.textContent = agentMapDraft.name; knowledgeFolderList.innerHTML = ''; skillFolderList.innerHTML = ''; refreshAgentMapModelOptions(); syncAgentMapMixUi(); agentMapOverlay.classList.add('visible'); vscode.postMessage({ type: 'getAgentMap', agentPath: agentSel.value }); } function closeAgentMapModal() { agentMapOverlay.classList.remove('visible'); agentMapStatus.textContent = ''; agentMapStatus.className = 'map-status'; } let streamBody = null; let internetEnabled = false; let secondBrainTraceEnabled = true; let secondBrainTraceDebug = false; let pendingFiles = []; let editMode = false; if (previousState && typeof previousState.secondBrainTraceEnabled === 'boolean') { secondBrainTraceEnabled = previousState.secondBrainTraceEnabled; } if (previousState && typeof previousState.secondBrainTraceDebug === 'boolean') { secondBrainTraceDebug = previousState.secondBrainTraceDebug; } const initialTraceBtn = document.getElementById('brainTraceBtn'); initialTraceBtn.classList.toggle('active', secondBrainTraceEnabled); initialTraceBtn.setAttribute('data-tooltip', secondBrainTraceEnabled ? 'Second Brain Trace Mode: On' : 'Second Brain Trace Mode: Off'); const initialTraceDebugBtn = document.getElementById('brainTraceDebugBtn'); initialTraceDebugBtn.classList.toggle('active', secondBrainTraceDebug); initialTraceDebugBtn.setAttribute('data-tooltip', secondBrainTraceDebug ? 'Second Brain Debug JSON: On' : 'Second Brain Debug JSON: Off'); function fmt(text) { return marked.parse(text || ''); } function copyToClipboard(text, btn) { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); try { if (document.execCommand('copy')) { btn.innerText = '✅ Copied!'; setTimeout(() => { btn.innerText = '📋 Copy'; }, 2000); } } catch (err) { console.error('Copy failed', err); } document.body.removeChild(textarea); } window.approve = () => { const box = document.querySelector('.approval-box'); if (box) box.remove(); vscode.postMessage({ type: 'approveAction' }); }; window.reject = () => { const box = document.querySelector('.approval-box'); if (box) box.remove(); vscode.postMessage({ type: 'rejectAction' }); }; function exportToMD(text) { vscode.postMessage({ type: 'exportResponse', text: text }); } function addMsg(text, role, rationale) { const isUser = role === 'user'; const msgEl = document.createElement('div'); msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai'); msgEl._raw = text; const head = document.createElement('div'); head.className = 'msg-head'; head.innerHTML = isUser ? '
U
You' : '
Astra'; const body = document.createElement('div'); body.className = 'msg-body markdown-body'; if (isUser) { body.innerText = text; } else { body.innerHTML = fmt(text); } const actions = document.createElement('div'); actions.className = 'msg-actions'; const copyBtn = document.createElement('button'); copyBtn.className = 'action-btn'; copyBtn.innerText = '📋 Copy'; copyBtn.onclick = (e) => { e.stopPropagation(); copyToClipboard(msgEl._raw, copyBtn); }; const exportBtn = document.createElement('button'); exportBtn.className = 'action-btn'; exportBtn.innerText = '💾 Export'; exportBtn.onclick = (e) => { e.stopPropagation(); exportToMD(msgEl._raw); }; actions.appendChild(copyBtn); actions.appendChild(exportBtn); msgEl.appendChild(head); msgEl.appendChild(body); msgEl.appendChild(actions); chat.appendChild(msgEl); chat.scrollTop = chat.scrollHeight; return { body, msgEl }; } window.addEventListener('message', e => { const msg = e.data; switch(msg.type) { case 'addMessage': addMsg(msg.value, msg.role, msg.rationale); // Update state for non-streamed messages const s = vscode.getState() || { history: [] }; s.history.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.value, rationale: msg.rationale }); saveWebviewState(s.history); break; case 'streamStart': thinkingBar.classList.remove('active'); if (document.querySelector('.welcome')) document.querySelector('.welcome').remove(); const res = addMsg('', 'assistant'); streamBody = res.body; streamBody._parent = res.msgEl; streamBody._parent._raw = ''; streamBody.classList.add('stream-active'); break; case 'streamChunk': if (streamBody) { streamBody._parent._raw += msg.value; streamBody.innerHTML = fmt(streamBody._parent._raw); chat.scrollTop = chat.scrollHeight; } break; case 'streamReplace': // Progressive answering: the backend streamed raw tokens // live (including hidden reasoning, pre-sanitize text); // once everything is finalized it sends the cleaned full // text via streamReplace so the bubble ends up correct // regardless of what slipped through during streaming. if (streamBody) { streamBody._parent._raw = String(msg.value ?? ''); streamBody.innerHTML = fmt(streamBody._parent._raw); chat.scrollTop = chat.scrollHeight; } break; case 'streamEnd': if (streamBody) { streamBody.classList.remove('stream-active'); // Update state after stream finishes const state = vscode.getState() || { history: [] }; state.history.push({ role: 'assistant', content: streamBody._parent._raw }); saveWebviewState(state.history); } streamBody = null; // 생성 완료 시 Stop 버튼 숨기고 Send 복구 setGenerating(false); resetStepper(); // 1인 기업 모드는 streamStart를 거치지 않아 thinkingBar가 // 그대로 남으므로 streamEnd에서 명시적으로 끄는 게 안전. thinkingBar.classList.remove('active'); Sound.success(); vscode.postMessage({ type: 'getReadyStatus' }); break; case 'restoreHistory': case 'sessionLoaded': const historyPayload = msg.type === 'sessionLoaded' ? msg.value : msg.value; const history = Array.isArray(historyPayload) ? historyPayload : (Array.isArray(historyPayload?.history) ? historyPayload.history : []); if (history && history.length > 0) { renderHistory(history); saveWebviewState(history); } if (historyPayload?.negativePrompt !== undefined) { negativePrompt.value = historyPayload.negativePrompt; } historyOverlay.classList.remove('visible'); break; case 'clearChat': // welcomePanel을 빈 div로 다시 만들고 _renderWelcome으로 상태에 맞는 // 내용을 채워 넣는다 — 신규 사용자에게는 체크리스트, 준비된 사용자에게는 // 예시 질문 chip이 나옴. chat.innerHTML = '
'; try { _renderWelcome(); } catch {} break; case 'focusInput': input.focus(); break; case 'modelsList': { modelSel.innerHTML = ''; const inlineModelSel = document.getElementById('modelInlineSel'); if (inlineModelSel) inlineModelSel.innerHTML = ''; // [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도 const _savedModel = localStorage.getItem('g1nation_last_model'); // 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정 // LocalStorage에 저장된 값이 현재 목록에 있으면 그것을 우선 사용 (Tier 2 우선) const _preferredModel = (_savedModel && msg.value.models.includes(_savedModel)) ? _savedModel : msg.value.selected; const _loadedSet = new Set(Array.isArray(msg.value.loadedModels) ? msg.value.loadedModels : []); const _models = Array.isArray(msg.value.models) ? msg.value.models.slice() : []; // Fallback: server returned nothing but we still know the configured model. if (_models.length === 0 && _preferredModel) _models.push(_preferredModel); _models.forEach(m => { const label = _loadedSet.has(m) ? `● ${m}` : m; const o1 = document.createElement('option'); o1.value = m; o1.innerText = label; if (m === _preferredModel) o1.selected = true; modelSel.appendChild(o1); if (inlineModelSel) { const o2 = document.createElement('option'); o2.value = m; o2.innerText = label; if (m === _preferredModel) o2.selected = true; inlineModelSel.appendChild(o2); } }); // LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화 if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) { vscode.postMessage({ type: 'model', value: _savedModel }); } // The model name is now visible inside the footer pill itself, // so statusLabel is reserved for actual status (autoContinue // progress, etc.). Keep it empty in steady state. statusLabel.innerText = ''; // Refresh per-agent model dropdown options (if currently visible) so it stays in sync. if (typeof refreshAgentMapModelOptions === 'function') refreshAgentMapModelOptions(); // If the company manage overlay is open with cached agent data, // re-render its cards so each per-agent model // (or label/spacer) keeps the menu open so the user can change several things. menu.addEventListener('click', e => { e.stopPropagation(); if (e.target && e.target.closest && e.target.closest('button')) { setTimeout(() => menu.classList.remove('open'), 0); } }); }); document.addEventListener('click', () => closeAllDropdowns()); // Keep the Context Bar / Records line in sync with the (now-collapsed) selectors. [brainSel, agentSel, designerSel].forEach(s => s && s.addEventListener('change', syncContextBar)); if (chronicleRecordSel) chronicleRecordSel.addEventListener('change', syncRecordsLine); syncContextBar(); syncRecordsLine(); vscode.postMessage({ type: 'getModels' }); vscode.postMessage({ type: 'getAgents' }); vscode.postMessage({ type: 'getChronicleProjects' }); vscode.postMessage({ type: 'getChronicleRecords' }); vscode.postMessage({ type: 'getKnowledgeMix' }); vscode.postMessage({ type: 'getArchitectureStatus' }); vscode.postMessage({ type: 'getCompanyStatus' }); vscode.postMessage({ type: 'ready' }); // ── Project Architecture chip buttons ───────────────────────────────── const _archOpenBtn = document.getElementById('archOpenBtn'); const _archRefreshBtn = document.getElementById('archRefreshBtn'); const _archDetachBtn = document.getElementById('archDetachBtn'); if (_archOpenBtn) _archOpenBtn.onclick = () => vscode.postMessage({ type: 'openArchitectureDoc' }); if (_archRefreshBtn) _archRefreshBtn.onclick = () => vscode.postMessage({ type: 'refreshArchitecture' }); if (_archDetachBtn) _archDetachBtn.onclick = () => vscode.postMessage({ type: 'detachArchitecture' }); // [Attach] is visible only in the inactive chip state; clicking it // re-enables architecture mode for the current workspace's project. const _archAttachBtn = document.getElementById('archAttachBtn'); if (_archAttachBtn) _archAttachBtn.onclick = () => vscode.postMessage({ type: 'attachArchitecture' }); // ── 1인 기업 (Company) Mode chip + manage overlay ───────────────────── // The chip itself toggles enabled/disabled. The ▾ button opens the // manage overlay where the user picks active agents + per-agent // model overrides. State round-trips through `companyStatus` / // `companyAgents` messages so the webview and extension stay in sync. const _companyChip = document.getElementById('companyChip'); const _companyManageBtn = document.getElementById('companyManageBtn'); const _companyOverlay = document.getElementById('companyOverlay'); const _closeCompanyBtns = [ document.getElementById('closeCompanyOverlayBtn'), document.getElementById('closeCompanyOverlayBtn2'), ].filter(Boolean); const _companyNameInput = document.getElementById('companyNameInput'); const _saveCompanyNameBtn = document.getElementById('saveCompanyNameBtn'); const _companyAgentList = document.getElementById('companyAgentList'); const _companyStatusEl = document.getElementById('companyStatus'); /** * Chip lives in the main header toolbar now, so it uses the same * `icon-btn.active` styling as `brainTraceBtn` / `internetBtn`. * Detail (company name + agent count) goes in the tooltip — the * label stays a constant "Corp" so the toolbar tone-and-manner * isn't broken by a wildly varying-width chip. */ const renderCompanyChip = (active, summary) => { if (!_companyChip) return; _companyChip.classList.toggle('active', !!active); _companyChip.setAttribute( 'data-tooltip', active ? `1인 기업 ON · ${summary || ''}`.trim() : '1인 기업 모드 OFF — 클릭해서 켜기', ); // 스코프 프리셋 segmented control 도 기업 모드 ON 일 때만 노출. const scopeSeg = document.getElementById('companyScopeSeg'); if (scopeSeg) scopeSeg.hidden = !active; }; // 활성 pipeline 의 id 가 어느 SCOPE 프리셋의 suggestedPipelineId 와 매칭되는지로 active 표시. // companyStatus 메시지가 activePipelineId 를 보낼 때마다 호출. const SCOPE_PRESET_TO_PIPELINE_ID = { 'plan-only': 'plan-only', 'dev-only': 'dev-only', 'full-product-dev': 'product-dev', }; const renderScopeSeg = (activePipelineId) => { const scopeSeg = document.getElementById('companyScopeSeg'); if (!scopeSeg) return; for (const btn of scopeSeg.querySelectorAll('.scope-seg-btn')) { const tplId = btn.getAttribute('data-scope'); const expected = SCOPE_PRESET_TO_PIPELINE_ID[tplId]; btn.classList.toggle('active', !!activePipelineId && activePipelineId === expected); } }; // Wire up clicks once. const _scopeSeg = document.getElementById('companyScopeSeg'); if (_scopeSeg && !_scopeSeg.dataset.wired) { _scopeSeg.dataset.wired = '1'; _scopeSeg.addEventListener('click', (e) => { const btn = e.target && e.target.closest && e.target.closest('.scope-seg-btn'); if (!btn) return; const tplId = btn.getAttribute('data-scope'); if (!tplId) return; // Optimistic visual flip — backend ack 가 companyStatus 갱신으로 결과 확정. for (const b of _scopeSeg.querySelectorAll('.scope-seg-btn')) { b.classList.toggle('active', b === btn); } vscode.postMessage({ type: 'setCompanyScopePreset', templateId: tplId }); }); } if (_companyChip) { _companyChip.onclick = () => { const isActive = _companyChip.classList.contains('active'); // Optimistic flip — backend echoes the canonical state back. renderCompanyChip(!isActive, ''); vscode.postMessage({ type: 'setCompanyEnabled', value: !isActive }); }; } if (_companyManageBtn) { _companyManageBtn.onclick = () => { if (!_companyOverlay) return; _companyOverlay.classList.add('visible'); _companyStatusEl.textContent = '불러오는 중...'; vscode.postMessage({ type: 'getCompanyAgents' }); vscode.postMessage({ type: 'getCompanyPipelines' }); vscode.postMessage({ type: 'getCompanyResumable' }); }; } for (const btn of _closeCompanyBtns) { btn.onclick = () => _companyOverlay?.classList.remove('visible'); } if (_saveCompanyNameBtn && _companyNameInput) { _saveCompanyNameBtn.onclick = () => { vscode.postMessage({ type: 'setCompanyName', value: _companyNameInput.value }); }; } // ── Add-agent form: toggle, clear, submit ── const _addCompanyAgentBtn = document.getElementById('addCompanyAgentBtn'); const _addAgentForm = document.getElementById('addCompanyAgentForm'); const _cancelAddAgentBtn = document.getElementById('cancelAddAgentBtn'); const _saveAddAgentBtn = document.getElementById('saveAddAgentBtn'); const _addAgentError = document.getElementById('addAgentError'); const _addAgentFields = () => ({ id: document.getElementById('newAgentId'), name: document.getElementById('newAgentName'), role: document.getElementById('newAgentRole'), emoji: document.getElementById('newAgentEmoji'), color: document.getElementById('newAgentColor'), roleCategory: document.getElementById('newAgentRoleCategory'), tagline: document.getElementById('newAgentTagline'), specialty: document.getElementById('newAgentSpecialty'), persona: document.getElementById('newAgentPersona'), }); const _populateAddAgentRoleSelect = () => { const sel = document.getElementById('newAgentRoleCategory'); if (!sel) return; sel.innerHTML = ''; for (const cat of _roleCategoryOrder) { if (cat === 'ceo') continue; // ceo는 빌트인 전용 const opt = document.createElement('option'); opt.value = cat; opt.textContent = _roleCategoryLabels[cat] || cat; sel.appendChild(opt); } sel.value = 'support'; // 기본은 가장 무해한 직군 }; const _clearAddAgentForm = () => { const f = _addAgentFields(); for (const k of Object.keys(f)) { if (!f[k]) continue; if (k === 'roleCategory') continue; // select는 별도 populate f[k].value = ''; } if (_addAgentError) _addAgentError.textContent = ''; _populateAddAgentRoleSelect(); }; if (_addCompanyAgentBtn && _addAgentForm) { _addCompanyAgentBtn.onclick = () => { const open = _addAgentForm.getAttribute('data-open') === 'true'; _addAgentForm.setAttribute('data-open', open ? 'false' : 'true'); if (!open) { _clearAddAgentForm(); _addAgentFields().id?.focus(); } }; } if (_cancelAddAgentBtn && _addAgentForm) { _cancelAddAgentBtn.onclick = () => { _addAgentForm.setAttribute('data-open', 'false'); _clearAddAgentForm(); }; } if (_saveAddAgentBtn) { _saveAddAgentBtn.onclick = () => { const f = _addAgentFields(); const def = { id: (f.id?.value || '').trim().toLowerCase(), name: (f.name?.value || '').trim(), role: (f.role?.value || '').trim(), emoji: (f.emoji?.value || '').trim(), color: (f.color?.value || '').trim(), roleCategory: (f.roleCategory?.value || 'support'), tagline: (f.tagline?.value || '').trim(), specialty: (f.specialty?.value || '').trim(), persona: (f.persona?.value || '').trim(), }; if (!def.id || !def.name || !def.role) { if (_addAgentError) _addAgentError.textContent = 'id · 이름 · 역할은 필수입니다.'; return; } if (_addAgentError) _addAgentError.textContent = ''; vscode.postMessage({ type: 'addCompanyAgent', def }); }; } // ── 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 _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, reviewWith: '', reviewMaxRounds: 3, 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 = ''; // "⚙️ CEO 자동 선택" 옵션을 항상 맨 위에 추가 — value=''로 빈 // agentId 저장됨. dispatcher가 보고 직군 후보 중 매번 CEO에게 // 적임자 결정 요청. 사용자 의도 "CEO가 배분"의 정공법. const autoOpt = document.createElement('option'); autoOpt.value = ''; autoOpt.textContent = '⚙️ CEO 자동 선택 (이 직군 중)'; agentSel.appendChild(autoOpt); const list = _activeAgentsByCategory[roleSel.value] || []; if (list.length === 0) { const opt = document.createElement('option'); opt.value = '__no_agents__'; opt.textContent = '(이 직군의 활성 에이전트 없음)'; opt.disabled = true; agentSel.appendChild(opt); // CEO 자동 선택은 여전히 가능 — 활성 후보가 없으면 dispatcher에서 // no-active-agent-in-role 에러로 사용자에게 알린다. } else { 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가 빈값이면 CEO 자동 선택(default). 활성 후보에 있으면 // 그 사람, 없으면 다시 CEO 자동으로 되돌림 — stale agentId 박제 방지. const aid = stage.agentId || ''; if (aid && list.some((a) => a.id === aid)) { agentSel.value = aid; } else { agentSel.value = ''; stage.agentId = ''; } }; _refillAgentSel(); roleSel.onchange = () => { stage.roleCategory = roleSel.value; // 직군 바뀌면 명시적 담당자 박힌 게 더 이상 의미 없음 — CEO 자동 선택으로 리셋. stage.agentId = ''; _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 = '체크하면 단계 완료 후 자동 진행하지 않고 승인/수정요청/중단 버튼이 채팅창에 뜹니다'; approvalWrap.appendChild(approvalText); body.appendChild(approvalWrap); // ── 3-way 합의 검수 사이클 ── // 작업자 산출물 → 검수자 의견 → CEO 메타-판단을 라운드로 반복해 // "작업자 + 검수자 + 대표" 셋이 모두 만족할 때까지 진행. 작은 // 모델에선 라운드를 많이 돌 수 있으므로 maxRounds 안전망 필요. const reviewWrap = document.createElement('label'); reviewWrap.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:10px; color:var(--text-dim); cursor:pointer; margin-top:4px;'; const reviewCb = document.createElement('input'); reviewCb.type = 'checkbox'; reviewCb.checked = !!stage.reviewWith; const reviewText = document.createElement('span'); reviewText.textContent = '🔍 검수 사이클 (작업자 + 검수자 + CEO 합의)'; reviewText.title = '체크하면 작업자 산출물 직후 검수자 + CEO 메타-판단을 라운드로 돌려 셋이 모두 만족할 때만 통과'; reviewWrap.appendChild(reviewCb); reviewWrap.appendChild(reviewText); body.appendChild(reviewWrap); // 검수자 선택 + 최대 라운드 (체크박스 ON일 때만 노출) const reviewDetail = document.createElement('div'); reviewDetail.style.cssText = 'display:none; gap:8px; flex-wrap:wrap; align-items:center; font-size:10px; color:var(--text-dim); margin: 2px 0 4px 22px;'; const inspLbl = document.createElement('label'); inspLbl.textContent = '검수자:'; const inspSel = document.createElement('select'); // 옵션: 직군 inspector 자동 + 활성 inspector 직군 에이전트 + 'any active agent of given category' 정도면 충분 const inspectorOpts = (_activeAgentsByCategory['inspector'] || []); const autoOpt = document.createElement('option'); autoOpt.value = 'inspector'; autoOpt.textContent = '⚙️ 감리 직군 자동'; inspSel.appendChild(autoOpt); for (const a of inspectorOpts) { const opt = document.createElement('option'); opt.value = `agent:${a.id}`; opt.textContent = `${a.emoji} ${a.name}`; inspSel.appendChild(opt); } // 현재값 적용 inspSel.value = stage.reviewWith || 'inspector'; inspSel.onchange = () => { stage.reviewWith = inspSel.value; }; const roundLbl = document.createElement('label'); roundLbl.textContent = '최대 라운드:'; const roundInput = document.createElement('input'); roundInput.type = 'number'; roundInput.min = '1'; roundInput.max = '10'; roundInput.style.cssText = 'width:55px; padding:2px 4px; background:var(--bg); color:var(--text-primary); border:1px solid var(--border); border-radius:4px;'; roundInput.value = String(stage.reviewMaxRounds || 3); roundInput.oninput = () => { const v = parseInt(roundInput.value, 10); stage.reviewMaxRounds = Number.isFinite(v) ? Math.max(1, Math.min(10, v)) : 3; }; reviewDetail.appendChild(inspLbl); reviewDetail.appendChild(inspSel); reviewDetail.appendChild(roundLbl); reviewDetail.appendChild(roundInput); body.appendChild(reviewDetail); const _syncReviewDetail = () => { reviewDetail.style.display = reviewCb.checked ? 'flex' : 'none'; }; _syncReviewDetail(); reviewCb.onchange = () => { if (reviewCb.checked) { stage.reviewWith = inspSel.value || 'inspector'; if (!stage.reviewMaxRounds) stage.reviewMaxRounds = 3; } else { stage.reviewWith = ''; } _syncReviewDetail(); }; // 지시 텍스트 + 토큰 버튼 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 = '감지할 표현:'; 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'); if (_pipelineEditError) _pipelineEditError.textContent = ''; if (pipeline) { if (_pipelineEditId) { _pipelineEditId.value = pipeline.id; _pipelineEditId.disabled = true; } if (_pipelineEditName) _pipelineEditName.value = pipeline.name || ''; // 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, reviewWith: s.reviewWith || '', reviewMaxRounds: s.reviewMaxRounds || 3, 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 = ''; _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) { _addPipelineBtn.onclick = () => { _openPipelineEditor(null); _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 = () => { const id = (_pipelineEditId?.value || '').trim().toLowerCase(); if (!id) { if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자로 시작, /-/_).'; return; } // 각 stage 검증: 라벨 + (담당자 또는 직군) 중 하나는 반드시. // CEO 자동 선택 모드(agentId 비어 있음)면 직군이 필수. 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 && !s.roleCategory) { if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당자(또는 직군)을 지정하세요.`; return; } } const def = { id, name: (_pipelineEditName?.value || '').trim() || id, stages: _editStages.map((s) => { const out = { id: s.id, label: s.label.trim(), // 빈 agentId(= CEO 자동 선택) 일 땐 필드 자체를 빼서 backend에 // optional로 전달. dispatcher가 roleCategory 보고 실시간 결정. roleCategory: s.roleCategory, instructionTemplate: s.instructionTemplate || '', }; if (s.agentId && s.agentId.trim()) { out.agentId = s.agentId.trim(); } if (s.modelOverride && s.modelOverride.trim()) { out.modelOverride = s.modelOverride.trim(); } if (s.requiresApproval) out.requiresApproval = true; if (s.reviewWith && s.reviewWith.trim()) { out.reviewWith = s.reviewWith.trim(); out.reviewMaxRounds = s.reviewMaxRounds || 3; } if (s.loopBackPattern && s.loopBackTo) { out.loopBackPattern = s.loopBackPattern; out.loopBackTo = s.loopBackTo; out.maxIterations = s.maxIterations || 3; } return out; }), }; vscode.postMessage({ type: 'upsertCompanyPipeline', def }); }; } if (_activePipelineSel) { _activePipelineSel.onchange = () => { vscode.postMessage({ type: 'setActiveCompanyPipeline', pipelineId: _activePipelineSel.value || null, }); }; } 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 = ''; 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 = ''; for (const p of Object.values(pipelines)) { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = `${p.name || p.id} · ${(p.stages || []).length}단계`; _activePipelineSel.appendChild(opt); } _activePipelineSel.value = activeId; // list _pipelineList.innerHTML = ''; const entries = Object.values(pipelines); if (entries.length === 0) { const li = document.createElement('li'); li.className = 'pipeline-empty-state'; li.textContent = '아직 저장된 작업 흐름이 없습니다. 템플릿으로 시작하거나 직접 만들어보세요.'; _pipelineList.appendChild(li); return; } for (const p of entries) { const li = document.createElement('li'); li.className = 'pipeline-summary-card'; const head = document.createElement('div'); head.className = 'pipeline-summary-head'; const title = document.createElement('div'); title.className = 'pipeline-summary-title'; title.innerHTML = `${escAttr(p.name || p.id)}${(p.stages || []).length}단계${p.id === activeId ? ' · 현재 사용 중' : ''}`; const actions = document.createElement('div'); actions.className = 'pipeline-summary-actions'; const editBtn = document.createElement('button'); editBtn.className = 'company-agent-edit'; editBtn.textContent = '편집'; editBtn.onclick = () => _openPipelineEditor(p); const delBtn = document.createElement('button'); delBtn.className = 'company-agent-edit'; delBtn.textContent = '삭제'; delBtn.title = '작업 흐름 삭제'; delBtn.onclick = () => { if (!confirm(`'${p.name || p.id}' 작업 흐름을 삭제할까요?`)) return; vscode.postMessage({ type: 'deleteCompanyPipeline', pipelineId: p.id }); }; actions.appendChild(editBtn); actions.appendChild(delBtn); head.appendChild(title); head.appendChild(actions); li.appendChild(head); const flow = document.createElement('div'); flow.className = 'pipeline-summary-flow'; const stages = Array.isArray(p.stages) ? p.stages : []; flow.textContent = stages.length > 0 ? stages.map((s) => s.label || s.id).join(' → ') : '단계가 없습니다'; li.appendChild(flow); _pipelineList.appendChild(li); } }; // expose for the message handler below window.__renderCompanyPipelines = renderCompanyPipelines; window.__closePipelineEditor = _closePipelineEditor; // ────────────────────────────────────────────────────────────────────── // Pixel Office 렌더러 — 백엔드의 pixelOfficeUpdate 메시지를 받아 캐릭터, // 정보 패널, 말풍선 큐를 갱신. 어떤 상태도 그대로 그리기만 함 (read-only). // ────────────────────────────────────────────────────────────────────── (function setupPixelOffice() { const root = document.getElementById('pixelOffice'); if (!root) return; const collapseBtn = document.getElementById('poCollapseBtn'); const expandBtn = document.getElementById('poExpandBtn'); const head = document.querySelector('#pixelOffice .po-head'); const charEl = document.getElementById('poChar'); const charEmoji = document.getElementById('poCharEmoji'); const charProp = document.getElementById('poCharProp'); const bubblesEl = document.getElementById('poBubbles'); const progressBar = document.getElementById('poProgressBar'); const statusLabel = document.getElementById('poStatusLabel'); const statusVal = document.getElementById('poStatusVal'); const agentName = document.getElementById('poAgentName'); const taskEl = document.getElementById('poTask'); const stepEl = document.getElementById('poStep'); const nextStepRow = document.getElementById('poNextStepRow'); const nextStepEl = document.getElementById('poNextStep'); const messageRow = document.getElementById('poMessageRow'); const messageEl = document.getElementById('poMessage'); const needInputSection = document.getElementById('poNeedInputSection'); const needInputList = document.getElementById('poNeedInputList'); const approvalSection = document.getElementById('poApprovalSection'); const approvalText = document.getElementById('poApprovalText'); const contractSection = document.getElementById('poContractSection'); const contractEl = document.getElementById('poContract'); const logsSection = document.getElementById('poLogsSection'); const logsEl = document.getElementById('poLogs'); // 상태별 캐릭터·소품 매핑 — 픽셀아트 대신 이모지 조합으로. const STATUS_VIS = { idle: { emoji: '🧑‍💼', prop: '' }, intake: { emoji: '🧑‍💼', prop: '📨' }, analyzing: { emoji: '🧐', prop: '🔍' }, need_clarification: { emoji: '🤔', prop: '❓' }, contract_ready: { emoji: '🧑‍💼', prop: '📋' }, planning: { emoji: '🧑‍💼', prop: '📝' }, executing: { emoji: '🧑‍💻', prop: '⚙️' }, reviewing: { emoji: '🧐', prop: '✅' }, waiting_approval: { emoji: '🧑‍💼', prop: '🛑' }, error: { emoji: '😵', prop: '⚠️' }, done: { emoji: '😎', prop: '☕' }, }; // ── 말풍선 큐 ── // 큐 크기 제한 + 자동 사라짐 타이머. 같은 텍스트 연속 등장 시 1개로 합침. let cfg = { enabled: true, bubblesEnabled: true, maxVisibleBubbles: 3, bubbleDurationMs: 4500 }; const bubbleQueue = []; // { el, timer } let lastBubbleText = ''; const collapseToggle = () => { const cur = root.getAttribute('data-collapsed') === 'true'; root.setAttribute('data-collapsed', cur ? 'false' : 'true'); }; if (collapseBtn) collapseBtn.onclick = (e) => { e.stopPropagation(); collapseToggle(); }; if (expandBtn) expandBtn.onclick = (e) => { e.stopPropagation(); // 백엔드에 전체보기 panel 열기 요청. vscode.postMessage({ type: 'openPixelOfficePanel' }); }; // head 영역 자체 클릭으로도 토글 (버튼 외 영역). if (head) head.addEventListener('click', (e) => { if (e.target instanceof HTMLElement && (e.target.closest('.po-collapse') || e.target.closest('.po-expand'))) return; collapseToggle(); }); const dropOldestBubble = () => { const first = bubbleQueue.shift(); if (!first) return; if (first.timer) clearTimeout(first.timer); first.el.classList.add('po-bubble-fading'); setTimeout(() => { try { first.el.remove(); } catch {} }, 300); }; const pushBubble = (b) => { if (!cfg.bubblesEnabled) return; if (!b || !b.text) return; if (b.text === lastBubbleText) return; // 연속 중복 차단 lastBubbleText = b.text; const el = document.createElement('div'); el.className = 'po-bubble po-bubble-' + (b.type || 'status'); el.textContent = b.text; bubblesEl.appendChild(el); const duration = b.durationMs || cfg.bubbleDurationMs || 4500; const timer = setTimeout(() => { const idx = bubbleQueue.findIndex((x) => x.el === el); if (idx >= 0) { bubbleQueue.splice(idx, 1); el.classList.add('po-bubble-fading'); setTimeout(() => { try { el.remove(); } catch {} }, 300); } }, duration); bubbleQueue.push({ el, timer }); while (bubbleQueue.length > Math.max(1, cfg.maxVisibleBubbles)) { dropOldestBubble(); } }; const setText = (el, val) => { if (el) el.textContent = (val == null || val === '') ? '—' : String(val); }; const apply = (payload) => { cfg = Object.assign(cfg, payload && payload.config ? payload.config : {}); root.setAttribute('data-enabled', cfg.enabled ? 'true' : 'false'); if (!cfg.enabled) return; const state = payload && payload.state; if (state) { const vis = STATUS_VIS[state.status] || STATUS_VIS.idle; if (charEmoji) charEmoji.textContent = vis.emoji; if (charProp) charProp.textContent = vis.prop; root.setAttribute('data-status', state.status || 'idle'); // 상태 라벨 색상 클래스 새로. if (statusLabel) { statusLabel.className = 'po-status-label po-status-' + (state.status || 'idle'); statusLabel.textContent = state.status || 'idle'; } setText(statusVal, state.status); setText(agentName, state.agentName); setText(taskEl, state.currentTask); setText(stepEl, state.currentStep); if (state.nextStep) { nextStepRow.style.display = ''; setText(nextStepEl, state.nextStep); } else { nextStepRow.style.display = 'none'; } if (state.message) { messageRow.style.display = ''; setText(messageEl, state.message); } else { messageRow.style.display = 'none'; } // Progress if (progressBar) { const pct = typeof state.progress === 'number' ? Math.round(Math.max(0, Math.min(1, state.progress)) * 100) : 0; progressBar.style.width = pct + '%'; } // Need input if (Array.isArray(state.needUserInput) && state.needUserInput.length > 0) { needInputSection.style.display = ''; needInputList.innerHTML = ''; for (const q of state.needUserInput) { const li = document.createElement('li'); li.textContent = q; needInputList.appendChild(li); } } else { needInputSection.style.display = 'none'; } // Approval if (state.awaitingApproval) { approvalSection.style.display = ''; setText(approvalText, state.awaitingApproval); } else { approvalSection.style.display = 'none'; } // Contract const c = state.requirementContract; if (c && (c.goal || c.context || (c.criteria && c.criteria.length) || c.format)) { contractSection.style.display = ''; contractEl.innerHTML = ''; const addRow = (k, v) => { if (!v || (Array.isArray(v) && v.length === 0)) return; const ke = document.createElement('div'); ke.className = 'po-contract-key'; ke.textContent = k; const ve = document.createElement('div'); ve.className = 'po-contract-val'; ve.textContent = Array.isArray(v) ? v.map((x) => '• ' + x).join('\n') : String(v); ve.style.whiteSpace = 'pre-line'; contractEl.appendChild(ke); contractEl.appendChild(ve); }; addRow('Goal', c.goal); addRow('Ctx', c.context); addRow('Crit', c.criteria); addRow('Fmt', c.format); if (c.confidence) addRow('Conf', c.confidence); } else { contractSection.style.display = 'none'; } // Recent logs if (Array.isArray(state.recentLogs) && state.recentLogs.length > 0) { logsSection.style.display = ''; logsEl.innerHTML = ''; for (const line of state.recentLogs) { const d = document.createElement('div'); d.textContent = line; logsEl.appendChild(d); } } else { logsSection.style.display = 'none'; } } // Bubbles if (Array.isArray(payload?.bubbles)) { for (const b of payload.bubbles) pushBubble(b); } }; window.__pixelOfficeApply = apply; // webview 로드 직후 백엔드 캐시 상태 요청. try { vscode.postMessage({ type: 'getPixelOfficeState' }); } catch {} })(); // ────────────────────────────────────────────────────────────────────── // 이어서 진행 가능 세션 렌더링. // // 백엔드가 보낸 items 배열을 카드 목록으로 그린다. 비어 있으면 섹션 자체를 // data-empty="true"로 숨겨 평소 시야에서 사라지게 만든다. 카드는 두 액션: // - 이어서 진행 → resumeCompanyTurn 메시지 // - 버리기 → discardResumableSession (resume 파일을 'failed'로 마킹) // ────────────────────────────────────────────────────────────────────── const _formatRelative = (iso) => { if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const diff = Date.now() - d.getTime(); const m = Math.round(diff / 60000); if (m < 1) return '방금 전'; if (m < 60) return `${m}분 전`; const h = Math.round(m / 60); if (h < 24) return `${h}시간 전`; const days = Math.round(h / 24); return `${days}일 전`; }; const _formatAbortReason = (reason) => { if (!reason) return '도중에 멈춤'; const map = { 'signal-aborted': '시작 직전에 중단', 'aborted-after-plan': '계획 직후 중단', 'aborted-mid-dispatch': '실행 중에 중단', 'aborted-mid-pipeline': '단계 진행 중 중단', 'aborted-mid-approval': '승인 대기 중 중단', 'aborted-by-user-at-approval': '승인 단계에서 중단', 'aborted-before-report': '보고서 직전 중단', }; return map[reason] || reason; }; const renderCompanyResumable = (payload) => { const section = document.getElementById('companyResumableSection'); const list = document.getElementById('companyResumableList'); if (!section || !list) return; const items = (payload && Array.isArray(payload.items)) ? payload.items : []; list.innerHTML = ''; if (items.length === 0) { section.setAttribute('data-empty', 'true'); return; } section.setAttribute('data-empty', 'false'); for (const it of items) { const li = document.createElement('li'); li.className = 'company-resumable-card'; li.dataset.timestamp = it.timestamp; const head = document.createElement('div'); head.className = 'company-resumable-head'; const prompt = document.createElement('div'); prompt.className = 'company-resumable-prompt'; prompt.textContent = it.userPrompt || '(빈 요청)'; prompt.title = it.userPrompt || ''; const actions = document.createElement('div'); actions.className = 'company-resumable-actions'; const resumeBtn = document.createElement('button'); resumeBtn.className = 'primary company-resumable-resume'; resumeBtn.textContent = '이어서 진행'; resumeBtn.title = '이 작업을 멈췄던 다음 단계부터 같은 세션에 이어 기록합니다.'; resumeBtn.onclick = () => { // 사용자에게 곧 시작될 거라는 시각 피드백. resumeBtn.disabled = true; resumeBtn.textContent = '재개 중…'; vscode.postMessage({ type: 'resumeCompanyTurn', timestamp: it.timestamp }); // overlay를 닫아 채팅 화면이 보이게 — 사용자가 진행 상황 즉시 확인. document.getElementById('companyOverlay')?.classList.remove('visible'); }; const discardBtn = document.createElement('button'); discardBtn.className = 'company-resumable-discard'; discardBtn.textContent = '버리기'; discardBtn.title = '이 작업을 더 이상 이어가지 않습니다. 목록에서만 빠지고 기존 산출물 파일은 그대로 남습니다.'; discardBtn.onclick = () => { if (!confirm('이 미완 작업을 목록에서 버릴까요? 이미 만들어진 산출물 파일은 사라지지 않습니다.')) return; vscode.postMessage({ type: 'discardResumableSession', timestamp: it.timestamp }); }; actions.appendChild(resumeBtn); actions.appendChild(discardBtn); head.appendChild(prompt); head.appendChild(actions); li.appendChild(head); const meta = document.createElement('div'); meta.className = 'company-resumable-meta'; const pipelineLabel = it.pipelineName ? `📋 ${escAttr(it.pipelineName)}` : '🧭 대표 분배 모드'; const progress = (it.totalCount > 0) ? `${it.completedCount}/${it.totalCount} 단계 완료` : '진행도 정보 없음'; const when = _formatRelative(it.lastUpdatedAt); const why = it.status === 'aborted' ? `· ${_formatAbortReason(it.abortReason)}` : (it.status === 'in-progress' ? '· 프로세스 중단 추정' : ''); meta.innerHTML = `${pipelineLabel}${escAttr(progress)}${escAttr(when)} ${escAttr(why)}`; li.appendChild(meta); list.appendChild(li); } }; window.__renderCompanyResumable = renderCompanyResumable; // 템플릿 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 * model list refreshes (the top `#modelSel` is the source of truth * for available models — see `populateAgentModelSelect`). */ let _lastCompanyAgentsPayload = null; /** * Populate one agent's model `