Files
connectai/media/sidebar.js
T

1400 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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(`<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 0100 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 ? '<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();
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 = '<div class="welcome"><div class="welcome-logo">✦</div><div class="welcome-title">Welcome to Astra</div><p>Your premium local AI assistant.<br>Ready to analyze projects and build reports.</p></div>';
break;
case 'focusInput':
input.focus();
break;
case 'modelsList': {
modelSel.innerHTML = '';
const inlineModelSel = document.getElementById('inlineModelSel');
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 });
}
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
statusLabel.innerText = `Model: ${_preferredModel}`;
// Refresh per-agent model dropdown options (if currently visible) so it stays in sync.
if (typeof refreshAgentMapModelOptions === 'function') refreshAgentMapModelOptions();
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 'architectureStatus': {
// Show / hide the chip + reflect current state.
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-active', 'false');
break;
}
chip.setAttribute('data-active', 'true');
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}`;
break;
}
case 'architectureRefreshFailed': {
const reason = msg.value && msg.value.reason;
if (reason === 'no-active-project') {
showToast('활성 프로젝트가 없습니다. 먼저 프로젝트를 선택하세요.', 'warn');
} else {
showToast('Architecture 갱신 실패', 'warn');
}
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('inlineModelSel');
// 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);
statusLabel.innerText = `Model: ${pinned} (agent override)`;
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
}
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');
const updateInputPlaceholder = () => {
if (typeof input !== 'undefined' && input) {
input.placeholder = `Ask ${modelSel ? modelSel.value : 'AI'}...`;
}
};
// Shared handler so the top-bar dropdown and the inline-below-input 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('inlineModelSel');
if (originEl !== modelSel && modelSel.value !== selectedModel) modelSel.value = selectedModel;
if (inlineSel && originEl !== inlineSel && inlineSel.value !== selectedModel) inlineSel.value = selectedModel;
updateInputPlaceholder();
// 상태 레이블 즉시 업데이트
statusLabel.innerText = `Model: ${selectedModel}`;
};
modelSel.onchange = () => applyModelSelection(modelSel.value, modelSel);
const _inlineModelSelEl = document.getElementById('inlineModelSel');
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 0100 = 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: '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' });
// ── 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');