Update ConnectAI codebase

This commit is contained in:
g1nation
2026-05-18 08:15:01 +09:00
parent 88664c7c6e
commit 86cacaeb03
38 changed files with 1043 additions and 99 deletions
+66
View File
@@ -1302,6 +1302,16 @@ export class AgentExecutor {
memoryLayers: this._lastRetrievalInfo?.usedMemoryLayers ?? [],
note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`,
});
// ── Devil Agent (도현) — 비활성 시 silent skip. 활성 시 별도 LLM 호출로 반박 카드 emit. ──
// 비동기 — main turn 완료에 영향 없음. 실패해도 main 답변은 보존됨.
void this._maybeEmitDevilRebuttal({
userPrompt: prompt || '',
assistantAnswer: finalAssistantContent,
baseUrl: ollamaUrl,
modelName: actualModel,
contextLength: ctxLimits.contextLength,
engine,
});
} else {
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
}
@@ -2885,6 +2895,62 @@ export class AgentExecutor {
* "lock() request could not be registered" error this method is helping
* to avoid.
*/
/**
* Devil Agent 반박 emit — main turn 완료 직후 호출 (fire-and-forget).
* 비활성 시 즉시 return. 활성 시 별도 LLM 호출 (callNonStreaming 재사용) 로 짧은 비판 생성.
* 성공 시 webview 에 'devilRebuttal' 메시지 전송 → UI 가 카드로 렌더.
*/
private async _maybeEmitDevilRebuttal(opts: {
userPrompt: string;
assistantAnswer: string;
baseUrl: string;
modelName: string;
contextLength: number;
engine: 'lmstudio' | 'ollama';
}): Promise<void> {
try {
const { isDevilAgentEnabled, generateDevilRebuttal, DEVIL_PERSONA_NAME } =
await import('./features/devilAgent');
if (!isDevilAgentEnabled()) return;
if (!opts.userPrompt.trim() || !opts.assistantAnswer.trim()) return;
// Local callLLM wrapper — callNonStreaming 재사용 (cloud / local 자동 라우팅).
const callLLM = async (system: string, userMessage: string, maxTokens: number) => {
const r = await this.callNonStreaming({
baseUrl: opts.baseUrl,
modelName: opts.modelName,
engine: opts.engine,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: userMessage },
],
temperature: 0.7,
maxTokens,
contextLength: opts.contextLength,
signal: this.abortController?.signal,
});
return r.text;
};
const rebuttal = await generateDevilRebuttal(callLLM, {
userPrompt: opts.userPrompt,
assistantAnswer: opts.assistantAnswer,
});
if (!rebuttal) return;
this.webview?.postMessage({
type: 'devilRebuttal',
value: {
persona: DEVIL_PERSONA_NAME,
text: rebuttal,
// 사용자가 '재반박' 누를 때 원래 컨텍스트로 돌아갈 수 있게 stash.
userPrompt: opts.userPrompt,
assistantAnswer: opts.assistantAnswer,
},
});
} catch (e: any) {
// Devil 실패는 main 답변에 영향 없음 — silent log.
logInfo('Devil rebuttal skipped.', { error: e?.message ?? String(e) });
}
}
private async callNonStreaming(params: {
baseUrl: string;
modelName: string;
+13
View File
@@ -680,6 +680,19 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('g1nation.calendar.connectOAuth', async () => {
await runConnectGoogleCalendarOAuth(context);
}),
// Devil Agent (도현) — 매 답변 직후 비판적 반박. 토글 명령.
vscode.commands.registerCommand('g1nation.devilAgent.toggle', async () => {
const { isDevilAgentEnabled, setDevilAgentEnabled, DEVIL_PERSONA_NAME } =
await import('./features/devilAgent');
const wasOn = isDevilAgentEnabled();
await setDevilAgentEnabled(!wasOn);
const nowOn = !wasOn;
vscode.window.showInformationMessage(
nowOn
? `🎭 ${DEVIL_PERSONA_NAME} 활성화됨 — 이제 매 답변 뒤에 비판적 반박 카드가 떠요.`
: `🎭 ${DEVIL_PERSONA_NAME} 비활성화됨.`
);
}),
);
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
+1 -1
View File
@@ -17,7 +17,7 @@ export const OFFICE_BODY = `
<div id="editToolbar" class="edit-toolbar" style="display:none;">
<span class="et-hint">드래그로 이동 · <b>R</b> 회전 · <b>]</b>/<b>[</b> 레이어 · 4px snap</span>
<button id="addDeskBtn" class="add" title="책상 추가">+ 책상</button>
<button id="addPropBtn" class="add" title="프랍(소품) 추가">+ 프랍</button>
<button id="addPropBtn" class="add" title="가구 추가">+ 가구</button>
<button id="deleteSelBtn" class="del" title="선택 항목 삭제" disabled>삭제</button>
<button id="layerUpBtn" title="레이어 위로 (])">위로</button>
<button id="layerDownBtn" title="레이어 아래로 ([)">아래로</button>
+22 -4
View File
@@ -300,7 +300,25 @@ button,input,select{font:inherit}
.stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after,
.stage:has(.char.active[data-agent="writer"]) .desk[data-agent="writer"]::after{border-color:var(--role-color);box-shadow:0 0 0 1px rgba(255,255,255,.06),0 0 18px color-mix(in srgb,var(--role-color) 35%,transparent)}
.shadow{position:absolute;left:12px;bottom:0;width:28px;height:7px;background:radial-gradient(ellipse,rgba(0,0,0,.55),transparent 70%)}
.bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);max-width:180px;padding:7px 10px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(10,14,24,.92);color:var(--text);font-size:11px;line-height:1.35;box-shadow:0 10px 24px rgba(0,0,0,.28);white-space:normal}
.bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);max-width:180px;padding:7px 10px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(10,14,24,.92);color:var(--text);font-size:11px;line-height:1.35;box-shadow:0 10px 24px rgba(0,0,0,.28);white-space:normal;animation:bubble-pop .22s cubic-bezier(.2,1.4,.6,1)}
@keyframes bubble-pop{from{transform:translate(-50%,-88%) scale(.6);opacity:0}to{transform:translate(-50%,-100%) scale(1);opacity:1}}
/* 감정 태그별 변형 — 희노애락. webtoon 느낌으로 background / 색 / 이모지 prefix. */
.bubble-joy {background:rgba(254,243,199,.97);color:#7c5d11;border-color:rgba(252,211,77,.6)}
.bubble-joy::before {content:'😊 ';opacity:.85}
.bubble-anger {background:rgba(254,226,226,.97);color:#7f1d1d;border-color:#ef4444;animation:bubble-pop .22s cubic-bezier(.2,1.4,.6,1),bubble-shake .4s ease-in-out 1}
.bubble-anger::before {content:'😠 ';opacity:.85}
.bubble-sorrow {background:rgba(219,234,254,.97);color:#1e3a8a;border-color:rgba(96,165,250,.5)}
.bubble-sorrow::before {content:'😔 ';opacity:.85}
.bubble-panic {background:rgba(254,226,226,.97);color:#9a1c1c;border-color:rgba(248,113,113,.55);animation:bubble-pop .22s cubic-bezier(.2,1.4,.6,1),bubble-shake .5s ease-in-out 1}
.bubble-panic::before {content:'😱 ';opacity:.85}
.bubble-curious {background:rgba(243,232,255,.97);color:#5b21b6;border-color:rgba(167,139,250,.45)}
.bubble-curious::before {content:'🤔 ';opacity:.85}
.bubble-firm {background:rgba(255,255,255,.98);color:#111;border-color:rgba(0,0,0,.18);font-weight:700}
.bubble-firm::before {content:'✋ ';opacity:.85}
.bubble-gratitude{background:rgba(220,252,231,.97);color:#14532d;border-color:rgba(74,222,128,.5)}
.bubble-gratitude::before{content:'🙏 ';opacity:.85}
.bubble-thought {/* default 그대로 */}
@keyframes bubble-shake{0%,100%{transform:translate(-50%,-100%) rotate(0)}25%{transform:translate(-52%,-100%) rotate(-2deg)}75%{transform:translate(-48%,-100%) rotate(2deg)}}
.brief-grid{display:flex;flex-direction:column;gap:10px}
.brief-card{
padding:14px;
@@ -367,13 +385,13 @@ body:not([data-edit-mode="true"]) .char{cursor:pointer}
.prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated}
.prop-panel .pp-thumb.active{border-color:rgba(138,124,255,.7);box-shadow:0 0 0 2px rgba(138,124,255,.18)}
.prop-picker{position:fixed;inset:0;background:rgba(3,5,10,.68);z-index:1100;display:flex;align-items:center;justify-content:center}
.prop-picker-box{background:rgba(10,14,24,.98);border:1px solid var(--line-strong);border-radius:20px;padding:16px;max-width:520px;max-height:80vh;overflow-y:auto;color:var(--text)}
.prop-picker-box{background:rgba(10,14,24,.98);border:1px solid var(--line-strong);border-radius:20px;padding:16px;max-width:380px;max-height:80vh;overflow-y:auto;color:var(--text)}
.prop-picker-box h3{margin:0 0 12px;font-size:13px;color:#C6BEFF}
.prop-picker-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
.prop-picker-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.prop-pick{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:14px;padding:7px;cursor:pointer;text-align:center}
.prop-pick:hover{border-color:rgba(138,124,255,.6)}
.prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated}
.prop-pick .pp-name{font-size:10px;color:var(--muted);margin-top:5px;word-break:break-all}
.prop-pick .pp-name{font-size:10px;color:var(--muted);margin-top:5px;word-break:keep-all}
body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(138,124,255,.18) 1px,transparent 1px),linear-gradient(90deg,rgba(138,124,255,.18) 1px,transparent 1px);background-size:32px 32px}
body[data-edit-mode="true"] .desk,body[data-edit-mode="true"] .char,body[data-edit-mode="true"] .obj{cursor:grab;outline:1px dashed rgba(138,124,255,.45)}
body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid rgba(138,124,255,.8);z-index:30}
+200 -17
View File
@@ -7,7 +7,7 @@ const OFFICE_RUNTIME_JS_TEMPLATE = `
const base='\${assets.derivedBase}'; const stage=document.getElementById('stage');
const officeEl = stage && stage.closest ? stage.closest('.office') : null;
if(officeEl){
officeEl.style.setProperty('--office-backdrop', 'url(\"'+base+'/office-backdrop-astra-v2.png\")');
officeEl.style.setProperty('--office-backdrop', 'url(\"'+base+'/office-backdrop-astra-v3.png\")');
officeEl.classList.add('has-art');
}
let _stageScale = 1;
@@ -41,9 +41,20 @@ const AGENT_ALIASES={writer:'planner',editor:'designer',secretary:'support',busi
// \uB9E4\uD551 dropdown \uC5D0 \uBCF4\uC5EC\uC904 \uC5D0\uC774\uC804\uD2B8 \uD6C4\uBCF4. agentKey \uAC00 unique \uD55C base set.
const AGENT_CHOICES=['','ceo','planner','researcher','designer','developer','qa','inspector','support'];
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uCC45\uC0C1 sprite \uD6C4\uBCF4 (assets/pixelOffice/derived \uC5D0 \uC788\uB294 desk-* PNG).
const DESK_SPRITE_CHOICES=['desk-main','desk-boss','desk-dark-mirror','desk-main-mirror','desk-dark','desk-boss-mirror','desk-main-front','desk-dark-front','desk-boss-front','desk-partition'];
const DESK_SPRITE_CHOICES=['astra-desk-work','astra-desk-dark','astra-desk-exec'];
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uD504\uB78D sprite \uD6C4\uBCF4.
const PROP_SPRITE_CHOICES=['board','plant-tall','bookshelf','plant-bushy','partition','cooler','filing','couch','rug','shelf','printer','monitor-blue','monitor-black','chair-blue','crt'];
const PROP_SPRITE_CHOICES=['astra-storage-low','astra-bookshelf-slim','astra-plant-corner','astra-cooler-slim','astra-printer-compact','astra-chair-task'];
const SPRITE_LABELS={
'astra-desk-work':'워크 데스크',
'astra-desk-dark':'다크 데스크',
'astra-desk-exec':'대표 데스크',
'astra-storage-low':'로우 스토리지',
'astra-bookshelf-slim':'북케이스',
'astra-plant-corner':'코너 플랜트',
'astra-cooler-slim':'정수기',
'astra-printer-compact':'프린터',
'astra-chair-task':'태스크 체어',
};
// 후면 배경 자체가 이미 충분히 강하므로, 목적 없는 프랍은 기본 장면에서 제거.
// 커스텀 편집 모드에선 여전히 사용자가 원하는 프랍을 직접 추가할 수 있다.
const DEFAULT_PROPS=[];
@@ -63,6 +74,7 @@ const anim={}; // role \u2192 animation state.
const _autoDeskedFor = new Set();
function png(name){return base+'/'+name+'.png'}
function _spriteLabel(name){ return SPRITE_LABELS[name] || name; }
function _rebuildStationIndex(){
Object.keys(stationByKey).forEach(k=>delete stationByKey[k]);
@@ -113,8 +125,8 @@ function _pipelineRosterOrder(roster, pipeline){
return _uniqueAgentsById(ordered);
}
function _stationSpriteFor(slot, roleCategory){
if(roleCategory==='ceo') return 'desk-boss';
return slot % 2 ? 'desk-dark' : 'desk-main';
if(roleCategory==='ceo') return 'astra-desk-exec';
return 'astra-desk-work';
}
function _stationSlots(count){
const rows=[];
@@ -131,7 +143,7 @@ function _stationSlots(count){
const remaining = count - row*cols;
const inRow = Math.min(cols, remaining);
const xs = xSets[inRow] || xSets[3];
const rowYs = rowCount === 1 ? [330] : (rowCount === 2 ? [304,432] : [270,380,490]);
const rowYs = rowCount === 1 ? [300] : (rowCount === 2 ? [304,432] : [270,380,490]);
const y = rowYs[row] ?? (490 + Math.max(0,row-rowYs.length+1)*88);
xs.forEach((x)=>rows.push([x,y,'R']));
}
@@ -141,12 +153,12 @@ function _makeStationForRosterAgent(agent, slot, slots){
if(agent.roleCategory==='ceo' || agent.agentId==='ceo'){
return {
key:'ceo',agentKey:agent.agentId,label:agent.agentName||'CEO',charRow:_roleCategoryToCharRow(agent.roleCategory),
deskSprite:'desk-boss',deskX:294,deskY:188,deskW:124,seatX:319,seatY:218,face:'R',
deskSprite:'astra-desk-exec',deskX:286,deskY:188,deskW:140,seatX:319,seatY:218,face:'R',
dock:[350,268],roam:[[304,280],[402,280]],boss:true,
};
}
const [deskX,deskY,face]=(slots && slots[slot]) || [308,318,'R'];
const deskW=104;
const deskW=112;
const seatX=deskX+4;
const seatY=deskY+36;
return {
@@ -677,6 +689,8 @@ const REVIEWER_KEYWORD_BANK = [
// 6~9초 random 간격으로 활성 character 중 하나에서 속마음 emit. mode 가 work / sit 인 것만.
// 한 tick 에 *한 명만* 골라서 띄움 — 동시에 여러 풍선 뜨면 시각 부담.
function _innerThoughtTick(){
// Banter 가 재생 중이면 일반 inner-thought 는 잠시 멈춤 — 동시 다발 풍선으로 산만해지지 않게.
if(_activeBanter) return;
// Reviewing 중: 검수자 본인은 keyword 던지고, 옮겨와 있는 작성자는 nervous thought.
if(_prevStatus === 'reviewing'){
const inspectorRole = roleMap['inspector'];
@@ -712,6 +726,171 @@ function _innerThoughtTick(){
if(text) _bubbleFromLog(role, text);
}
setInterval(_innerThoughtTick, 7500);
// ── Webtoon-style 티키타카 banter (refactor: pipeline-aware) ──
// 각 phase 에 *시퀀스화된 대화 script* 가 있어 phase 진입 시 한 줄씩 시간 차로 emit.
// 각 line 은 (agentKey, text, emotion) — emotion 은 bubble class 로 변환 (희노애락).
//
// 한 phase 마다 여러 variant 중 1개 random pick — 같은 흐름을 매번 안 보게.
// 진행 중 phase 가 바뀌면 즉시 중단.
const BANTER_SCRIPTS = {
// 의도 분석 — CEO 가 planner 와 함께 요청 정리
analyzing: [
[
{ agent: 'ceo', text: '의도 다시 보자.', emotion: 'thought' },
{ agent: 'planner', text: '핵심은 명확한데…', emotion: 'thought' },
{ agent: 'ceo', text: '뭐가 막혀?', emotion: 'curious' },
{ agent: 'planner', text: '성공 기준이 모호해요.', emotion: 'sorrow' },
{ agent: 'ceo', text: '좋아, 그것부터 정하자.', emotion: 'firm' },
],
[
{ agent: 'planner', text: '범위가 너무 넓은데…', emotion: 'sorrow' },
{ agent: 'ceo', text: '첫 사용자 한 명만 그려보자.', emotion: 'firm' },
{ agent: 'planner', text: '오, 그러면 깔끔해지네요.', emotion: 'joy' },
],
],
intake: [
[
{ agent: 'ceo', text: '오, 새 요청.', emotion: 'joy' },
{ agent: 'support', text: '브리프 정리해드릴게요.', emotion: 'thought' },
{ agent: 'ceo', text: '바로 보자.', emotion: 'firm' },
],
],
// 사용자에게 확인 필요
need_clarification: [
[
{ agent: 'planner', text: '이건 추측으로 가면 위험.', emotion: 'panic' },
{ agent: 'ceo', text: '맞아. 사장님께 묻자.', emotion: 'firm' },
{ agent: 'support', text: '질문 정리해서 띄울게요.', emotion: 'thought' },
],
],
// 계획
planning: [
[
{ agent: 'ceo', text: '순서 잡자. 누가 먼저?', emotion: 'firm' },
{ agent: 'planner', text: '리서치 → 설계 → 구현 흐름.', emotion: 'thought' },
{ agent: 'developer', text: '설계 전에 데이터 모델 확정', emotion: 'firm' },
{ agent: 'planner', text: '맞아요, 그게 우선.', emotion: 'joy' },
],
[
{ agent: 'planner', text: '여기 분기점이 두 개.', emotion: 'thought' },
{ agent: 'ceo', text: 'A 안 비용은?', emotion: 'curious' },
{ agent: 'planner', text: 'B 보다 절반.', emotion: 'thought' },
{ agent: 'ceo', text: 'A 로 가자. 확정.', emotion: 'firm' },
],
],
// 구현
executing: [
[
{ agent: 'developer', text: '집중 모드 들어갑니다.', emotion: 'firm' },
{ agent: 'qa', text: '엣지케이스 같이 봐드려요?', emotion: 'curious' },
{ agent: 'developer', text: '아, 빈 입력 케이스 빠뜨릴 뻔', emotion: 'panic' },
{ agent: 'qa', text: '네, 그거 자주 빼먹어요.', emotion: 'thought' },
{ agent: 'developer', text: '고마워요.', emotion: 'gratitude' },
],
[
{ agent: 'developer', text: '여기 너무 복잡한데.', emotion: 'sorrow' },
{ agent: 'planner', text: '단계 둘로 나누면?', emotion: 'curious' },
{ agent: 'developer', text: '오, 그러면 되겠네요.', emotion: 'joy' },
],
[
{ agent: 'designer', text: '여백 좀 더 줄까요?', emotion: 'curious' },
{ agent: 'developer', text: '여기 컴포넌트 정렬 잘 못맞춰요…', emotion: 'sorrow' },
{ agent: 'designer', text: 'flex gap 8 로 가면 깔끔.', emotion: 'firm' },
{ agent: 'developer', text: '오케이 반영.', emotion: 'gratitude' },
],
],
// 검수
reviewing: [
[
{ agent: 'inspector', text: '시나리오 빠진 것 있나…', emotion: 'thought' },
{ agent: 'planner', text: '괜찮을까…', emotion: 'panic' },
{ agent: 'inspector', text: '여기 측정 기준 누락.', emotion: 'firm' },
{ agent: 'planner', text: '아 KPI 부분, 보강할게요.', emotion: 'sorrow' },
{ agent: 'inspector', text: '그거만 채우면 통과.', emotion: 'thought' },
],
[
{ agent: 'inspector', text: '전체 흐름은 OK.', emotion: 'thought' },
{ agent: 'inspector', text: '근데 여기 over-engineering.', emotion: 'firm' },
{ agent: 'developer', text: '단순화 어떻게…', emotion: 'curious' },
{ agent: 'inspector', text: '함수 셋을 하나로.', emotion: 'firm' },
{ agent: 'developer', text: '바로 줄일게요.', emotion: 'gratitude' },
],
[
{ agent: 'qa', text: '회귀 케이스 1개 실패.', emotion: 'panic' },
{ agent: 'developer', text: '어… 어떤 거?', emotion: 'curious' },
{ agent: 'qa', text: '동시 호출 race.', emotion: 'firm' },
{ agent: 'developer', text: '아 그거… 죄송, 빨리 고칠게요.', emotion: 'sorrow' },
],
],
// 승인 대기
waiting_approval: [
[
{ agent: 'inspector', text: '위험 작업 감지.', emotion: 'panic' },
{ agent: 'ceo', text: '사장님 결재 필요.', emotion: 'firm' },
{ agent: 'support', text: '승인 카드 띄울게요.', emotion: 'thought' },
],
],
error: [
[
{ agent: 'developer', text: '앗, 이건 예상 못 했어요.', emotion: 'panic' },
{ agent: 'qa', text: '재현 단계는?', emotion: 'curious' },
{ agent: 'developer', text: '지금 정리 중…', emotion: 'sorrow' },
{ agent: 'inspector', text: '뭐가 깨졌나 같이 보자.', emotion: 'firm' },
],
],
done: [
[
{ agent: 'ceo', text: '좋아, 끝났다!', emotion: 'joy' },
{ agent: 'planner', text: '이번 작업 깔끔하게 완료.', emotion: 'joy' },
{ agent: 'developer', text: '커피 한 잔!', emotion: 'joy' },
{ agent: 'support', text: '회의록·일정 자동 정리 했어요.', emotion: 'joy' },
],
],
};
// 감정 태그 → CSS class. _emoteBubble 가 .bubble + .bubble-<emotion> 으로 렌더.
function _emoteBubble(role, text, emotion){
const ch = chars[role];
if(!ch || !text) return;
const b = document.createElement('div');
b.className = 'bubble bubble-' + (emotion || 'thought');
b.textContent = text;
b.style.left = (parseFloat(ch.style.left) + 28) + 'px';
b.style.top = (parseFloat(ch.style.top) - 6) + 'px';
stage.appendChild(b);
setTimeout(() => b.remove(), 3200);
}
// 활성 banter 추적 — phase 가 바뀌면 즉시 중단해서 새 script 시작.
let _activeBanter = null;
function _stopBanter(){
if(_activeBanter && _activeBanter.timer){ clearTimeout(_activeBanter.timer); }
_activeBanter = null;
}
function _stepBanter(){
if(!_activeBanter) return;
if(_activeBanter.idx >= _activeBanter.lines.length){ _activeBanter = null; return; }
const line = _activeBanter.lines[_activeBanter.idx++];
const role = roleMap[line.agent];
if(role){
_emoteBubble(role, line.text, line.emotion);
}
// 다음 줄까지 1.8~2.4초 — 자연스러운 대화 호흡.
const delay = 1800 + Math.floor(Math.random() * 600);
_activeBanter.timer = setTimeout(_stepBanter, delay);
}
function _playBanterForPhase(phaseOrStatus){
if(!phaseOrStatus) return;
const variants = BANTER_SCRIPTS[phaseOrStatus];
if(!Array.isArray(variants) || variants.length === 0) return;
const script = variants[Math.floor(Math.random() * variants.length)];
if(!Array.isArray(script) || script.length === 0) return;
_stopBanter();
_activeBanter = { lines: script, idx: 0, timer: null };
// 첫 줄 즉시.
_stepBanter();
}
// ── A. 상태 계층화 ──
// 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함.
// 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고,
@@ -871,6 +1050,10 @@ function apply(s){
_restoreWriterHome(r);
}
}
// ── Webtoon-style banter trigger ──
// Phase 바뀔 때 해당 phase 의 대화 script 한 variant 무작위 선택해서 시퀀스 재생.
// 진행 중 다른 phase 진입하면 자동 중단되고 새 script 가 시작.
_playBanterForPhase(st);
}
if(isTransition){
// 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
@@ -1123,7 +1306,7 @@ function _autoCreateDeskForAgent(r){
agentKey: r.agentId,
label: r.agentName || r.agentId,
charRow: _roleCategoryToCharRow(r.roleCategory),
deskSprite: isBoss ? 'desk-boss' : 'desk-main',
deskSprite: isBoss ? 'astra-desk-exec' : 'astra-desk-work',
face: 'R',
boss: isBoss,
deskX: baseX, deskY: baseY, deskW: isBoss ? 136 : 112,
@@ -1259,7 +1442,7 @@ function _snapshotLayout(){
agentKey: st.agentKey || '',
label: st.label || '',
charRow: st.charRow ?? 0,
deskSprite: st.deskSprite || 'desk-main',
deskSprite: st.deskSprite || 'astra-desk-work',
face: st.face || 'R',
boss: !!st.boss,
noChar: !!st.noChar,
@@ -1330,7 +1513,7 @@ function _restoreLayout(snap){
agentKey: c.agentKey || '',
label: c.label || c.roleKey,
charRow: typeof c.charRow === 'number' ? c.charRow : 0,
deskSprite: c.deskSprite || 'desk-main',
deskSprite: c.deskSprite || 'astra-desk-work',
face: c.face || 'R',
boss: !!c.boss,
noChar: !!c.noChar,
@@ -1448,7 +1631,7 @@ function _renderDeskProps(deskEl){
// 에이전트 매핑 dropdown.
const agentOpts = AGENT_CHOICES.map(a=>'<option value="'+a+'"'+(a===(st.agentKey||'')?' selected':'')+'>'+(a||'(매핑 없음)')+'</option>').join('');
// 책상 sprite picker.
const deskOpts = DESK_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===st.deskSprite?' selected':'')+'>'+s+'</option>').join('');
const deskOpts = DESK_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===st.deskSprite?' selected':'')+'>'+_spriteLabel(s)+'</option>').join('');
// charRow 썸네일 picker (idle-r<n>-f0.png).
let thumbs='';
for(let r=0;r<8;r++){
@@ -1513,9 +1696,9 @@ function _renderObjProps(el){
panel.classList.add('show');
const name = el.dataset.objName || '';
const w = el.dataset.objW || '';
const opts = PROP_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===name?' selected':'')+'>'+s+'</option>').join('');
const opts = PROP_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===name?' selected':'')+'>'+_spriteLabel(s)+'</option>').join('');
panel.innerHTML =
'<h4>프랍 속성</h4>'+
'<h4>가구 속성</h4>'+
'<div class="pp-row"><label>sprite</label><select id="ppObjName">'+opts+'</select></div>'+
'<div class="pp-row"><label>너비 (px, 비우면 원본)</label><input id="ppObjW" type="number" value="'+w+'" placeholder="원본"></div>';
panel.querySelector('#ppObjName').onchange = (ev)=>{
@@ -1536,7 +1719,7 @@ function _addNewDesk(){
const baseX = 280 + ((__nextDeskN%5)*16);
const baseY = 260 + ((__nextDeskN%5)*16);
const st = {
key: id, agentKey: '', label: '새 책상', charRow: 0, deskSprite: 'desk-main', face: 'R',
key: id, agentKey: '', label: '새 책상', charRow: 0, deskSprite: 'astra-desk-work', face: 'R',
boss: false,
deskX: baseX, deskY: baseY, deskW: 112,
seatX: baseX+4, seatY: baseY+36,
@@ -1557,9 +1740,9 @@ function _openPropPicker(){
overlay.className = 'prop-picker';
const box = document.createElement('div');
box.className = 'prop-picker-box';
box.innerHTML = '<h3>프랍 추가 — sprite 선택</h3>'+
box.innerHTML = '<h3>가구 추가</h3>'+
'<div class="prop-picker-grid">'+
PROP_SPRITE_CHOICES.map(n=>'<div class="prop-pick" data-name="'+n+'"><img src="'+png(n)+'"><div class="pp-name">'+n+'</div></div>').join('')+
PROP_SPRITE_CHOICES.map(n=>'<div class="prop-pick" data-name="'+n+'"><img src="'+png(n)+'"><div class="pp-name">'+_spriteLabel(n)+'</div></div>').join('')+
'</div>';
overlay.appendChild(box);
overlay.onclick = (e)=>{ if(e.target === overlay) overlay.remove(); };
+71
View File
@@ -0,0 +1,71 @@
/**
* Devil's Advocate (도현) — system prompt 빌더.
*
* 설계 원칙:
* - 모든 약점을 나열하지 않음. *한 turn 에 1개* — 사용자가 깊게 생각하게.
* - 'X 입장에서 본다면' framing — 본인 의견이 아니라 *다른 시점* 으로 제시.
* 이는 LLM 의 '내가 옳다' 경향을 줄이는 잘 알려진 패턴.
* - 통계 / 구체 수치 / 외부 사례 *금지* — 환각 표면적 차단.
* Phase 2 에서 외부 데이터 활성화 옵션 추가 예정.
* - 출처 명시 강제 — '(근거: 추론)' 또는 '(근거: brain/<file>)' 로 끝.
*
* 출력 형태:
* 3~5 문장 한 문단. 끝에 "① 우려" + "② 검증 방법 1개" 강제.
*/
export const DEVIL_PERSONA_NAME = '도현';
export interface DevilPromptInput {
/** 사용자가 던진 원래 질문. */
userPrompt: string;
/** Astra 의 직전 답변. 도현이 반박할 대상. */
assistantAnswer: string;
/** Optional Second Brain context — 있으면 인용 근거로 사용 가능. */
brainContext?: string;
/** Optional re-rebuttal — 사용자가 '재반박' 클릭 후 보낸 메시지. 있으면 다시 한 라운드. */
userRebuttal?: string;
}
const BASE_RULES = [
'당신은 \'도현\', 사용자의 사고를 더 깊게 만드는 *비판적 sparring partner* 입니다.',
'',
'규칙:',
'1. *한 turn 에 한 가지 약점* 만 골라 반박. 모든 약점을 나열하지 말 것 — 사용자가 깊게 생각하도록.',
'2. 본인 의견이 아닌 *다른 시점* 제시: "만약 비관적으로 본다면", "사용자 입장에서는", "1년 뒤 본다면" 등으로 framing 시작.',
'3. 칭찬 / 동의 / "좋은 답변입니다" 금지. 건설적이지만 *단호*.',
'4. 통계·연구·구체 수치·외부 사례 *인용 금지* — 당신은 그런 데이터를 보유하지 않음. 구조적·논리적·시나리오 비판만.',
'5. 답변은 *3~5 문장 한 문단*. 마지막에 반드시 두 줄:',
' ① 우려: <한 문장 핵심 우려>',
' ② 검증: <한 가지 구체적 검증 방법>',
'6. 답변 끝에 출처 태그: brain 자료 인용했으면 `(근거: brain/<filename>)`, 안 했으면 `(근거: 추론)`.',
].join('\n');
export function buildDevilSystemPrompt(): string {
return BASE_RULES;
}
export function buildDevilUserPrompt(input: DevilPromptInput): string {
const parts: string[] = [];
parts.push('## 사용자 원래 질문');
parts.push(input.userPrompt.trim() || '(빈 질문)');
parts.push('');
parts.push('## Astra 의 직전 답변 (당신이 반박할 대상)');
parts.push(input.assistantAnswer.trim() || '(빈 답변)');
if (input.brainContext && input.brainContext.trim()) {
parts.push('');
parts.push('## 참고 가능한 Second Brain 자료');
parts.push('(필요할 때만 인용. 인용하지 않으면 추론으로 표시.)');
parts.push(input.brainContext.trim());
}
if (input.userRebuttal && input.userRebuttal.trim()) {
parts.push('');
parts.push('## 사용자의 재반박');
parts.push(input.userRebuttal.trim());
parts.push('');
parts.push('재반박을 받았으니 *기존 입장을 굽히지 말되 한 단계 더 깊은 약점* 으로 이동하세요. 같은 약점을 반복하지 말 것.');
} else {
parts.push('');
parts.push('위 답변에서 *가장 본질적인 약점 하나* 를 골라 반박하세요.');
}
return parts.join('\n');
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Devil Agent — 직전 답변에 대한 반박 생성기.
*
* 동작:
* 1. agent.ts 가 main turn 완료 직후 호출
* 2. 같은 model/engine 으로 *별도 LLM call 1회* — 짧은 비판 한 문단 생성
* 3. 결과를 webview 로 'devilRebuttal' message 로 send
* 4. 실패 / 비활성 시 silent skip — main 답변에는 영향 없음
*
* 비용:
* - 매 turn 마다 LLM 1회 추가. 사용자가 settings 에서 명시적 ON 했을 때만.
* - 짧은 prompt + maxTokens 350 으로 제한 → 일반 turn 비용의 ~10-15%.
*/
import * as vscode from 'vscode';
import { buildDevilSystemPrompt, buildDevilUserPrompt, DevilPromptInput, DEVIL_PERSONA_NAME } from './devilPrompt';
const SETTING_KEY = 'g1nation.devilAgent.enabled';
export function isDevilAgentEnabled(): boolean {
const cfg = vscode.workspace.getConfiguration('g1nation');
return !!cfg.get<boolean>('devilAgent.enabled', false);
}
export async function setDevilAgentEnabled(enabled: boolean): Promise<void> {
const cfg = vscode.workspace.getConfiguration('g1nation');
await cfg.update('devilAgent.enabled', enabled, vscode.ConfigurationTarget.Global);
}
export { DEVIL_PERSONA_NAME, SETTING_KEY };
/**
* Main turn 끝나면 caller (agent.ts) 가 이 함수를 호출.
* - input 에 사용자 질문 + 직전 답변 + (선택) brain context 전달
* - 반환값: 도현의 반박 텍스트 또는 null (비활성·실패 시)
*
* 호출자가 webview 에 직접 'devilRebuttal' message 를 보내도록 분리 — 본 함수는 *pure*.
*/
export async function generateDevilRebuttal(
callLLM: (system: string, userMessage: string, maxTokens: number) => Promise<string>,
input: DevilPromptInput,
): Promise<string | null> {
if (!isDevilAgentEnabled()) return null;
try {
const system = buildDevilSystemPrompt();
const userMsg = buildDevilUserPrompt(input);
const out = await callLLM(system, userMsg, 350);
const cleaned = (out || '').trim();
if (!cleaned) return null;
// 환각 가드: 통계 / 수치 패턴이 보이면 *(근거: 추론)* 로 강제 변경 — 도현이 환각 자신 가질 가능성 줄임.
return _appendSourceTagIfMissing(cleaned);
} catch {
return null;
}
}
function _appendSourceTagIfMissing(text: string): string {
if (/\(근거:/.test(text)) return text;
return text.trim() + '\n\n(근거: 추론)';
}
+13
View File
@@ -0,0 +1,13 @@
export {
buildDevilSystemPrompt,
buildDevilUserPrompt,
DevilPromptInput,
DEVIL_PERSONA_NAME,
} from './devilPrompt';
export {
isDevilAgentEnabled,
setDevilAgentEnabled,
generateDevilRebuttal,
SETTING_KEY,
} from './devilService';
@@ -98,6 +98,18 @@ interface SettingsState {
connectedAt?: string;
lastIcalFetchAt?: string;
};
/**
* Cloud LLM providers (OpenRouter / Anthropic / Gemini). API key 자체는 echo 안 함 —
* hasApiKey boolean 만 전송. enabled 와 defaultModel 은 settings 에서 직접 읽음.
*/
providers: {
openrouter: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
anthropic: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
gemini: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
};
devilAgent: {
enabled: boolean;
};
/** Sectional banner shown when config.update fails (e.g. reload required). */
bannerError?: string;
}
@@ -238,6 +250,12 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
case 'google.icalRefresh':
await this._handleGoogleIcalRefresh();
return;
case 'providers.update':
await this._handleProvidersUpdate(msg);
return;
case 'devilAgent.toggle':
await this._safeConfigUpdate('devilAgent.enabled', !!msg.enabled);
return;
case 'openVscodeSettings':
await vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
return;
@@ -509,6 +527,36 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
await this._refreshState();
}
// ────────────── Cloud LLM Providers ──────────────
// OpenRouter / Anthropic / Gemini API key + enable 토글. API key 는 Secret Storage 만.
// settings 패널은 *값 자체는 안 보여줌* (hasApiKey boolean 만). 사용자가 새로 입력 시 덮어씀.
private async _buildProvidersState(): Promise<SettingsState['providers']> {
const { readProviderStatus } = require('../providers') as typeof import('../providers');
const ctx = this._deps.context;
const [or, an, ge] = await Promise.all([
readProviderStatus(ctx, 'openrouter'),
readProviderStatus(ctx, 'anthropic'),
readProviderStatus(ctx, 'gemini'),
]);
return { openrouter: or, anthropic: an, gemini: ge };
}
private async _handleProvidersUpdate(msg: any): Promise<void> {
const { writeProviderConfig } = require('../providers') as typeof import('../providers');
const id = msg.providerId;
if (id !== 'openrouter' && id !== 'anthropic' && id !== 'gemini') return;
const patch: any = {};
if (typeof msg.enabled === 'boolean') patch.enabled = msg.enabled;
if (typeof msg.apiKey === 'string') patch.apiKey = msg.apiKey;
if (typeof msg.defaultModel === 'string') patch.defaultModel = msg.defaultModel;
if (Object.keys(patch).length === 0) return;
await writeProviderConfig(this._deps.context, id, patch);
this._lastSuccess = `${id} 저장 완료`;
this._lastError = undefined;
await this._refreshState();
}
private async _handleAdvancedUpdate(msg: any): Promise<void> {
if (typeof msg.dryRun === 'boolean') {
await this._safeConfigUpdate('dryRun', msg.dryRun);
@@ -573,6 +621,8 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
maxContextSize: cfg.get<number>('maxContextSize', 32000) ?? 32000,
},
google: this._buildGoogleState(),
providers: await this._buildProvidersState(),
devilAgent: { enabled: cfg.get<boolean>('devilAgent.enabled', false) },
bannerError: this._bannerError,
};
const payload = { type: 'state', value: state };