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 `Model ${100 - w}% · Brain ${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) || '—'; } function syncRecordsLine() { if (!recordsLatest) return; const opt = chronicleRecordSel && chronicleRecordSel.value ? selText(chronicleRecordSel) : ''; recordsLatest.textContent = opt ? '· ' + truncMid(opt, 38) : ''; } // ── Ready-status bar (Engine / Model / Brain count / Context / Memory) ── let readyState = {}; function renderReadyBar() { if (!readyBar || !rbContent) return; const s = readyState; const segs = []; if (s.engine) { const on = s.engine.online; const tag = on === true ? 'Online' : on === false ? 'Offline' : '확인 중'; segs.push(`${escAttr(tag)}`); } if (s.model && s.model.name) { const loaded = s.model.loaded; segs.push(`${escAttr(shortModel(s.model.name))}`); } if (s.brain && typeof s.brain.files === 'number') { segs.push(`Brain ${s.brain.files}`); } if (typeof s.contextLength === 'number') { if (s.cappedForSmallModel) { segs.push(`ctx ${fmtK(s.contextLength)} · 소형모델 제한`); } else { segs.push(`ctx ${fmtK(s.contextLength)}`); } } segs.push(`메모리 ${s.memory ? 'On' : 'Off'}`); if (s.multiAgent) segs.push(`멀티에이전트`); rbContent.innerHTML = segs.join('·'); if (rbDot) { const on = s.engine && s.engine.online; rbDot.className = 'rb-dot ' + (on === true ? 'ok' : on === false ? 'bad' : 'warn'); } } // ── Context-budget badge (직전 요청 기준) ──────────────────────────── function renderCtxBadge(b) { if (!ctxBadge) return; if (!b || typeof b.inputTokens !== 'number') { ctxBadge.textContent = ''; ctxBadge.className = 'ctx-badge'; ctxBadge.title = ''; return; } const parts = [`≈${fmtK(b.inputTokens)} in / ${fmtK(b.maxOutputTokens)} out`]; if (typeof b.contextLength === 'number') { parts.push(b.cappedForSmallModel ? `ctx ${fmtK(b.contextLength)}↓` : `ctx ${fmtK(b.contextLength)}`); } if (typeof b.brainFiles === 'number' && b.brainFiles > 0) parts.push(`Brain ${b.brainFiles}`); if (b.includesOpenFile) parts.push('📄 열린 파일'); if (b.imageCount > 0) parts.push(`🖼 ${b.imageCount}`); if (b.droppedHistory > 0) parts.push(`기록 −${b.droppedHistory}`); if (b.systemTruncated) parts.push('컨텍스트 일부 생략'); if (b.cappedForSmallModel) parts.push('🔻 작은 모델 모드'); if (b.tight) parts.push('⚠ 컨텍스트 거의 가득'); const warn = b.tight || b.systemTruncated; ctxBadge.textContent = parts.join(' · '); ctxBadge.className = 'ctx-badge' + (warn ? ' warn' : ' ok'); ctxBadge.title = `model: ${b.model || ''}${b.paramB != null ? ' (~' + b.paramB + 'B)' : ''}\n입력 ≈ ${b.inputTokens} tokens (시스템 ${b.systemTokens}, 기록 ${b.historyKept}개)\n출력 상한 ${b.maxOutputTokens} tokens / 유효 context window ${b.contextLength} tokens${b.cappedForSmallModel ? ' (작은 모델용 축소; 설정값 ' + b.nominalContextLength + ')' : ''}`; } if (readyBar) { readyBar.addEventListener('click', e => { const t = e.target; if (t && t.dataset && t.dataset.act === 'map') vscode.postMessage({ type: 'editKnowledgeMap' }); }); } // ── "Record a lesson?" prompt (after a rollback / rejected change / repeated complaint) ── function renderLessonCandidate(v) { const t = v && v.trigger; const titleText = t === 'rejected' ? '⚠ 방금 변경을 거부하셨네요 — 같은 실수를 반복하지 않게 교훈으로 기록할까요?' : t === 'qa-feedback' ? '⚠ 같은 문제가 반복되는 것 같습니다 — 교훈으로 기록해두면 다음 작업 전에 자동으로 체크합니다.' : '⚠ 방금 작업이 롤백됐습니다 — 같은 실수를 반복하지 않게 교훈으로 기록할까요?'; const reasonLine = v && v.reason ? `
사유: ${escAttr(String(v.reason))}
` : ''; // Reuse the active (or last) assistant bubble as the anchor; fall back to appending to the chat. let anchor = streamBody && streamBody._parent; if (!anchor) { const all = chat.querySelectorAll('.msg.msg-ai'); anchor = all[all.length - 1]; } const old = (anchor || chat).querySelector('.lesson-candidate-box'); if (old) old.remove(); const box = document.createElement('div'); box.className = 'lesson-candidate-box'; box.innerHTML = `
${escAttr(titleText)}
` + reasonLine + `
`; box.querySelector('.lc-rec').onclick = () => { vscode.postMessage({ type: 'createLessonFromConversation' }); box.remove(); }; box.querySelector('.lc-skip').onclick = () => box.remove(); if (anchor) { const actions = anchor.querySelector('.msg-actions'); if (actions) anchor.insertBefore(box, actions); else anchor.appendChild(box); } else { chat.appendChild(box); } chat.scrollTop = chat.scrollHeight; } // ── Per-answer "scope used" footer ────────────────────────────────── const MEMORY_LAYER_LABELS = { 'long-term-memory': '장기기억', 'project-memory': '프로젝트기억', 'procedural-memory': '절차기억', 'episodic-memory': '에피소드기억', 'project-scan': '프로젝트스캔', 'recent-knowledge': '최근지식', }; function dirOf(rel) { const i = Math.max(rel.lastIndexOf('/'), rel.lastIndexOf('\\')); return i > 0 ? rel.slice(0, i) : '(루트)'; } function renderScopeFooter(target, v) { if (!target) return; const old = target.querySelector('.msg-scope-footer'); if (old) old.remove(); const footer = document.createElement('div'); footer.className = 'msg-scope-footer'; const files = Array.isArray(v.usedBrainFiles) ? v.usedBrainFiles : []; const layers = (Array.isArray(v.usedMemoryLayers) ? v.usedMemoryLayers : []).map(s => MEMORY_LAYER_LABELS[s] || s); const lessons = Array.isArray(v.lessonFiles) ? v.lessonFiles : []; if (files.length === 0 && layers.length === 0 && lessons.length === 0) { footer.innerHTML = `🔎 참조 지식 없음 — 모델 자체 지식으로 답변`; } else { const dirs = Array.from(new Set(files.map(dirOf))); let scopeLabel; if (v.scoped && Array.isArray(v.configuredFolders) && v.configuredFolders.length) { scopeLabel = v.configuredFolders.join(', '); } else if (dirs.length) { scopeLabel = dirs.slice(0, 4).join(', ') + (dirs.length > 4 ? ` 외 ${dirs.length - 4}` : ''); } else if (files.length === 0) { scopeLabel = '브레인 파일 없음'; } else { scopeLabel = '전체 브레인'; } const agentTag = v.agentName ? `[${escAttr(v.agentName)}] ` : ''; const fileTag = files.length ? ` · 파일 ${files.length}` : ''; const layerTag = layers.length ? ` · 메모리 ${escAttr(layers.join('·'))}` : ''; const lessonTag = lessons.length ? ` · ⚠ 교훈 ${lessons.length}` : ''; const titleAttr = files.length ? `사용된 브레인 파일:\n${files.join('\n')}` : '에이전트↔지식 매핑 편집'; footer.innerHTML = `🔎 참조: ${agentTag}${escAttr(scopeLabel)}${fileTag}${lessonTag}${layerTag}`; } // Knowledge Mix indicator — shows the policy that actually drove this turn so the // user can see *why* the answer leaned the way it did. if (v.knowledgeMix && typeof v.knowledgeMix.weight === 'number') { const w = Math.max(0, Math.min(100, v.knowledgeMix.weight)); const src = v.knowledgeMix.source; const srcLabel = src === 'agent' ? `agent: ${v.knowledgeMix.agent || v.agentName || ''}` : src === 'global' ? 'global' : 'default'; const mix = document.createElement('div'); mix.className = 'scope-mix'; mix.innerHTML = `🎚 Knowledge Mix · Model ${100 - w}% / Brain ${w}% (${escAttr(srcLabel)})`; footer.appendChild(mix); } // Non-blocking flag: lesson Prevention-Checklist items the answer doesn't visibly address. const unaddressed = Array.isArray(v.unaddressedChecklist) ? v.unaddressedChecklist : []; if (unaddressed.length) { const list = unaddressed.map(it => `· ${escAttr(it)}`).join('
'); const w = document.createElement('div'); w.className = 'scope-unaddressed'; w.innerHTML = `⚠ 답변에서 안 보이는 교훈 체크리스트 항목:
${list}`; footer.appendChild(w); } footer.addEventListener('click', e => { const act = e.target && e.target.dataset && e.target.dataset.act; if (act === 'map') vscode.postMessage({ type: 'editKnowledgeMap' }); else if (act === 'lessons') vscode.postMessage({ type: 'manageLessons' }); }); const actions = target.querySelector('.msg-actions'); if (actions) target.insertBefore(footer, actions); else target.appendChild(footer); } // `model: ''` means "Use current model" (i.e. no per-agent override). // `secondBrainWeight: null` means "Use global setting"; a number 0–100 overrides it. let agentMapDraft = { agentPath: '', name: '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null }; /** * Sync the per-agent Knowledge Mix UI (checkbox + slider + hint) to whatever * is currently in `agentMapDraft.secondBrainWeight`. Called whenever the * modal opens or the backend ships fresh data. */ function syncAgentMapMixUi() { const cb = document.getElementById('agentMapMixUseGlobal'); const slider = document.getElementById('agentMapMixSlider'); const hint = document.getElementById('agentMapMixHint'); if (!cb || !slider || !hint) return; const useGlobal = agentMapDraft.secondBrainWeight === null || agentMapDraft.secondBrainWeight === undefined; cb.checked = useGlobal; slider.disabled = useGlobal; const value = useGlobal ? (parseInt((document.getElementById('knowledgeMixSlider') || {}).value, 10) || 50) : Math.max(0, Math.min(100, agentMapDraft.secondBrainWeight | 0)); slider.value = String(value); hint.textContent = useGlobal ? `Use global · ${fmtMixHint(value)}` : fmtMixHint(value); } /** * Rebuild the per-agent model dropdown using whatever model list the top-bar * #modelSel currently has. Called whenever the modal opens OR the model list * is refreshed by the extension host. Preserves the current draft selection. */ function refreshAgentMapModelOptions() { const sel = document.getElementById('agentMapModelSel'); if (!sel) return; const desired = agentMapDraft.model || ''; sel.innerHTML = ''; const useDefault = document.createElement('option'); useDefault.value = ''; useDefault.innerText = 'Use current model'; sel.appendChild(useDefault); const seen = new Set(); // Source the available models from the populated top-bar dropdown so we don't // need an additional round-trip; if a model is selected for this agent but // is no longer in the list, we still surface it so the user sees the value. for (const opt of modelSel.options) { if (!opt.value || seen.has(opt.value)) continue; seen.add(opt.value); const o = document.createElement('option'); o.value = opt.value; o.innerText = opt.innerText; sel.appendChild(o); } if (desired && !seen.has(desired)) { const o = document.createElement('option'); o.value = desired; o.innerText = `${desired} (saved)`; sel.appendChild(o); } sel.value = desired; } function renderAgentMapLists() { const renderList = (listEl, items, kind) => { listEl.innerHTML = ''; items.forEach((p, idx) => { const li = document.createElement('li'); li.className = 'map-item'; const icon = document.createElement('span'); icon.className = 'map-item-icon'; icon.textContent = kind === 'knowledge' ? '📁' : (p.endsWith('.md') || p.endsWith('.markdown') ? '📄' : '📁'); const pathEl = document.createElement('span'); pathEl.className = 'map-item-path'; pathEl.textContent = p; pathEl.title = p; const removeBtn = document.createElement('button'); removeBtn.className = 'map-item-remove'; removeBtn.textContent = '✕'; removeBtn.title = '연결 해제'; removeBtn.onclick = () => { items.splice(idx, 1); renderAgentMapLists(); }; li.appendChild(icon); li.appendChild(pathEl); li.appendChild(removeBtn); listEl.appendChild(li); }); }; renderList(knowledgeFolderList, agentMapDraft.knowledgeFolders, 'knowledge'); renderList(skillFolderList, agentMapDraft.skillFolders, 'skill'); } function openAgentMapModal() { if (!agentSel || agentSel.value === 'none' || !agentSel.value) { showToast('에이전트를 먼저 선택하세요.'); return; } agentMapStatus.className = 'map-status'; agentMapStatus.textContent = '불러오는 중...'; agentMapDraft = { agentPath: agentSel.value, name: agentSel.options[agentSel.selectedIndex]?.text || '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null }; agentMapAgentName.textContent = agentMapDraft.name; knowledgeFolderList.innerHTML = ''; skillFolderList.innerHTML = ''; refreshAgentMapModelOptions(); syncAgentMapMixUi(); agentMapOverlay.classList.add('visible'); vscode.postMessage({ type: 'getAgentMap', agentPath: agentSel.value }); } function closeAgentMapModal() { agentMapOverlay.classList.remove('visible'); agentMapStatus.textContent = ''; agentMapStatus.className = 'map-status'; } let streamBody = null; let internetEnabled = false; let secondBrainTraceEnabled = true; let secondBrainTraceDebug = false; let pendingFiles = []; let editMode = false; if (previousState && typeof previousState.secondBrainTraceEnabled === 'boolean') { secondBrainTraceEnabled = previousState.secondBrainTraceEnabled; } if (previousState && typeof previousState.secondBrainTraceDebug === 'boolean') { secondBrainTraceDebug = previousState.secondBrainTraceDebug; } const initialTraceBtn = document.getElementById('brainTraceBtn'); initialTraceBtn.classList.toggle('active', secondBrainTraceEnabled); initialTraceBtn.setAttribute('data-tooltip', secondBrainTraceEnabled ? 'Second Brain Trace Mode: On' : 'Second Brain Trace Mode: Off'); const initialTraceDebugBtn = document.getElementById('brainTraceDebugBtn'); initialTraceDebugBtn.classList.toggle('active', secondBrainTraceDebug); initialTraceDebugBtn.setAttribute('data-tooltip', secondBrainTraceDebug ? 'Second Brain Debug JSON: On' : 'Second Brain Debug JSON: Off'); function fmt(text) { return marked.parse(text || ''); } function copyToClipboard(text, btn) { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); try { if (document.execCommand('copy')) { btn.innerText = '✅ Copied!'; setTimeout(() => { btn.innerText = '📋 Copy'; }, 2000); } } catch (err) { console.error('Copy failed', err); } document.body.removeChild(textarea); } window.approve = () => { const box = document.querySelector('.approval-box'); if (box) box.remove(); vscode.postMessage({ type: 'approveAction' }); }; window.reject = () => { const box = document.querySelector('.approval-box'); if (box) box.remove(); vscode.postMessage({ type: 'rejectAction' }); }; function exportToMD(text) { vscode.postMessage({ type: 'exportResponse', text: text }); } function addMsg(text, role, rationale) { const isUser = role === 'user'; const msgEl = document.createElement('div'); msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai'); msgEl._raw = text; const head = document.createElement('div'); head.className = 'msg-head'; head.innerHTML = isUser ? '
U
You' : '
Astra'; const body = document.createElement('div'); body.className = 'msg-body markdown-body'; if (isUser) { body.innerText = text; } else { body.innerHTML = fmt(text); } const actions = document.createElement('div'); actions.className = 'msg-actions'; const copyBtn = document.createElement('button'); copyBtn.className = 'action-btn'; copyBtn.innerText = '📋 Copy'; copyBtn.onclick = (e) => { e.stopPropagation(); copyToClipboard(msgEl._raw, copyBtn); }; const exportBtn = document.createElement('button'); exportBtn.className = 'action-btn'; exportBtn.innerText = '💾 Export'; exportBtn.onclick = (e) => { e.stopPropagation(); exportToMD(msgEl._raw); }; actions.appendChild(copyBtn); actions.appendChild(exportBtn); msgEl.appendChild(head); msgEl.appendChild(body); msgEl.appendChild(actions); chat.appendChild(msgEl); chat.scrollTop = chat.scrollHeight; return { body, msgEl }; } window.addEventListener('message', e => { const msg = e.data; switch(msg.type) { case 'addMessage': addMsg(msg.value, msg.role, msg.rationale); // Update state for non-streamed messages const s = vscode.getState() || { history: [] }; s.history.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.value, rationale: msg.rationale }); saveWebviewState(s.history); break; case 'streamStart': thinkingBar.classList.remove('active'); if (document.querySelector('.welcome')) document.querySelector('.welcome').remove(); const res = addMsg('', 'assistant'); streamBody = res.body; streamBody._parent = res.msgEl; streamBody._parent._raw = ''; streamBody.classList.add('stream-active'); break; case 'streamChunk': if (streamBody) { streamBody._parent._raw += msg.value; streamBody.innerHTML = fmt(streamBody._parent._raw); chat.scrollTop = chat.scrollHeight; } break; case 'streamReplace': // Progressive answering: the backend streamed raw tokens // live (including hidden reasoning, pre-sanitize text); // once everything is finalized it sends the cleaned full // text via streamReplace so the bubble ends up correct // regardless of what slipped through during streaming. if (streamBody) { streamBody._parent._raw = String(msg.value ?? ''); streamBody.innerHTML = fmt(streamBody._parent._raw); chat.scrollTop = chat.scrollHeight; } break; case 'streamEnd': if (streamBody) { streamBody.classList.remove('stream-active'); // Update state after stream finishes const state = vscode.getState() || { history: [] }; state.history.push({ role: 'assistant', content: streamBody._parent._raw }); saveWebviewState(state.history); } streamBody = null; // 생성 완료 시 Stop 버튼 숨기고 Send 복구 setGenerating(false); resetStepper(); 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': chat.innerHTML = '
Welcome to Astra

Your premium local AI assistant.
Ready to analyze projects and build reports.

'; break; case 'focusInput': input.focus(); break; case 'modelsList': { modelSel.innerHTML = ''; const inlineModelSel = document.getElementById('modelInlineSel'); if (inlineModelSel) inlineModelSel.innerHTML = ''; // [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도 const _savedModel = localStorage.getItem('g1nation_last_model'); // 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정 // LocalStorage에 저장된 값이 현재 목록에 있으면 그것을 우선 사용 (Tier 2 우선) const _preferredModel = (_savedModel && msg.value.models.includes(_savedModel)) ? _savedModel : msg.value.selected; const _loadedSet = new Set(Array.isArray(msg.value.loadedModels) ? msg.value.loadedModels : []); const _models = Array.isArray(msg.value.models) ? msg.value.models.slice() : []; // Fallback: server returned nothing but we still know the configured model. if (_models.length === 0 && _preferredModel) _models.push(_preferredModel); _models.forEach(m => { const label = _loadedSet.has(m) ? `● ${m}` : m; const o1 = document.createElement('option'); o1.value = m; o1.innerText = label; if (m === _preferredModel) o1.selected = true; modelSel.appendChild(o1); if (inlineModelSel) { const o2 = document.createElement('option'); o2.value = m; o2.innerText = label; if (m === _preferredModel) o2.selected = true; inlineModelSel.appendChild(o2); } }); // LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화 if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) { vscode.postMessage({ type: 'model', value: _savedModel }); } // The model name is now visible inside the footer pill itself, // so statusLabel is reserved for actual status (autoContinue // progress, etc.). Keep it empty in steady state. statusLabel.innerText = ''; // Refresh per-agent model dropdown options (if currently visible) so it stays in sync. if (typeof refreshAgentMapModelOptions === 'function') refreshAgentMapModelOptions(); // If the company manage overlay is open with cached agent data, // re-render its cards so each per-agent model // (or label/spacer) keeps the menu open so the user can change several things. menu.addEventListener('click', e => { e.stopPropagation(); if (e.target && e.target.closest && e.target.closest('button')) { setTimeout(() => menu.classList.remove('open'), 0); } }); }); document.addEventListener('click', () => closeAllDropdowns()); // Keep the Context Bar / Records line in sync with the (now-collapsed) selectors. [brainSel, agentSel, designerSel].forEach(s => s && s.addEventListener('change', syncContextBar)); if (chronicleRecordSel) chronicleRecordSel.addEventListener('change', syncRecordsLine); syncContextBar(); syncRecordsLine(); vscode.postMessage({ type: 'getModels' }); vscode.postMessage({ type: 'getAgents' }); vscode.postMessage({ type: 'getChronicleProjects' }); vscode.postMessage({ type: 'getChronicleRecords' }); vscode.postMessage({ type: 'getKnowledgeMix' }); vscode.postMessage({ type: 'getArchitectureStatus' }); vscode.postMessage({ type: 'getCompanyStatus' }); vscode.postMessage({ type: 'ready' }); // ── Project Architecture chip buttons ───────────────────────────────── const _archOpenBtn = document.getElementById('archOpenBtn'); const _archRefreshBtn = document.getElementById('archRefreshBtn'); const _archDetachBtn = document.getElementById('archDetachBtn'); if (_archOpenBtn) _archOpenBtn.onclick = () => vscode.postMessage({ type: 'openArchitectureDoc' }); if (_archRefreshBtn) _archRefreshBtn.onclick = () => vscode.postMessage({ type: 'refreshArchitecture' }); if (_archDetachBtn) _archDetachBtn.onclick = () => vscode.postMessage({ type: 'detachArchitecture' }); // [Attach] is visible only in the inactive chip state; clicking it // re-enables architecture mode for the current workspace's project. const _archAttachBtn = document.getElementById('archAttachBtn'); if (_archAttachBtn) _archAttachBtn.onclick = () => vscode.postMessage({ type: 'attachArchitecture' }); // ── 1인 기업 (Company) Mode chip + manage overlay ───────────────────── // The chip itself toggles enabled/disabled. The ▾ button opens the // manage overlay where the user picks active agents + per-agent // model overrides. State round-trips through `companyStatus` / // `companyAgents` messages so the webview and extension stay in sync. const _companyChip = document.getElementById('companyChip'); const _companyChipLabel = document.getElementById('companyChipLabel'); 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'); const renderCompanyChip = (active, summary) => { if (!_companyChip || !_companyChipLabel) return; _companyChip.setAttribute('data-active', active ? 'true' : 'false'); _companyChipLabel.textContent = active ? (summary || 'Company ON') : 'Company OFF'; }; if (_companyChip) { _companyChip.onclick = () => { const isActive = _companyChip.getAttribute('data-active') === 'true'; // Optimistic flip — backend echoes the canonical state back. renderCompanyChip(!isActive, _companyChipLabel?.textContent || ''); vscode.postMessage({ type: 'setCompanyEnabled', value: !isActive }); }; } if (_companyManageBtn) { _companyManageBtn.onclick = () => { if (!_companyOverlay) return; _companyOverlay.classList.add('visible'); _companyStatusEl.textContent = '불러오는 중...'; vscode.postMessage({ type: 'getCompanyAgents' }); }; } for (const btn of _closeCompanyBtns) { btn.onclick = () => _companyOverlay?.classList.remove('visible'); } if (_saveCompanyNameBtn && _companyNameInput) { _saveCompanyNameBtn.onclick = () => { vscode.postMessage({ type: 'setCompanyName', value: _companyNameInput.value }); }; } /** * 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 `