Files
connectai/media/sidebar.js
T

1099 lines
58 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');
// ── Ready-status bar ─────────────────────────────────────────────────
let readyState = {};
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 renderReadyBar() {
if (!readyBar || !rbContent) return;
const s = readyState;
const segs = [];
// Engine
if (s.engine) {
const on = s.engine.online;
const tag = on === true ? '온라인' : on === false ? '오프라인' : '확인 중';
segs.push(`<span class="rb-seg ${on === false ? 'bad' : on === true ? 'ok' : ''}">${s.engine.label || 'Engine'}: ${tag}</span>`);
}
// Model
if (s.model && s.model.name) {
const loaded = s.model.loaded;
const dot = loaded === true ? '● ' : loaded === false ? '○ ' : '';
segs.push(`<span class="rb-seg" title="${loaded === true ? '메모리에 로드됨' : loaded === false ? '아직 로드되지 않음' : ''}">${dot}${escAttr(s.model.name)}</span>`);
}
// Brain
if (s.brain) {
segs.push(`<span class="rb-seg">Brain ${typeof s.brain.files === 'number' ? s.brain.files : '?'}<span class="rb-dim"> ${escAttr(s.brain.name || '')}</span></span>`);
}
// Agent + scope
if (s.agent && s.agent.name) {
const scope = s.agent.scopeFolders > 0
? ` <span class="rb-link" data-act="map">(범위 ${s.agent.scopeFolders})</span>`
: ` <span class="rb-dim">(범위 미설정)</span>`;
segs.push(`<span class="rb-seg">Agent: ${escAttr(s.agent.name)}${scope}</span>`);
} else {
segs.push(`<span class="rb-seg rb-dim">Agent 없음</span>`);
}
// Memory
segs.push(`<span class="rb-seg ${s.memory ? '' : 'rb-dim'}">메모리 ${s.memory ? '켜짐' : '꺼짐'}</span>`);
// Multi-agent (only when on)
if (s.multiAgent) segs.push(`<span class="rb-seg">멀티에이전트</span>`);
// Context window (capped for small models gets a ↓ marker)
if (typeof s.contextLength === 'number') {
if (s.cappedForSmallModel) {
segs.push(`<span class="rb-seg" title="작은 모델(≤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>`);
}
}
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');
}
}
function escAttr(t) { return String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// ── 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}`;
}
// 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);
}
let agentMapDraft = { agentPath: '', name: '', knowledgeFolders: [], skillFolders: [] };
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: [] };
agentMapAgentName.textContent = agentMapDraft.name;
knowledgeFolderList.innerHTML = '';
skillFolderList.innerHTML = '';
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 '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 = '';
// [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 : []);
msg.value.models.forEach(m => {
const o = document.createElement('option');
o.value = m;
// ● = 현재 LM Studio 메모리에 로드된 모델 / ○ = 다운로드만 됨
o.innerText = _loadedSet.has(m) ? `${m}` : m;
if (m === _preferredModel) o.selected = true;
modelSel.appendChild(o);
});
// 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}`;
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);
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 });
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() : [],
};
renderAgentMapLists();
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);
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);
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);
});
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;
document.getElementById('inputNewChatBtn').onclick = startNewChat;
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 });
};
document.getElementById('inputSyncBtn').onclick = syncBrain;
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'}...`;
}
};
modelSel.onchange = () => {
const _selectedModel = modelSel.value;
// [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 });
updateInputPlaceholder();
// 상태 레이블 즉시 업데이트
statusLabel.innerText = `Model: ${_selectedModel}`;
};
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,
});
};
}
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 });
};
vscode.postMessage({ type: 'getModels' });
vscode.postMessage({ type: 'getAgents' });
vscode.postMessage({ type: 'getChronicleProjects' });
vscode.postMessage({ type: 'getChronicleRecords' });
vscode.postMessage({ type: 'ready' });
// --- 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');