3072 lines
167 KiB
JavaScript
3072 lines
167 KiB
JavaScript
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 = `<span class="welcome-chip-emoji">${p.emoji}</span><span class="welcome-chip-text"></span>`;
|
||
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 = '복잡한 작업은 헤더의 <b>[기업 모드]</b>로 여러 전문 에이전트에게 분배할 수 있습니다. · 입력 후 <b>Cmd/Ctrl + Enter</b>로 전송.';
|
||
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(`<span class="rb-seg ${on === false ? 'bad' : on === true ? 'ok' : ''}">${escAttr(tag)}</span>`);
|
||
}
|
||
if (s.model && s.model.name) {
|
||
const loaded = s.model.loaded;
|
||
segs.push(`<span class="rb-seg" title="${escAttr(s.model.name)}${loaded === true ? ' — 메모리에 로드됨' : loaded === false ? ' — 아직 로드 안 됨' : ''}">${escAttr(shortModel(s.model.name))}</span>`);
|
||
}
|
||
if (s.brain && typeof s.brain.files === 'number') {
|
||
segs.push(`<span class="rb-seg" title="${escAttr('Brain: ' + (s.brain.name || ''))}">Brain ${s.brain.files}</span>`);
|
||
}
|
||
if (typeof s.contextLength === 'number') {
|
||
if (s.cappedForSmallModel) {
|
||
segs.push(`<span class="rb-seg rb-warn" title="${escAttr('작은 모델(≤4B) 감지 — 예산을 ' + fmtK(s.contextLength) + ' tokens 로 축소 (설정 g1nation.contextLength = ' + fmtK(s.nominalContextLength) + '). g1nation.smallModelContextCap 으로 조절.')}">ctx ${fmtK(s.contextLength)} <span class="rb-dim">· 소형모델 제한</span></span>`);
|
||
} else {
|
||
segs.push(`<span class="rb-seg" title="모델 context window (g1nation.contextLength). 실제 로드된 값과 맞춰주세요.">ctx ${fmtK(s.contextLength)}</span>`);
|
||
}
|
||
}
|
||
segs.push(`<span class="rb-seg ${s.memory ? '' : 'rb-dim'}">메모리 ${s.memory ? 'On' : 'Off'}</span>`);
|
||
if (s.multiAgent) segs.push(`<span class="rb-seg">멀티에이전트</span>`);
|
||
rbContent.innerHTML = segs.join('<span class="rb-sep">·</span>');
|
||
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 ? `<div class="lc-reason">사유: ${escAttr(String(v.reason))}</div>` : '';
|
||
// 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 =
|
||
`<div class="lc-title">${escAttr(titleText)}</div>` +
|
||
reasonLine +
|
||
`<div class="lc-btns"><button class="lc-rec">📝 교훈 기록</button><button class="lc-skip">무시</button></div>`;
|
||
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 = `<span class="scope-link" data-act="map" title="에이전트↔지식 매핑 편집">🔎 참조 지식 없음</span> <span class="scope-dim">— 모델 자체 지식으로 답변</span>`;
|
||
} 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 ? ` <span class="scope-dim">· 파일 ${files.length}</span>` : '';
|
||
const layerTag = layers.length ? ` <span class="scope-dim">· 메모리 ${escAttr(layers.join('·'))}</span>` : '';
|
||
const lessonTag = lessons.length ? ` <span class="scope-lesson" data-act="lessons" title="${escAttr('적용된 교훈 (클릭 → 교훈 관리):\n' + lessons.join('\n'))}">· ⚠ 교훈 ${lessons.length}</span>` : '';
|
||
const titleAttr = files.length ? `사용된 브레인 파일:\n${files.join('\n')}` : '에이전트↔지식 매핑 편집';
|
||
footer.innerHTML = `<span class="scope-link" data-act="map" title="${escAttr(titleAttr)}">🔎 참조: ${agentTag}${escAttr(scopeLabel)}</span>${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}% <span class="scope-dim">(${escAttr(srcLabel)})</span>`;
|
||
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('<br>');
|
||
const w = document.createElement('div');
|
||
w.className = 'scope-unaddressed';
|
||
w.innerHTML = `⚠ 답변에서 안 보이는 교훈 체크리스트 항목:<br>${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 ? '<div class="av av-user">U</div> You' : '<div class="av av-ai">✦</div> 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 = '<div class="welcome" id="welcomePanel"></div>';
|
||
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 <select> picks up
|
||
// any newly-discovered models from the master list.
|
||
if (typeof _lastCompanyAgentsPayload !== 'undefined' && _lastCompanyAgentsPayload
|
||
&& document.getElementById('companyOverlay')?.classList.contains('visible')) {
|
||
renderCompanyAgentCards(_lastCompanyAgentsPayload);
|
||
}
|
||
// 모델 목록이 채워졌고 default 선택이 정해졌으니 welcome 패널의
|
||
// "사용할 모델 선택" 체크 표시도 즉시 업데이트.
|
||
try { _renderWelcome(); } catch {}
|
||
break;
|
||
}
|
||
case 'brainProfiles':
|
||
brainSel.innerHTML = '';
|
||
msg.value.profiles.forEach(p => {
|
||
const o = document.createElement('option'); o.value = p.id; o.innerText = p.name;
|
||
if (p.id === msg.value.activeBrainId) o.selected = true;
|
||
brainSel.appendChild(o);
|
||
});
|
||
const addOpt = document.createElement('option');
|
||
addOpt.value = 'new'; addOpt.innerText = '+ Add New Brain...';
|
||
brainSel.appendChild(addOpt);
|
||
syncContextBar();
|
||
break;
|
||
case 'sessionList':
|
||
historyList.innerHTML = '';
|
||
msg.value.forEach(s => {
|
||
const el = document.createElement('div'); el.className = 'history-item';
|
||
el.setAttribute('role', 'button');
|
||
el.tabIndex = 0;
|
||
el.dataset.sessionId = s.id;
|
||
el.innerHTML = `<div style="font-weight:600; color:var(--text-bright); margin-bottom:2px;">${s.title}</div><div style="font-size:10px; color:var(--text-dim)">${new Date(s.timestamp).toLocaleString()} · ${s.messageCount} msgs</div>`;
|
||
const load = () => {
|
||
if (!el.dataset.sessionId) return;
|
||
vscode.postMessage({ type: 'loadSession', id: el.dataset.sessionId });
|
||
};
|
||
el.addEventListener('click', load);
|
||
el.addEventListener('keydown', event => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
load();
|
||
}
|
||
});
|
||
historyList.appendChild(el);
|
||
});
|
||
break;
|
||
case 'engineStatus':
|
||
statusDot.style.background = msg.value.online ? 'var(--success)' : 'var(--error)';
|
||
engineStatusText.innerText = msg.value.online ? 'Online' : 'Offline';
|
||
readyState.engine = Object.assign({}, readyState.engine, { online: !!msg.value.online });
|
||
renderReadyBar();
|
||
break;
|
||
case 'readyStatus':
|
||
readyState = Object.assign({}, readyState, msg.value || {});
|
||
renderReadyBar();
|
||
break;
|
||
case 'contextBudget':
|
||
renderCtxBadge(msg.value);
|
||
break;
|
||
case 'usedScope': {
|
||
let target = streamBody && streamBody._parent;
|
||
if (!target) {
|
||
const all = chat.querySelectorAll('.msg.msg-ai');
|
||
target = all[all.length - 1];
|
||
}
|
||
renderScopeFooter(target, msg.value || {});
|
||
break;
|
||
}
|
||
case 'lessonCandidate':
|
||
renderLessonCandidate(msg.value || {});
|
||
break;
|
||
case 'autoContinue':
|
||
statusLabel.innerText = msg.value; thinkingBar.classList.add('active');
|
||
if (msg.value.includes('Analyzing')) setStep('analyze');
|
||
if (msg.value.includes('Planning')) setStep('plan');
|
||
if (msg.value.includes('Executing')) setStep('execute');
|
||
setTimeout(() => { thinkingBar.classList.remove('active'); }, 3000);
|
||
break;
|
||
case 'agentsList':
|
||
agentSel.innerHTML = '<option value="none">No Agent</option>';
|
||
msg.value.forEach(a => {
|
||
const o = document.createElement('option'); o.value = a.path; o.innerText = a.name;
|
||
if (a.path === msg.selected) o.selected = true;
|
||
agentSel.appendChild(o);
|
||
});
|
||
if (msg.selected && msg.selected !== 'none') {
|
||
vscode.postMessage({ type: 'getAgentContent', path: msg.selected });
|
||
}
|
||
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected });
|
||
syncContextBar();
|
||
break;
|
||
case 'companyStatus': {
|
||
const v = msg.value || {};
|
||
renderCompanyChip(!!v.enabled, v.summary || '');
|
||
break;
|
||
}
|
||
case 'companyAgents': {
|
||
renderCompanyAgentCards(msg.value || {});
|
||
break;
|
||
}
|
||
case 'addCompanyAgentResult': {
|
||
const v = msg.value || {};
|
||
const errEl = document.getElementById('addAgentError');
|
||
const form = document.getElementById('addCompanyAgentForm');
|
||
if (v.ok) {
|
||
if (errEl) errEl.textContent = '';
|
||
// 폼 닫고 필드 초기화 — companyAgents 메시지가 목록을 다시 그려줌
|
||
if (form) form.setAttribute('data-open', 'false');
|
||
const fids = ['newAgentId','newAgentName','newAgentRole','newAgentEmoji','newAgentColor','newAgentTagline','newAgentSpecialty','newAgentPersona'];
|
||
for (const fid of fids) { const el = document.getElementById(fid); if (el) el.value = ''; }
|
||
showToast(`✅ '${v.agentId}' 에이전트 추가 완료`, 'info');
|
||
} else {
|
||
if (errEl) errEl.textContent = v.reason || '에이전트 추가 실패.';
|
||
}
|
||
break;
|
||
}
|
||
case 'deleteCompanyAgentResult': {
|
||
const v = msg.value || {};
|
||
if (v.ok) {
|
||
const msg2 = v.kind === 'builtin-hidden'
|
||
? `🗑 기본 에이전트 '${v.agentId}' 숨김 처리 (목록 하단에서 복원 가능)`
|
||
: `🗑 '${v.agentId}' 에이전트 삭제됨`;
|
||
showToast(msg2, 'warn');
|
||
} else {
|
||
showToast(`삭제 실패: ${v.reason || '알 수 없음'}`, 'warn');
|
||
}
|
||
break;
|
||
}
|
||
case 'restoreHiddenAgentResult': {
|
||
const v = msg.value || {};
|
||
if (v.ok) {
|
||
showToast(`↩ '${v.agentId}' 에이전트 복원 완료`, 'info');
|
||
} else {
|
||
showToast(`복원 실패: ${v.reason || '알 수 없음'}`, 'warn');
|
||
}
|
||
break;
|
||
}
|
||
case 'companyPipelines': {
|
||
if (typeof window.__renderCompanyPipelines === 'function') {
|
||
window.__renderCompanyPipelines(msg.value || {});
|
||
}
|
||
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');
|
||
if (v.ok) {
|
||
if (errEl) errEl.textContent = '';
|
||
if (typeof window.__closePipelineEditor === 'function') window.__closePipelineEditor();
|
||
showToast('작업 흐름 저장 완료', 'info');
|
||
} else {
|
||
if (errEl) errEl.textContent = v.reason || '작업 흐름 저장 실패.';
|
||
}
|
||
break;
|
||
}
|
||
case 'deleteCompanyPipelineResult': {
|
||
const v = msg.value || {};
|
||
if (v.ok) showToast(`작업 흐름 '${v.pipelineId}' 삭제됨`, 'warn');
|
||
else showToast(`삭제 실패: ${v.reason || '알 수 없음'}`, 'warn');
|
||
break;
|
||
}
|
||
case 'setActiveCompanyPipelineResult': {
|
||
const v = msg.value || {};
|
||
if (v.ok) {
|
||
showToast(v.pipelineId ? `작업 흐름 '${v.pipelineId}' 사용 중` : '대표가 알아서 분배하도록 변경', 'info');
|
||
} else {
|
||
showToast(`활성 설정 실패: ${v.reason || '알 수 없음'}`, 'warn');
|
||
}
|
||
break;
|
||
}
|
||
case 'openCompanyManageOverlay': {
|
||
// Triggered by the Command Palette `Manage 1인 기업 Agents`.
|
||
document.getElementById('companyOverlay')?.classList.add('visible');
|
||
vscode.postMessage({ type: 'getCompanyAgents' });
|
||
break;
|
||
}
|
||
case 'companyTurnUpdate': {
|
||
if (msg.value) renderCompanyPhase(msg.value);
|
||
break;
|
||
}
|
||
case 'architectureStatus': {
|
||
// Three-state chip:
|
||
// active — full info + Open/Refresh/Detach
|
||
// inactive — name + [Attach] button (user previously detached, OR doc not yet generated)
|
||
// hidden — no project + no workspace
|
||
const chip = document.getElementById('archChip');
|
||
const title = document.getElementById('archChipTitle');
|
||
const meta = document.getElementById('archChipMeta');
|
||
if (!chip || !title || !meta) break;
|
||
const v = msg.value || {};
|
||
if (v.active) {
|
||
chip.setAttribute('data-state', 'active');
|
||
title.textContent = `${v.projectName || 'Project'} architecture`;
|
||
const updatedLabel = v.lastUpdated ? `updated ${formatRelativeTime(v.lastUpdated)}` : 'just attached';
|
||
const autoLabel = v.autoUpdate === false ? 'Auto-update Off' : 'Auto-update On';
|
||
meta.textContent = `${updatedLabel} · ${autoLabel}`;
|
||
} else if (v.canAttach && v.projectName) {
|
||
chip.setAttribute('data-state', 'inactive');
|
||
title.textContent = `${v.projectName} architecture`;
|
||
meta.textContent = v.detached ? 'detached — click Attach to re-enable' : 'not yet activated';
|
||
} else {
|
||
chip.setAttribute('data-state', 'hidden');
|
||
}
|
||
break;
|
||
}
|
||
case 'architectureRefreshFailed': {
|
||
const reason = msg.value && msg.value.reason;
|
||
if (reason === 'no-active-project') {
|
||
showToast('활성 프로젝트가 없습니다. 먼저 프로젝트를 선택하세요.', 'warn');
|
||
} else {
|
||
showToast('Architecture 갱신 실패', 'warn');
|
||
}
|
||
break;
|
||
}
|
||
case 'architectureRefreshResult': {
|
||
// Trust-building stats card: shows exactly what the
|
||
// refresh did so users don't have to guess whether the
|
||
// 0.1s click actually accomplished anything.
|
||
const v = msg.value || {};
|
||
const card = document.createElement('div');
|
||
card.className = 'arch-refresh-card';
|
||
const noChanges = (v.newlyAnalyzed | 0) === 0 && (v.deleted | 0) === 0;
|
||
if (noChanges) card.classList.add('no-changes');
|
||
const head = noChanges
|
||
? `📋 ${escAttr(v.projectName || 'Project')} architecture — 변경 사항 없음`
|
||
: `📋 ${escAttr(v.projectName || 'Project')} architecture refreshed`;
|
||
const parts = [
|
||
`${v.newlyAnalyzed | 0} newly analysed`,
|
||
`${v.cached | 0} cached`,
|
||
];
|
||
if ((v.deleted | 0) > 0) parts.push(`${v.deleted | 0} deleted`);
|
||
parts.push(`${v.durationMs | 0}ms`);
|
||
card.innerHTML =
|
||
`<div class="arc-head">${head}</div>` +
|
||
`<div class="arc-meta">${parts.join(' · ')}</div>`;
|
||
const chatEl = document.getElementById('chat');
|
||
if (chatEl) {
|
||
chatEl.appendChild(card);
|
||
chatEl.scrollTop = chatEl.scrollHeight;
|
||
setTimeout(() => {
|
||
card.classList.add('fading');
|
||
card.addEventListener('transitionend', () => card.remove(), { once: true });
|
||
}, 3000);
|
||
}
|
||
break;
|
||
}
|
||
case 'knowledgeMix': {
|
||
// Initial sync: reflect whatever weight is currently in settings.
|
||
if (msg.value && typeof msg.value.weight === 'number') {
|
||
const w = Math.max(0, Math.min(100, msg.value.weight));
|
||
const slider = document.getElementById('knowledgeMixSlider');
|
||
if (slider) slider.value = String(w);
|
||
const hint = document.getElementById('knowledgeMixHint');
|
||
if (hint) hint.textContent = fmtMixHint(w);
|
||
}
|
||
break;
|
||
}
|
||
case 'agentModelOverride': {
|
||
// The extension chose a different model than what the dropdowns show
|
||
// (per-agent pinned model). Reflect that in the UI without persisting
|
||
// it as the new global default — selecting a different agent or
|
||
// clearing the override should restore the previous selection.
|
||
const pinned = msg.value && msg.value.model;
|
||
if (pinned) {
|
||
const inlineSel = document.getElementById('modelInlineSel');
|
||
// Add an option if it isn't already known so the value can stick.
|
||
const ensureOption = (sel) => {
|
||
if (!sel) return;
|
||
const has = Array.from(sel.options).some(o => o.value === pinned);
|
||
if (!has) {
|
||
const o = document.createElement('option');
|
||
o.value = pinned;
|
||
o.innerText = `${pinned} (agent)`;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.value = pinned;
|
||
};
|
||
ensureOption(modelSel);
|
||
ensureOption(inlineSel);
|
||
// The pill shows the model directly; surface the override as a tooltip
|
||
// instead of a duplicate status string.
|
||
if (inlineSel) inlineSel.title = `Model pinned by current agent: ${pinned}`;
|
||
}
|
||
break;
|
||
}
|
||
case 'agentMapData':
|
||
if (msg.value) {
|
||
agentMapDraft = {
|
||
agentPath: agentMapDraft.agentPath,
|
||
name: agentMapDraft.name,
|
||
knowledgeFolders: Array.isArray(msg.value.knowledgeFolders) ? msg.value.knowledgeFolders.slice() : [],
|
||
skillFolders: Array.isArray(msg.value.skillFolders) ? msg.value.skillFolders.slice() : [],
|
||
model: typeof msg.value.model === 'string' ? msg.value.model : '',
|
||
secondBrainWeight: (typeof msg.value.secondBrainWeight === 'number'
|
||
&& Number.isFinite(msg.value.secondBrainWeight))
|
||
? Math.max(0, Math.min(100, Math.round(msg.value.secondBrainWeight)))
|
||
: null,
|
||
};
|
||
renderAgentMapLists();
|
||
refreshAgentMapModelOptions();
|
||
syncAgentMapMixUi();
|
||
agentMapStatus.textContent = msg.value.exists ? '' : '새 매핑입니다. 저장하면 생성됩니다.';
|
||
agentMapStatus.className = 'map-status';
|
||
}
|
||
break;
|
||
case 'pickedPath':
|
||
if (msg.value && msg.value.path && agentMapOverlay.classList.contains('visible')) {
|
||
const target = (msg.value.kind === 'knowledgeFolder')
|
||
? agentMapDraft.knowledgeFolders
|
||
: agentMapDraft.skillFolders;
|
||
if (!target.includes(msg.value.path)) {
|
||
target.push(msg.value.path);
|
||
renderAgentMapLists();
|
||
}
|
||
}
|
||
break;
|
||
case 'agentMapSaved':
|
||
if (msg.value && msg.value.ok) {
|
||
agentMapStatus.className = 'map-status ok';
|
||
agentMapStatus.textContent = '저장되었습니다.';
|
||
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentMapDraft.agentPath });
|
||
setTimeout(closeAgentMapModal, 700);
|
||
} else {
|
||
agentMapStatus.className = 'map-status error';
|
||
agentMapStatus.textContent = '저장 실패: ' + (msg.value?.error || '알 수 없는 오류');
|
||
}
|
||
break;
|
||
case 'knowledgeScope':
|
||
if (knowledgeScopeSel) {
|
||
knowledgeScopeSel.innerHTML = '';
|
||
const folders = (msg.value && msg.value.folders) || [];
|
||
if (folders.length === 0) {
|
||
const o = document.createElement('option');
|
||
o.value = '';
|
||
const label = (msg.value && msg.value.agent)
|
||
? `매핑된 폴더 없음 (agent: ${msg.value.agent})`
|
||
: '매핑 없음 — 전체 브레인 검색';
|
||
o.innerText = label;
|
||
knowledgeScopeSel.appendChild(o);
|
||
knowledgeScopeSel.disabled = true;
|
||
} else {
|
||
knowledgeScopeSel.disabled = false;
|
||
folders.forEach(f => {
|
||
const o = document.createElement('option');
|
||
o.value = f.absolute;
|
||
o.innerText = f.relative || f.absolute;
|
||
o.title = f.absolute;
|
||
knowledgeScopeSel.appendChild(o);
|
||
});
|
||
}
|
||
}
|
||
break;
|
||
case 'chronicleProjects':
|
||
designerSel.innerHTML = '';
|
||
msg.value.projects.forEach(p => {
|
||
const o = document.createElement('option');
|
||
o.value = p.id;
|
||
o.innerText = p.name;
|
||
o.title = p.recordRoot;
|
||
if (p.id === msg.value.activeProjectId) o.selected = true;
|
||
designerSel.appendChild(o);
|
||
});
|
||
const newDesignerOpt = document.createElement('option');
|
||
newDesignerOpt.value = 'new';
|
||
newDesignerOpt.innerText = '+ Add Designer Project...';
|
||
designerSel.appendChild(newDesignerOpt);
|
||
syncContextBar();
|
||
vscode.postMessage({ type: 'getChronicleRecords' });
|
||
break;
|
||
case 'chronicleRecords':
|
||
chronicleRecordSel.innerHTML = '';
|
||
if (!msg.value || msg.value.length === 0) {
|
||
const emptyRecordOpt = document.createElement('option');
|
||
emptyRecordOpt.value = '';
|
||
emptyRecordOpt.innerText = 'No records yet';
|
||
chronicleRecordSel.appendChild(emptyRecordOpt);
|
||
syncRecordsLine();
|
||
break;
|
||
}
|
||
msg.value.forEach(record => {
|
||
const o = document.createElement('option');
|
||
o.value = record.path;
|
||
o.innerText = record.relativePath;
|
||
o.title = record.path;
|
||
chronicleRecordSel.appendChild(o);
|
||
});
|
||
syncRecordsLine();
|
||
break;
|
||
case 'agentContent':
|
||
agentPrompt.value = msg.value;
|
||
negativePrompt.value = msg.negativePrompt || '';
|
||
break;
|
||
case 'agentDeleted':
|
||
agentConfigPanel.style.display = 'none';
|
||
editMode = false;
|
||
editAgentBtn.classList.remove('active');
|
||
agentPrompt.value = '';
|
||
negativePrompt.value = '';
|
||
break;
|
||
case 'error':
|
||
thinkingBar.classList.remove('active'); sendBtn.disabled = false;
|
||
addMsg(msg.value, 'error');
|
||
break;
|
||
case 'lmStudioError':
|
||
showToast('LM Studio: ' + msg.value, 'warn');
|
||
break;
|
||
case 'requiresApproval':
|
||
const box = document.createElement('div');
|
||
box.className = 'approval-box';
|
||
box.innerHTML = '<div class="approval-title"><span>🛡️</span> 작업 승인 대기 중 (Action Approval Required)</div>' +
|
||
'<div style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">위의 변경 사항을 프로젝트에 반영할까요?</div>' +
|
||
'<div class="approval-btns">' +
|
||
' <button class="btn-approve" onclick="approve()">승인 (Approve)</button>' +
|
||
' <button class="btn-reject" onclick="reject()">롤백 (Rollback)</button>' +
|
||
'</div>';
|
||
chat.appendChild(box);
|
||
chat.scrollTop = chat.scrollHeight;
|
||
break;
|
||
}
|
||
});
|
||
|
||
function renderAttachments() {
|
||
attachPreview.innerHTML = '';
|
||
if (pendingFiles.length === 0) { attachPreview.classList.remove('visible'); return; }
|
||
attachPreview.classList.add('visible');
|
||
pendingFiles.forEach((f, i) => {
|
||
const chip = document.createElement('div'); chip.className = 'file-chip';
|
||
chip.innerHTML = `<span>📎</span> ${f.name} <span class="remove" onclick="removeFile(${i})">✕</span>`;
|
||
attachPreview.appendChild(chip);
|
||
});
|
||
}
|
||
window.removeFile = (i) => {
|
||
pendingFiles.splice(i, 1);
|
||
renderAttachments();
|
||
// 파일 삭제 후 Draft State 재평가
|
||
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
|
||
};
|
||
function processFiles(files) {
|
||
if (!files || files.length === 0) return;
|
||
|
||
Array.from(files).forEach(file => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const base64 = reader.result.split(',')[1];
|
||
pendingFiles.push({ name: file.name, type: file.type, data: base64 });
|
||
renderAttachments();
|
||
setDraftActive(true);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
showToast(`${files.length}개의 파일이 추가되었습니다.`, 'success');
|
||
Sound.success();
|
||
}
|
||
|
||
attachBtn.onclick = () => fileInput.click();
|
||
fileInput.onchange = () => {
|
||
processFiles(fileInput.files);
|
||
fileInput.value = '';
|
||
};
|
||
|
||
// --- Drag and Drop Implementation ---
|
||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||
document.body.addEventListener(eventName, e => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}, false);
|
||
});
|
||
|
||
['dragenter', 'dragover'].forEach(eventName => {
|
||
document.body.addEventListener(eventName, () => {
|
||
document.body.classList.add('drag-over');
|
||
}, false);
|
||
});
|
||
|
||
['dragleave', 'drop'].forEach(eventName => {
|
||
document.body.addEventListener(eventName, () => {
|
||
document.body.classList.remove('drag-over');
|
||
}, false);
|
||
});
|
||
|
||
document.body.addEventListener('drop', e => {
|
||
const dt = e.dataTransfer;
|
||
const files = dt.files;
|
||
|
||
// ⭐ Kodari PD 가이드 반영: Input 요소의 상태를 드롭된 파일로 강제 동기화
|
||
if (files && files.length > 0) {
|
||
fileInput.files = files; // Input의 files 속성 업데이트
|
||
console.log(`✅ [DnD] Input 상태 동기화 성공: ${files[0].name} 외 ${files.length - 1}개`);
|
||
}
|
||
|
||
processFiles(files);
|
||
}, false);
|
||
|
||
function send() {
|
||
const val = input.value.trim();
|
||
if (!val && pendingFiles.length === 0) return;
|
||
addMsg(val || (pendingFiles.length > 0 ? `[Sent ${pendingFiles.length} files]` : ''), 'user');
|
||
vscode.postMessage({
|
||
type: 'prompt',
|
||
value: val,
|
||
model: modelSel.value,
|
||
internet: internetEnabled,
|
||
files: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||
agentFile: agentSel.value === 'none' ? undefined : agentSel.value,
|
||
brainProfileId: brainSel.value && brainSel.value !== 'new' ? brainSel.value : undefined,
|
||
negativePrompt: negativePrompt.value.trim() || undefined,
|
||
secondBrainTrace: secondBrainTraceEnabled,
|
||
secondBrainTraceDebug
|
||
});
|
||
input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments();
|
||
// 전송 완료 후 Draft State 리셋 + Stop 버튼 표시
|
||
setDraftActive(false);
|
||
setGenerating(true);
|
||
thinkingBar.classList.add('active');
|
||
|
||
// Save state after sending
|
||
const currentState = vscode.getState() || { history: [] };
|
||
currentState.history.push({ role: 'user', content: val });
|
||
saveWebviewState(currentState.history);
|
||
}
|
||
|
||
sendBtn.onclick = send;
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
if (e.isComposing) return;
|
||
e.preventDefault();
|
||
send();
|
||
}
|
||
});
|
||
let _lastActivityBump = 0;
|
||
const ACTIVITY_BUMP_INTERVAL_MS = 5000;
|
||
const bumpActivity = () => {
|
||
const now = Date.now();
|
||
if (now - _lastActivityBump < ACTIVITY_BUMP_INTERVAL_MS) return;
|
||
_lastActivityBump = now;
|
||
vscode.postMessage({ type: 'activity' });
|
||
};
|
||
input.addEventListener('input', () => {
|
||
input.style.height = 'auto';
|
||
input.style.height = input.scrollHeight + 'px';
|
||
// Draft State: 내용이 있으면 cancelBtn 표시
|
||
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
|
||
bumpActivity();
|
||
});
|
||
|
||
cancelBtn.onclick = () => clearDraft();
|
||
stopBtn.onclick = () => {
|
||
vscode.postMessage({ type: 'stopGeneration' });
|
||
setGenerating(false);
|
||
thinkingBar.classList.remove('active');
|
||
showToast('■ 생성이 중단되었습니다.', 'warn');
|
||
Sound.warn();
|
||
};
|
||
|
||
const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); };
|
||
document.getElementById('newChatBtn').onclick = startNewChat;
|
||
// Note: input-footer "New Chat" / "Sync Knowledge" buttons were removed.
|
||
// Both actions remain available in the top toolbar (newChatBtn / brainBtn / Tools menu).
|
||
|
||
document.getElementById('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' });
|
||
document.getElementById('internetBtn').onclick = () => {
|
||
internetEnabled = !internetEnabled; document.getElementById('internetBtn').classList.toggle('active', internetEnabled);
|
||
};
|
||
document.getElementById('brainTraceBtn').onclick = () => {
|
||
secondBrainTraceEnabled = !secondBrainTraceEnabled;
|
||
const btn = document.getElementById('brainTraceBtn');
|
||
btn.classList.toggle('active', secondBrainTraceEnabled);
|
||
btn.setAttribute('data-tooltip', secondBrainTraceEnabled ? 'Second Brain Trace Mode: On' : 'Second Brain Trace Mode: Off');
|
||
saveUiState();
|
||
};
|
||
document.getElementById('brainTraceDebugBtn').onclick = () => {
|
||
secondBrainTraceDebug = !secondBrainTraceDebug;
|
||
const btn = document.getElementById('brainTraceDebugBtn');
|
||
btn.classList.toggle('active', secondBrainTraceDebug);
|
||
btn.setAttribute('data-tooltip', secondBrainTraceDebug ? 'Second Brain Debug JSON: On' : 'Second Brain Debug JSON: Off');
|
||
saveUiState();
|
||
};
|
||
|
||
const syncBrain = () => { Sound.play(550, 'sine', 0.1); vscode.postMessage({ type: 'syncBrain' }); };
|
||
document.getElementById('brainBtn').onclick = syncBrain;
|
||
saveWikiRawBtn.onclick = () => vscode.postMessage({ type: 'saveWikiRaw' });
|
||
addBrainBtn.onclick = () => vscode.postMessage({ type: 'addBrain' });
|
||
editBrainBtn.onclick = () => {
|
||
if (!brainSel.value || brainSel.value === 'new') return;
|
||
vscode.postMessage({ type: 'editBrain', id: brainSel.value });
|
||
};
|
||
deleteBrainBtn.onclick = () => {
|
||
if (!brainSel.value || brainSel.value === 'new') return;
|
||
vscode.postMessage({ type: 'deleteBrain', id: brainSel.value });
|
||
};
|
||
// (inputSyncBtn removed — Sync Knowledge is reachable via the top brainBtn / Tools menu.)
|
||
document.getElementById('historyBtn').onclick = () => vscode.postMessage({ type: 'getSessions' });
|
||
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
|
||
document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('visible');
|
||
// The input placeholder is now a constant brand label — the model name
|
||
// lives in the footer pill itself, so we don't repeat it here.
|
||
const updateInputPlaceholder = () => {
|
||
if (typeof input !== 'undefined' && input) input.placeholder = 'Ask Astra...';
|
||
};
|
||
|
||
// Shared handler so the header dropdown and the footer pill dropdown
|
||
// always commit the same way and stay visually synced.
|
||
const applyModelSelection = (selectedModel, originEl) => {
|
||
if (!selectedModel) return;
|
||
// [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성)
|
||
try {
|
||
localStorage.setItem('g1nation_last_model', selectedModel);
|
||
} catch(e) {
|
||
console.warn('[Astra] LocalStorage 저장 실패:', e);
|
||
}
|
||
// [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장)
|
||
vscode.postMessage({ type: 'model', value: selectedModel });
|
||
// Mirror the value to the *other* dropdown so both pickers reflect reality.
|
||
const inlineSel = document.getElementById('modelInlineSel');
|
||
if (originEl !== modelSel && modelSel.value !== selectedModel) modelSel.value = selectedModel;
|
||
if (inlineSel && originEl !== inlineSel && inlineSel.value !== selectedModel) inlineSel.value = selectedModel;
|
||
};
|
||
modelSel.onchange = () => applyModelSelection(modelSel.value, modelSel);
|
||
const _inlineModelSelEl = document.getElementById('modelInlineSel');
|
||
if (_inlineModelSelEl) {
|
||
_inlineModelSelEl.onchange = () => applyModelSelection(_inlineModelSelEl.value, _inlineModelSelEl);
|
||
}
|
||
brainSel.onchange = () => {
|
||
if (brainSel.value === 'new') {
|
||
vscode.postMessage({ type: 'addBrain' });
|
||
} else {
|
||
vscode.postMessage({ type: 'setBrainProfile', id: brainSel.value });
|
||
}
|
||
};
|
||
|
||
designerSel.onchange = () => {
|
||
if (designerSel.value === 'new') {
|
||
vscode.postMessage({ type: 'createChronicleProject' });
|
||
} else {
|
||
vscode.postMessage({ type: 'setChronicleProject', id: designerSel.value });
|
||
vscode.postMessage({ type: 'getChronicleRecords' });
|
||
}
|
||
};
|
||
|
||
agentSel.onchange = () => {
|
||
if (agentSel.value !== 'none') {
|
||
vscode.postMessage({ type: 'getAgentContent', path: agentSel.value });
|
||
// [State Persistence Fix] 에이전트 선택값을 즉시 백엔드에 저장
|
||
vscode.postMessage({ type: 'saveAgentSelection', path: agentSel.value });
|
||
if (editMode) agentConfigPanel.style.display = 'flex';
|
||
} else {
|
||
agentConfigPanel.style.display = 'none';
|
||
editMode = false;
|
||
editAgentBtn.classList.remove('active');
|
||
agentPrompt.value = '';
|
||
negativePrompt.value = '';
|
||
// [State Persistence Fix] 에이전트 해제도 즉시 저장
|
||
vscode.postMessage({ type: 'saveAgentSelection', path: 'none' });
|
||
}
|
||
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentSel.value });
|
||
};
|
||
|
||
if (editKnowledgeMapBtn) {
|
||
editKnowledgeMapBtn.onclick = () => openAgentMapModal();
|
||
}
|
||
if (reloadKnowledgeMapBtn) {
|
||
reloadKnowledgeMapBtn.onclick = () => vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentSel.value });
|
||
}
|
||
if (closeAgentMapBtn) closeAgentMapBtn.onclick = closeAgentMapModal;
|
||
if (cancelAgentMapBtn) cancelAgentMapBtn.onclick = closeAgentMapModal;
|
||
if (editAgentMapJsonBtn) {
|
||
editAgentMapJsonBtn.onclick = () => vscode.postMessage({ type: 'editKnowledgeMap' });
|
||
}
|
||
if (addKnowledgeFolderBtn) {
|
||
addKnowledgeFolderBtn.onclick = () => vscode.postMessage({ type: 'pickPath', kind: 'knowledgeFolder' });
|
||
}
|
||
if (addSkillFolderBtn) {
|
||
addSkillFolderBtn.onclick = () => vscode.postMessage({ type: 'pickPath', kind: 'skillFolder' });
|
||
}
|
||
if (addSkillFileBtn) {
|
||
addSkillFileBtn.onclick = () => vscode.postMessage({ type: 'pickPath', kind: 'skillFile' });
|
||
}
|
||
if (saveAgentMapBtn) {
|
||
saveAgentMapBtn.onclick = () => {
|
||
agentMapStatus.className = 'map-status';
|
||
agentMapStatus.textContent = '저장 중...';
|
||
vscode.postMessage({
|
||
type: 'saveAgentMap',
|
||
agentPath: agentMapDraft.agentPath,
|
||
knowledgeFolders: agentMapDraft.knowledgeFolders,
|
||
skillFolders: agentMapDraft.skillFolders,
|
||
// Empty string = "Use current model" (override removed).
|
||
model: agentMapDraft.model || '',
|
||
// null = "Use global setting" (override removed); number 0–100 = pinned.
|
||
secondBrainWeight: agentMapDraft.secondBrainWeight,
|
||
});
|
||
};
|
||
}
|
||
// Track changes to the per-agent model dropdown so the draft stays in sync.
|
||
const _agentMapModelSelEl = document.getElementById('agentMapModelSel');
|
||
if (_agentMapModelSelEl) {
|
||
_agentMapModelSelEl.onchange = () => {
|
||
agentMapDraft.model = _agentMapModelSelEl.value || '';
|
||
};
|
||
}
|
||
// ── Per-agent Knowledge Mix slider + "Use global" checkbox ────────────
|
||
const _agentMapMixCb = document.getElementById('agentMapMixUseGlobal');
|
||
const _agentMapMixSlider = document.getElementById('agentMapMixSlider');
|
||
if (_agentMapMixCb && _agentMapMixSlider) {
|
||
_agentMapMixCb.addEventListener('change', () => {
|
||
if (_agentMapMixCb.checked) {
|
||
agentMapDraft.secondBrainWeight = null;
|
||
} else {
|
||
// Snap to whatever the slider currently shows so the user has a starting point.
|
||
agentMapDraft.secondBrainWeight = parseInt(_agentMapMixSlider.value, 10) || 50;
|
||
}
|
||
syncAgentMapMixUi();
|
||
});
|
||
_agentMapMixSlider.addEventListener('input', () => {
|
||
if (_agentMapMixCb.checked) return; // disabled state, but guard anyway
|
||
const w = Math.max(0, Math.min(100, parseInt(_agentMapMixSlider.value, 10) || 50));
|
||
agentMapDraft.secondBrainWeight = w;
|
||
const hint = document.getElementById('agentMapMixHint');
|
||
if (hint) hint.textContent = fmtMixHint(w);
|
||
});
|
||
}
|
||
|
||
editAgentBtn.onclick = () => {
|
||
if (agentSel.value === 'none') return;
|
||
editMode = !editMode;
|
||
editAgentBtn.classList.toggle('active', editMode);
|
||
agentConfigPanel.style.display = editMode ? 'flex' : 'none';
|
||
};
|
||
|
||
updateAgentBtn.onclick = () => {
|
||
if (agentSel.value !== 'none') {
|
||
vscode.postMessage({
|
||
type: 'updateAgent',
|
||
path: agentSel.value,
|
||
content: agentPrompt.value,
|
||
negativePrompt: negativePrompt.value.trim()
|
||
});
|
||
}
|
||
};
|
||
|
||
addAgentBtn.onclick = () => vscode.postMessage({ type: 'createAgent' });
|
||
deleteAgentBtn.onclick = () => {
|
||
if (agentSel.value === 'none') return;
|
||
vscode.postMessage({ type: 'deleteAgent', path: agentSel.value });
|
||
};
|
||
|
||
document.getElementById('addDesignerBtn').onclick = () => vscode.postMessage({ type: 'createChronicleProject' });
|
||
document.getElementById('openDesignerBtn').onclick = () => vscode.postMessage({ type: 'openChronicleFolder' });
|
||
document.getElementById('refreshChronicleRecordsBtn').onclick = () => vscode.postMessage({ type: 'getChronicleRecords' });
|
||
document.getElementById('openChronicleRecordBtn').onclick = () => {
|
||
if (!chronicleRecordSel.value) return;
|
||
vscode.postMessage({ type: 'openChronicleRecord', path: chronicleRecordSel.value });
|
||
};
|
||
|
||
// ── Header dropdowns (Tools ▾ / Edit ▾ / Records ▾) ──────────────────
|
||
function closeAllDropdowns(except) {
|
||
document.querySelectorAll('.hdr-menu.open').forEach(m => { if (m !== except) m.classList.remove('open'); });
|
||
}
|
||
document.querySelectorAll('[data-dd]').forEach(dd => {
|
||
const trigger = dd.querySelector('[data-dd-trigger]');
|
||
const menu = dd.querySelector('[data-dd-menu]');
|
||
if (!trigger || !menu) return;
|
||
trigger.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const willOpen = !menu.classList.contains('open');
|
||
closeAllDropdowns(menu);
|
||
menu.classList.toggle('open', willOpen);
|
||
});
|
||
// Clicks inside the menu shouldn't bubble to the document (which would close it). A click
|
||
// on a <button> means "I picked something" → close after its own handler runs; a <select>
|
||
// (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 — 클릭해서 켜기',
|
||
);
|
||
};
|
||
|
||
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' });
|
||
};
|
||
}
|
||
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,
|
||
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 = '체크하면 단계 완료 후 자동 진행하지 않고 승인/수정요청/중단 버튼이 채팅창에 뜹니다';
|
||
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 = '감지할 표현:';
|
||
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,
|
||
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 검증: 라벨 + 담당 에이전트 필수.
|
||
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,
|
||
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;
|
||
}),
|
||
};
|
||
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 = '<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="">대표가 알아서 분배</option>';
|
||
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 = `<strong>${escAttr(p.name || p.id)}</strong><span>${(p.stages || []).length}단계${p.id === activeId ? ' · 현재 사용 중' : ''}</span>`;
|
||
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;
|
||
// 템플릿 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 `<select>` from the master `#modelSel`
|
||
* options. Mirrors the pattern used by `refreshAgentMapModelOptions`
|
||
* so the user picks from the same canonical list everywhere.
|
||
* Empty value = "default (global)". Saved overrides not in the list
|
||
* are preserved as a "(saved)" option so the value never gets lost.
|
||
*/
|
||
function populateAgentModelSelect(sel, current) {
|
||
sel.innerHTML = '';
|
||
const useDefault = document.createElement('option');
|
||
useDefault.value = '';
|
||
useDefault.innerText = '기본 모델 사용';
|
||
sel.appendChild(useDefault);
|
||
const seen = new Set();
|
||
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 (current && !seen.has(current)) {
|
||
const o = document.createElement('option');
|
||
o.value = current;
|
||
o.innerText = `${current} (저장됨)`;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.value = current || '';
|
||
}
|
||
|
||
/**
|
||
* Render the agent cards in the manage overlay. Each card has:
|
||
* - a model dropdown (default + every loaded model)
|
||
* - an ON/OFF toggle (CEO always-on)
|
||
* - an Edit button that toggles an inline prompt editor with
|
||
* tagline / specialty / persona textareas + Reset/Save/Cancel.
|
||
*/
|
||
// 직군 라벨/순서 캐시. 페이로드에 같이 오므로 첫 렌더 후엔 항상 채워져 있음.
|
||
// pipeline 에디터(다음 phase)도 같은 캐시를 본다.
|
||
let _roleCategoryLabels = {
|
||
ceo: 'CEO', planner: '기획', researcher: '리서치', designer: '디자인',
|
||
developer: '개발', qa: 'QA', inspector: '감리', support: '지원',
|
||
};
|
||
let _roleCategoryOrder = ['ceo','planner','researcher','designer','developer','qa','inspector','support'];
|
||
window.__getRoleLabels = () => _roleCategoryLabels;
|
||
window.__getRoleOrder = () => _roleCategoryOrder;
|
||
|
||
function renderCompanyAgentCards(payload) {
|
||
if (!_companyAgentList) return;
|
||
_lastCompanyAgentsPayload = payload;
|
||
_companyAgentList.innerHTML = '';
|
||
if (_companyNameInput && payload && typeof payload.companyName === 'string') {
|
||
_companyNameInput.value = payload.companyName;
|
||
}
|
||
if (payload && payload.roleCategoryLabels) _roleCategoryLabels = payload.roleCategoryLabels;
|
||
if (payload && Array.isArray(payload.roleCategoryOrder)) _roleCategoryOrder = payload.roleCategoryOrder;
|
||
const rawAgents = (payload && Array.isArray(payload.agents)) ? payload.agents : [];
|
||
// 활성/비활성 정렬: CEO는 항상 최상단(alwaysOn), 그 다음 활성(켜짐) 에이전트,
|
||
// 마지막으로 비활성. 같은 그룹 내에서는 백엔드가 보낸 원래 순서를 유지(stable).
|
||
const agents = rawAgents
|
||
.map((a, i) => ({ a, i }))
|
||
.sort((x, y) => {
|
||
// CEO/alwaysOn 최우선
|
||
const xLocked = x.a.alwaysOn ? 0 : 1;
|
||
const yLocked = y.a.alwaysOn ? 0 : 1;
|
||
if (xLocked !== yLocked) return xLocked - yLocked;
|
||
// 그 다음 active 여부
|
||
const xAct = x.a.active ? 0 : 1;
|
||
const yAct = y.a.active ? 0 : 1;
|
||
if (xAct !== yAct) return xAct - yAct;
|
||
// tiebreak: 원래 순서
|
||
return x.i - y.i;
|
||
})
|
||
.map((p) => p.a);
|
||
let sectionKey = '';
|
||
const appendTeamSection = (key, title, hint) => {
|
||
if (sectionKey === key) return;
|
||
sectionKey = key;
|
||
const section = document.createElement('li');
|
||
section.className = 'company-agent-section-label';
|
||
// 시각적 그룹핑용 라벨 — 스크린리더는 카드 갯수만 세도록 presentation 처리.
|
||
section.setAttribute('role', 'presentation');
|
||
section.setAttribute('aria-hidden', 'true');
|
||
section.innerHTML = `<span>${escAttr(title)}</span><small>${escAttr(hint)}</small>`;
|
||
_companyAgentList.appendChild(section);
|
||
};
|
||
for (const a of agents) {
|
||
if (a.alwaysOn || a.active) {
|
||
appendTeamSection('active', '참여 중', '이번 작업에 함께 응답합니다');
|
||
} else {
|
||
appendTeamSection('standby', '대기 중', '필요할 때 팀에 합류시킬 수 있습니다');
|
||
}
|
||
const li = document.createElement('li');
|
||
li.className = 'company-agent-card';
|
||
li.setAttribute('data-active', a.active ? 'true' : 'false');
|
||
if (a.alwaysOn) li.setAttribute('data-locked', 'true');
|
||
li.dataset.agentId = a.id;
|
||
li.style.setProperty('--agent-color', a.color || 'var(--accent)');
|
||
|
||
// ── Row 1: emoji + name/tagline + controls ──
|
||
// CSS handles layout via `.company-agent-head` (flex-wrap,
|
||
// gap, etc.) so we don't repeat inline styles here.
|
||
const row = document.createElement('div');
|
||
row.className = 'company-agent-head';
|
||
|
||
const emoji = document.createElement('span');
|
||
emoji.className = 'company-agent-emoji';
|
||
emoji.textContent = a.emoji;
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'company-agent-body';
|
||
const name = document.createElement('div');
|
||
name.className = 'company-agent-name';
|
||
name.innerHTML = `<span>${escAttr(a.name)}</span><span class="company-agent-role">${escAttr(a.role)}</span>`;
|
||
const tag = document.createElement('div');
|
||
tag.className = 'company-agent-tagline';
|
||
tag.textContent = a.tagline || '';
|
||
tag.title = a.specialty || '';
|
||
body.appendChild(name);
|
||
body.appendChild(tag);
|
||
const meta = document.createElement('div');
|
||
meta.className = 'company-agent-meta';
|
||
const roleChip = document.createElement('span');
|
||
roleChip.className = 'company-agent-chip';
|
||
roleChip.textContent = _roleCategoryLabels[a.roleCategory || a.defaultRoleCategory] || a.roleCategory || a.defaultRoleCategory || '역할';
|
||
const modelChip = document.createElement('span');
|
||
modelChip.className = 'company-agent-chip';
|
||
modelChip.textContent = a.modelOverride ? '전용 모델' : '기본 모델';
|
||
meta.appendChild(roleChip);
|
||
meta.appendChild(modelChip);
|
||
if (a.personaOverridden || a.specialtyOverridden || a.taglineOverridden || a.nameOverridden || a.roleOverridden) {
|
||
const tunedChip = document.createElement('span');
|
||
tunedChip.className = 'company-agent-chip tuned';
|
||
tunedChip.textContent = '커스텀';
|
||
meta.appendChild(tunedChip);
|
||
}
|
||
body.appendChild(meta);
|
||
|
||
const controls = document.createElement('div');
|
||
controls.className = 'company-agent-controls';
|
||
|
||
// 직군 select — CEO는 잠금. role 라벨 보다 먼저 와서 시선 흐름이
|
||
// "이 사람은 [직군] 직군"으로 자연스럽게 읽히도록 controls 첫번째에.
|
||
const roleSelEl = document.createElement('select');
|
||
roleSelEl.className = 'company-agent-role-select';
|
||
roleSelEl.title = '이 에이전트의 직군. 파이프라인 에디터에서 직군별로 담당자를 고를 때 사용됩니다.';
|
||
if (a.id === 'ceo') {
|
||
const opt = document.createElement('option');
|
||
opt.value = 'ceo'; opt.textContent = _roleCategoryLabels.ceo || 'CEO';
|
||
roleSelEl.appendChild(opt);
|
||
roleSelEl.value = 'ceo';
|
||
roleSelEl.disabled = true;
|
||
} else {
|
||
for (const cat of _roleCategoryOrder) {
|
||
if (cat === 'ceo') continue;
|
||
const opt = document.createElement('option');
|
||
opt.value = cat;
|
||
opt.textContent = _roleCategoryLabels[cat] || cat;
|
||
roleSelEl.appendChild(opt);
|
||
}
|
||
roleSelEl.value = a.roleCategory || a.defaultRoleCategory || 'support';
|
||
if (a.roleCategoryOverridden) roleSelEl.classList.add('overridden');
|
||
roleSelEl.onchange = () => {
|
||
const v = roleSelEl.value;
|
||
const sendValue = v === a.defaultRoleCategory ? null : v;
|
||
vscode.postMessage({
|
||
type: 'setCompanyAgentRoleCategory',
|
||
agentId: a.id,
|
||
value: sendValue,
|
||
});
|
||
};
|
||
}
|
||
|
||
const modelSelEl = document.createElement('select');
|
||
modelSelEl.className = 'company-agent-model';
|
||
modelSelEl.title = '비워두면 글로벌 기본 모델 사용';
|
||
populateAgentModelSelect(modelSelEl, a.modelOverride || '');
|
||
modelSelEl.onchange = () => {
|
||
vscode.postMessage({
|
||
type: 'setCompanyAgentModel',
|
||
agentId: a.id,
|
||
model: modelSelEl.value || '',
|
||
});
|
||
};
|
||
|
||
const editBtn = document.createElement('button');
|
||
editBtn.className = 'company-agent-edit';
|
||
editBtn.textContent = '프로필';
|
||
if (a.personaOverridden || a.specialtyOverridden || a.taglineOverridden) {
|
||
editBtn.classList.add('dirty');
|
||
editBtn.title = '프로필과 응답 방식이 사용자에 의해 조정되었습니다';
|
||
} else {
|
||
editBtn.title = '이름, 역할, 잘하는 일, 응답 스타일 편집';
|
||
}
|
||
editBtn.onclick = () => {
|
||
const expanded = li.getAttribute('data-expanded') === 'true';
|
||
li.setAttribute('data-expanded', expanded ? 'false' : 'true');
|
||
// 카드가 갑자기 두 배로 길어지지 않게 다른 패널은 자동으로 접는다.
|
||
li.setAttribute('data-settings-expanded', 'false');
|
||
};
|
||
const settingsBtn = document.createElement('button');
|
||
settingsBtn.className = 'company-agent-edit company-agent-settings-btn';
|
||
settingsBtn.textContent = '설정';
|
||
settingsBtn.title = '역할 그룹, 모델, 참고 지식 반영 조정';
|
||
settingsBtn.onclick = () => {
|
||
const expanded = li.getAttribute('data-settings-expanded') === 'true';
|
||
li.setAttribute('data-settings-expanded', expanded ? 'false' : 'true');
|
||
li.setAttribute('data-expanded', 'false');
|
||
};
|
||
|
||
const toggle = document.createElement('button');
|
||
toggle.className = 'company-agent-toggle';
|
||
toggle.textContent = a.active ? '참여 중' : '+ 참여';
|
||
if (a.alwaysOn) {
|
||
toggle.disabled = true;
|
||
toggle.textContent = '항상 참여';
|
||
} else {
|
||
toggle.onclick = () => {
|
||
const wantActive = !(li.getAttribute('data-active') === 'true');
|
||
// ── Optimistic update ──
|
||
// 백엔드 응답이 오기 전에 카드를 옳은 섹션(참여 중 / 대기 중)으로
|
||
// 즉시 이동시켜야 라벨과 카드 위치가 어긋난 상태로 머무르지 않는다.
|
||
// 캐시된 페이로드의 active 플래그를 갱신한 뒤 같은 페이로드로 한 번 더
|
||
// 렌더하면 sort + 섹션 라벨이 새 상태대로 재계산됨.
|
||
if (_lastCompanyAgentsPayload && Array.isArray(_lastCompanyAgentsPayload.agents)) {
|
||
const found = _lastCompanyAgentsPayload.agents.find((x) => x.id === a.id);
|
||
if (found) found.active = wantActive;
|
||
}
|
||
const nextIds = (_lastCompanyAgentsPayload?.agents || [])
|
||
.filter((x) => x.active || x.alwaysOn)
|
||
.map((x) => x.id)
|
||
.filter(Boolean);
|
||
// 백엔드 알림은 먼저 보내고 (네트워크 latency 흡수), 그 직후 동기 재렌더로
|
||
// UI를 정렬. 백엔드 응답이 오면 같은 결과로 한 번 더 그려지지만 idempotent.
|
||
vscode.postMessage({ type: 'setCompanyActiveAgents', value: nextIds });
|
||
if (_lastCompanyAgentsPayload) renderCompanyAgentCards(_lastCompanyAgentsPayload);
|
||
};
|
||
}
|
||
controls.appendChild(editBtn);
|
||
controls.appendChild(settingsBtn);
|
||
// 삭제 버튼 — CEO만 빼고 빌트인/커스텀 모두 노출. 단, 어떤 워크 파이프라인의
|
||
// stage라도 이 에이전트를 참조하고 있으면 disabled로 처리하고 tooltip에
|
||
// 사용 중인 파이프라인을 적어, 사용자가 어디로 가서 빼야 하는지 보이게 한다.
|
||
if (!a.alwaysOn) {
|
||
const delBtn = document.createElement('button');
|
||
delBtn.className = 'company-agent-edit company-agent-delete';
|
||
delBtn.textContent = a.custom ? '삭제' : '숨김';
|
||
const usedIn = Array.isArray(a.usedInPipelines) ? a.usedInPipelines : [];
|
||
if (usedIn.length > 0) {
|
||
delBtn.disabled = true;
|
||
delBtn.classList.add('disabled');
|
||
delBtn.title = `숨길 수 없음 — 다음 작업 흐름에서 사용 중: ${usedIn.map((n) => `'${n}'`).join(', ')}. 작업 흐름에서 먼저 빼거나 다른 팀원으로 교체하세요.`;
|
||
} else {
|
||
delBtn.title = a.custom
|
||
? `'${a.name}' 팀원 삭제 (모든 설정 함께 제거)`
|
||
: `'${a.name}' 팀원 숨기기 (기본 팀원이라 복원 가능)`;
|
||
delBtn.onclick = () => {
|
||
const confirmMsg = a.custom
|
||
? `'${a.name}' 팀원을 삭제할까요? 이 팀원의 모든 설정(모델·응답 방식·지식 반영)도 함께 삭제됩니다.`
|
||
: `기본 팀원 '${a.name}'을(를) 목록에서 숨길까요? 나중에 [목록 맨 아래 → 숨긴 기본 팀원] 영역에서 복원할 수 있습니다.`;
|
||
if (!confirm(confirmMsg)) return;
|
||
vscode.postMessage({ type: 'deleteCompanyAgent', agentId: a.id });
|
||
};
|
||
}
|
||
controls.appendChild(delBtn);
|
||
}
|
||
controls.appendChild(toggle);
|
||
|
||
row.appendChild(emoji);
|
||
row.appendChild(body);
|
||
row.appendChild(controls);
|
||
li.appendChild(row);
|
||
|
||
const settings = document.createElement('div');
|
||
settings.className = 'company-agent-settings';
|
||
const settingsGrid = document.createElement('div');
|
||
settingsGrid.className = 'company-agent-settings-grid';
|
||
const roleWrap = document.createElement('label');
|
||
roleWrap.textContent = '역할 그룹';
|
||
roleWrap.appendChild(roleSelEl);
|
||
const modelWrap = document.createElement('label');
|
||
modelWrap.textContent = '모델';
|
||
modelWrap.appendChild(modelSelEl);
|
||
settingsGrid.appendChild(roleWrap);
|
||
settingsGrid.appendChild(modelWrap);
|
||
settings.appendChild(settingsGrid);
|
||
// CEO doesn't dispatch agents itself, it only synthesises,
|
||
// so the brain mix for CEO turns is governed by specialists.
|
||
if (a.id !== 'ceo') {
|
||
settings.appendChild(_buildAgentKnowledgeMixSlider(a, payload.globalKnowledgeMixWeight));
|
||
}
|
||
li.appendChild(settings);
|
||
|
||
// ── Row 2 (collapsed by default): prompt editor ──
|
||
li.appendChild(_buildAgentPromptEditor(a));
|
||
_companyAgentList.appendChild(li);
|
||
}
|
||
// ── 숨겨진 기본 에이전트 복원 영역 ──
|
||
// 사용자가 "삭제"한 빌트인 에이전트를 다시 꺼낼 수 있는 출구. 빈 배열이면
|
||
// 섹션 자체를 그리지 않아 평소엔 시야에서 사라진다.
|
||
const hidden = Array.isArray(payload && payload.hiddenBuiltins) ? payload.hiddenBuiltins : [];
|
||
if (hidden.length > 0) {
|
||
const restoreLi = document.createElement('li');
|
||
restoreLi.className = 'company-agent-hidden-section';
|
||
const head = document.createElement('div');
|
||
head.className = 'company-agent-hidden-head';
|
||
head.textContent = `숨긴 기본 팀원 (${hidden.length}명)`;
|
||
restoreLi.appendChild(head);
|
||
const hint = document.createElement('div');
|
||
hint.className = 'company-agent-hidden-hint';
|
||
hint.textContent = '복원하면 목록에 다시 추가됩니다. 설정은 모두 디폴트로 시작합니다.';
|
||
restoreLi.appendChild(hint);
|
||
const list = document.createElement('div');
|
||
list.className = 'company-agent-hidden-list';
|
||
for (const h of hidden) {
|
||
const chip = document.createElement('button');
|
||
chip.className = 'company-agent-hidden-chip';
|
||
chip.textContent = `${h.emoji || '🙂'} ${h.name} · ${h.role} ↩ 복원`;
|
||
chip.title = `'${h.name}' 복원`;
|
||
chip.onclick = () => {
|
||
vscode.postMessage({ type: 'restoreHiddenAgent', agentId: h.id });
|
||
};
|
||
list.appendChild(chip);
|
||
}
|
||
restoreLi.appendChild(list);
|
||
_companyAgentList.appendChild(restoreLi);
|
||
}
|
||
if (_companyStatusEl) _companyStatusEl.textContent = '';
|
||
}
|
||
|
||
/**
|
||
* Inline Knowledge Mix slider for one agent. Empty (override = null)
|
||
* means "use the global slider value" — shown as a hint label so the
|
||
* user knows what they'll fall back to. Slider commits on `change`
|
||
* (not `input`) so dragging doesn't spam writes.
|
||
*/
|
||
function _buildAgentKnowledgeMixSlider(a, globalWeight) {
|
||
const row = document.createElement('div');
|
||
row.className = 'company-agent-mix-row';
|
||
const usingOverride = a.knowledgeMixOverride !== null && a.knowledgeMixOverride !== undefined;
|
||
const effective = a.effectiveKnowledgeMixWeight;
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'company-agent-mix-label';
|
||
label.textContent = '참고 지식 반영';
|
||
|
||
const sourceBadge = document.createElement('span');
|
||
sourceBadge.className = 'company-agent-mix-source' + (usingOverride ? ' override' : '');
|
||
sourceBadge.textContent = usingOverride ? '직접 조정' : '전체 설정';
|
||
|
||
const slider = document.createElement('input');
|
||
slider.type = 'range';
|
||
slider.min = '0'; slider.max = '100'; slider.step = '5';
|
||
slider.value = String(effective);
|
||
slider.disabled = !usingOverride;
|
||
slider.className = 'company-agent-mix-slider';
|
||
|
||
const hint = document.createElement('span');
|
||
hint.className = 'company-agent-mix-hint';
|
||
const renderHint = () => {
|
||
const w = parseInt(slider.value, 10) || 50;
|
||
hint.textContent = `${w}%`;
|
||
};
|
||
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox'; cb.checked = !usingOverride;
|
||
cb.className = 'company-agent-mix-cb';
|
||
cb.title = '전체 참고 지식 설정 사용';
|
||
cb.onchange = () => {
|
||
if (cb.checked) {
|
||
// Reset to global.
|
||
slider.disabled = true;
|
||
slider.value = String(globalWeight ?? 50);
|
||
sourceBadge.textContent = '전체 설정';
|
||
sourceBadge.classList.remove('override');
|
||
renderHint();
|
||
vscode.postMessage({
|
||
type: 'setCompanyAgentKnowledgeMix',
|
||
agentId: a.id, value: null,
|
||
});
|
||
} else {
|
||
// Take ownership at the current displayed value.
|
||
slider.disabled = false;
|
||
sourceBadge.textContent = '직접 조정';
|
||
sourceBadge.classList.add('override');
|
||
const w = parseInt(slider.value, 10) || 50;
|
||
vscode.postMessage({
|
||
type: 'setCompanyAgentKnowledgeMix',
|
||
agentId: a.id, value: w,
|
||
});
|
||
}
|
||
};
|
||
slider.addEventListener('input', renderHint);
|
||
slider.addEventListener('change', () => {
|
||
if (cb.checked) return; // safety: shouldn't fire when disabled
|
||
const w = parseInt(slider.value, 10) || 50;
|
||
vscode.postMessage({
|
||
type: 'setCompanyAgentKnowledgeMix',
|
||
agentId: a.id, value: w,
|
||
});
|
||
});
|
||
renderHint();
|
||
const cbWrap = document.createElement('label');
|
||
cbWrap.className = 'company-agent-mix-cbwrap';
|
||
cbWrap.appendChild(cb);
|
||
cbWrap.appendChild(document.createTextNode(' 전체 설정 사용'));
|
||
row.appendChild(label);
|
||
row.appendChild(sourceBadge);
|
||
row.appendChild(slider);
|
||
row.appendChild(hint);
|
||
row.appendChild(cbWrap);
|
||
return row;
|
||
}
|
||
|
||
/**
|
||
* Build the per-agent prompt editor. Hidden until the user clicks
|
||
* the Edit button. Three fields (tagline / specialty / persona);
|
||
* Save sends whichever fields actually changed; Reset sends `null`
|
||
* to wipe all overrides at once.
|
||
*/
|
||
function _buildAgentPromptEditor(a) {
|
||
const editor = document.createElement('div');
|
||
editor.className = 'company-agent-editor';
|
||
|
||
const _field = (key, labelText, isTextarea, current, defaultVal, overridden) => {
|
||
const lbl = document.createElement('label');
|
||
lbl.className = 'field-label';
|
||
lbl.innerHTML = `<span>${labelText}</span>` +
|
||
(overridden
|
||
? '<span class="field-flag">조정됨</span>'
|
||
: '<span class="field-flag" style="color:var(--text-dim)">기본값</span>');
|
||
editor.appendChild(lbl);
|
||
const el = isTextarea
|
||
? document.createElement('textarea')
|
||
: document.createElement('input');
|
||
if (!isTextarea) el.type = 'text';
|
||
el.value = current || '';
|
||
el.placeholder = defaultVal || '';
|
||
editor.appendChild(el);
|
||
return el;
|
||
};
|
||
|
||
// ── 디스플레이 필드 (이름·역할·이모지·색상) ──
|
||
// 빌트인이든 커스텀이든 사용자가 자유롭게 리네이밍 가능. 변경 후
|
||
// CEO 보고서·planner enumeration·세션 로그 등 모든 표시 지점에
|
||
// 즉시 반영된다 — resolveAgent가 override를 머지하므로.
|
||
const nameInput = _field('name', '이름', false, a.name, a.defaultName, a.nameOverridden);
|
||
const roleInput = _field('role', '역할 소개', false, a.role, a.defaultRole, a.roleOverridden);
|
||
// 이모지·색상은 한 줄에 나란히 — CSS는 grid 없이 inline flex로 처리.
|
||
const visualWrap = document.createElement('div');
|
||
visualWrap.style.cssText = 'display:flex; gap:8px;';
|
||
const emojiInput = _field('emoji', '이모지', false, a.emoji, a.defaultEmoji, a.emojiOverridden);
|
||
const colorInput = _field('color', '테마 색상', false, a.color, a.defaultColor, a.colorOverridden);
|
||
// 위에서 만든 두 필드는 editor에 이미 append됨. 한 줄로 묶고 싶으면
|
||
// 부모에서 분리해 visualWrap에 다시 넣는다 — label은 직전 sibling.
|
||
// 더 단순하게: emoji/color 입력 후 reflow는 그냥 두고 max-width만 줄임.
|
||
emojiInput.style.maxWidth = '80px';
|
||
colorInput.style.maxWidth = '120px';
|
||
|
||
const tagInput = _field('tagline', '한 줄 소개', false, a.tagline, a.defaultTagline, a.taglineOverridden);
|
||
const specInput = _field('specialty', '잘하는 일', true, a.specialty, a.defaultSpecialty, a.specialtyOverridden);
|
||
const persInput = _field('persona', '응답 스타일', true, a.persona, a.defaultPersona, a.personaOverridden);
|
||
specInput.rows = 3;
|
||
persInput.rows = 5;
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'editor-actions';
|
||
|
||
const resetBtn = document.createElement('button');
|
||
resetBtn.className = 'danger';
|
||
resetBtn.textContent = '기본값으로';
|
||
resetBtn.title = '이 에이전트의 모든 사용자 변경 사항 제거 → 디폴트로 복귀 (이름·역할·프롬프트 전부)';
|
||
resetBtn.onclick = () => {
|
||
vscode.postMessage({ type: 'setCompanyAgentPrompt', agentId: a.id, override: null });
|
||
vscode.postMessage({ type: 'setCompanyAgentDisplay', agentId: a.id, override: null });
|
||
};
|
||
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.textContent = '취소';
|
||
cancelBtn.onclick = () => {
|
||
const card = editor.closest('.company-agent-card');
|
||
if (card) card.setAttribute('data-expanded', 'false');
|
||
};
|
||
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.className = 'primary';
|
||
saveBtn.textContent = '저장';
|
||
saveBtn.onclick = () => {
|
||
// 두 개의 message로 분리 전송 — display(name/role/emoji/color)와
|
||
// prompt(tagline/specialty/persona)는 백엔드에서 서로 다른
|
||
// override 테이블을 쓴다. 빈 문자열은 그 필드만 reset, 디폴트와
|
||
// 같은 값은 override 안 함(원본 그대로 두기) — 사용자가 일부러
|
||
// 디폴트 값을 다시 입력해 "override 박제"하는 케이스는 드물고,
|
||
// 안 박는 게 다음 코드 업데이트 시 새 디폴트를 자동 흡수해서
|
||
// 유리하다.
|
||
vscode.postMessage({
|
||
type: 'setCompanyAgentDisplay',
|
||
agentId: a.id,
|
||
override: {
|
||
name: nameInput.value === a.defaultName ? '' : nameInput.value,
|
||
role: roleInput.value === a.defaultRole ? '' : roleInput.value,
|
||
emoji: emojiInput.value === a.defaultEmoji ? '' : emojiInput.value,
|
||
color: colorInput.value === a.defaultColor ? '' : colorInput.value,
|
||
},
|
||
});
|
||
vscode.postMessage({
|
||
type: 'setCompanyAgentPrompt',
|
||
agentId: a.id,
|
||
override: {
|
||
tagline: tagInput.value === a.defaultTagline ? '' : tagInput.value,
|
||
specialty: specInput.value === a.defaultSpecialty ? '' : specInput.value,
|
||
persona: persInput.value === a.defaultPersona ? '' : persInput.value,
|
||
},
|
||
});
|
||
};
|
||
|
||
actions.appendChild(resetBtn);
|
||
actions.appendChild(cancelBtn);
|
||
actions.appendChild(saveBtn);
|
||
editor.appendChild(actions);
|
||
return editor;
|
||
}
|
||
|
||
/**
|
||
* Render one phase event from the dispatcher. The chat gets a
|
||
* card per phase so the user can follow progress in real time —
|
||
* "🧭 CEO 작업 분배 중..." → "📺 레오 작업 수행 중..." → final report.
|
||
*/
|
||
function renderCompanyPhase(ev) {
|
||
const chatEl = document.getElementById('chat');
|
||
if (!chatEl) return;
|
||
const card = document.createElement('div');
|
||
card.className = 'company-phase-card';
|
||
if (ev.phase === 'plan-start') {
|
||
card.innerHTML = '<div class="cph-head">🧭 CEO</div><div class="cph-meta">작업 분배 중…</div>';
|
||
} else if (ev.phase === 'plan-ready') {
|
||
const tasks = (ev.plan?.tasks || []).map((t, i) => `${i + 1}. <strong>${escAttr(t.agent)}</strong> — ${escAttr(t.task)}`).join('<br>');
|
||
card.innerHTML = `<div class="cph-head">🧭 CEO 브리프</div>
|
||
<div>${escAttr(ev.plan?.brief || '(brief 없음)')}</div>
|
||
<div class="cph-meta" style="margin-top:6px">${tasks || '(no tasks — chat reply)'}</div>`;
|
||
} else if (ev.phase === 'agent-start') {
|
||
card.innerHTML = `<div class="cph-head">${escAttr(ev.agentId)} 작업 수행 중…</div>
|
||
<div class="cph-meta">${escAttr(ev.task)} <em>(${ev.index + 1}/${ev.total})</em></div>`;
|
||
} else if (ev.phase === 'agent-done') {
|
||
const o = ev.output || {};
|
||
const body = (o.response || '').slice(0, 4000);
|
||
card.innerHTML = `<div class="cph-head">${escAttr(ev.agentId)} 완료 <span class="cph-meta">${(o.durationMs/1000).toFixed(1)}s${o.error ? ' · ⚠️ ' + escAttr(o.error) : ''}</span></div>
|
||
<div class="markdown-body">${fmt(body)}</div>`;
|
||
} 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') {
|
||
card.className += ' report';
|
||
card.innerHTML = `<div class="cph-head">🧭 CEO 보고서${ev.ok ? '' : ' (fallback)'}</div>
|
||
<div class="markdown-body">${fmt(ev.report || '')}</div>`;
|
||
} else if (ev.phase === 'telegram-mirror') {
|
||
// Reflect whether the secretary actually mirrored the round
|
||
// to Telegram. `ok === null` = the user hasn't opted in to
|
||
// Telegram at all (no token / chat id / enabled). We render
|
||
// that as a quiet line instead of an error to avoid nagging.
|
||
if (ev.ok === true) {
|
||
card.innerHTML = '<div class="cph-meta">📱 영숙이 텔레그램에 보고 완료</div>';
|
||
} else if (ev.ok === false) {
|
||
card.innerHTML = `<div class="cph-meta">⚠️ 텔레그램 보고 실패${ev.reason ? ` — ${escAttr(ev.reason)}` : ''}</div>`;
|
||
} else {
|
||
// null → not configured. Skip rendering entirely to keep chat clean.
|
||
return;
|
||
}
|
||
} else if (ev.phase === 'session-saved') {
|
||
card.innerHTML = `<div class="cph-meta">세션 저장 완료 — 클릭하여 열기</div>`;
|
||
card.style.cursor = 'pointer';
|
||
card.onclick = () => vscode.postMessage({ type: 'openCompanySession', sessionDir: ev.sessionDir });
|
||
} else if (ev.phase === 'aborted') {
|
||
card.innerHTML = `<div class="cph-head">⛔ 회사 모드 중단</div><div class="cph-meta">${escAttr(ev.reason)}</div>`;
|
||
}
|
||
chatEl.appendChild(card);
|
||
chatEl.scrollTop = chatEl.scrollHeight;
|
||
}
|
||
|
||
// ── Knowledge Mix: global slider ──────────────────────────────────────
|
||
// Mirrors `g1nation.knowledgeMix.secondBrainWeight`. The hint label updates
|
||
// live as the user drags; the value is committed (postMessage) on `change`
|
||
// so we don't spam settings updates while scrubbing.
|
||
const knowledgeMixSlider = document.getElementById('knowledgeMixSlider');
|
||
const knowledgeMixHint = document.getElementById('knowledgeMixHint');
|
||
const renderGlobalMixHint = () => {
|
||
if (!knowledgeMixSlider || !knowledgeMixHint) return;
|
||
knowledgeMixHint.textContent = fmtMixHint(parseInt(knowledgeMixSlider.value, 10) || 50);
|
||
};
|
||
if (knowledgeMixSlider) {
|
||
knowledgeMixSlider.addEventListener('input', renderGlobalMixHint);
|
||
knowledgeMixSlider.addEventListener('change', () => {
|
||
const w = Math.max(0, Math.min(100, parseInt(knowledgeMixSlider.value, 10) || 50));
|
||
vscode.postMessage({ type: 'setKnowledgeMix', value: w });
|
||
});
|
||
renderGlobalMixHint();
|
||
}
|
||
|
||
// --- Proactive Behavioral Tracking ---
|
||
let hoverTimer = null;
|
||
const trackBehavior = (elementId, context) => {
|
||
const el = document.getElementById(elementId);
|
||
if (!el) return;
|
||
el.addEventListener('mouseenter', () => {
|
||
hoverTimer = setTimeout(() => {
|
||
vscode.postMessage({ type: 'proactiveTrigger', context: context });
|
||
}, 5000); // 5 seconds threshold
|
||
});
|
||
el.addEventListener('mouseleave', () => {
|
||
if (hoverTimer) clearTimeout(hoverTimer);
|
||
});
|
||
};
|
||
|
||
trackBehavior('settingsBtn', 'settings_exploration');
|
||
trackBehavior('brainBtn', 'brain_sync_exploration');
|
||
trackBehavior('agentSel', 'agent_selection_exploration');
|