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