Update ConnectAI codebase
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(); };
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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(근거: 추론)';
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user