v2.2.19: Cloud Model Providers Support (OpenRouter, Anthropic, Gemini)

This commit is contained in:
g1nation
2026-05-16 23:34:35 +09:00
parent c7b596f17a
commit 88664c7c6e
21 changed files with 1154 additions and 46 deletions
+187
View File
@@ -496,6 +496,73 @@ setInterval(()=>{
walkPath(k,[st.dock,pt,st.dock,[st.seatX,st.seatY]],()=>setSprite(k,'sit'));
},9000);
function activate(role){Object.keys(chars).forEach(k=>chars[k].classList.toggle('active',k===role))}
// ── 검수 시나리오: 작성자를 검수자 옆으로 데려가기 + 안전거리 ──
// 작성자(writer) 가 작업 끝낸 후 status='reviewing' 진입하면 검수자 책상 옆으로 이동.
// 다른 캐릭터들과 겹치지 않도록 안전거리(>= SAFE_DIST px) 유지하면서 자리 후보 탐색.
const SAFE_DIST = 36;
function _isClearSpot(x, y, ignoreRole){
for(const k of Object.keys(chars)){
if(k === ignoreRole) continue;
const c = chars[k]; if(!c) continue;
const cx = parseFloat(c.style.left), cy = parseFloat(c.style.top);
const dx = cx - x, dy = cy - y;
if(Math.sqrt(dx*dx + dy*dy) < SAFE_DIST) return false;
}
return true;
}
function _findSpotNear(targetX, targetY, ignoreRole){
// 검수자 책상 주변 8방향 원형 탐색 — 60px 부터 시작해 비면 즉시 채택.
const offsets = [
[60, 0], [-60, 0], [0, -60], [0, 60],
[50, -40], [-50, -40], [50, 40], [-50, 40],
[80, 0], [-80, 0], [0, -70], [0, 70],
];
for(const [dx, dy] of offsets){
const x = targetX + dx, y = targetY + dy;
// stage 경계.
if(x < 20 || y < 20 || x > (stage.offsetWidth - 60) || y > (stage.offsetHeight - 80)) continue;
if(_isClearSpot(x, y, ignoreRole)) return [x, y];
}
// 모든 후보 실패 — fallback 으로 그냥 target 옆 60px (겹쳐도 어쩔 수 없음).
return [targetX + 60, targetY];
}
/**
* writerRole 캐릭터를 reviewerRole 의 자리 근처로 walk. 도착 후 standing 자세로 face 만 회전.
* reviewing 모드 종료 시 _restoreWriterHome 으로 원위치.
*/
const _movedToReviewer = new Map(); // role → original home {x, y, face}
function _walkToReviewer(writerRole, reviewerRole){
if(!writerRole || !reviewerRole) return;
if(writerRole === reviewerRole) return;
const writerCh = chars[writerRole], reviewerCh = chars[reviewerRole];
if(!writerCh || !reviewerCh) return;
if(_movedToReviewer.has(writerRole)) return; // 이미 가 있음
const rx = parseFloat(reviewerCh.style.left), ry = parseFloat(reviewerCh.style.top);
const [tx, ty] = _findSpotNear(rx, ry, writerRole);
const a = anim[writerRole]; if(!a) return;
// home 기억해뒀다가 reviewing 끝나면 복귀.
_movedToReviewer.set(writerRole, {
x: parseFloat(writerCh.style.left), y: parseFloat(writerCh.style.top), face: a.face,
});
walkPath(writerRole, [[tx, ty]], () => {
// 도착 후 검수자 쪽 face. 좌우 위치만 비교 (목적지가 reviewer 왼쪽인지 오른쪽인지).
if(a){ a.face = tx < rx ? 'R' : 'L'; }
setSprite(writerRole, 'sit');
});
}
function _restoreWriterHome(writerRole){
const saved = _movedToReviewer.get(writerRole);
if(!saved){
// 옮긴 적 없으면 그냥 sendHome.
sendHome(writerRole, 'sit');
return;
}
_movedToReviewer.delete(writerRole);
const a = anim[writerRole]; if(a) a.face = saved.face;
// station seat 좌표로 복귀 (saved.x/y 는 옛 home 이지만 그 동안 사용자 layout 편집이 있을 수 있어 station 우선).
sendHome(writerRole, 'sit');
}
function bubble(role,text){const ch=chars[role];if(!ch||!text)return;const b=document.createElement('div');b.className='bubble';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(),2400)}
// ── 로그 → 말풍선 요약기 ──
@@ -547,6 +614,104 @@ function _bubbleFromLog(role, raw){
_lastBubbleAt[role] = now;
bubble(role, txt);
}
// ── 속마음 라이브러리 (refactor: inner-thought bubbles) ──
// 각 직군이 작업 중 (mode='work') 무슨 생각을 하는지 — 자동 주기 emitter 가 6~9초마다
// 활성 character 중 한 명을 골라 한 줄 띄움. 사용자가 "지금 이 캐릭터가 뭘 고민 중인지"
// 느낌으로 알게. 로그 기반 _bubbleFromLog 와는 독립 — 둘 다 같은 throttle gate 통과.
const INNER_THOUGHTS = {
ceo: {
work: ['전체 그림 다시 한번', '리스크는?', '우선순위 재배치', '누구한테 맡길까', '결정 미루지 말자'],
sit: ['오늘 일정 확인…', '보고서 정리 시작', '커피 한 모금'],
},
planner: {
work: ['핵심 시나리오 빠진 거 없나', '측정 가능한 기준 추가', '범위 너무 넓어졌어', '한 문단으로 줄여보자', '독자 입장에서…'],
sit: ['오늘 뭐부터 시작할까', '레퍼런스 한 번 더', '메모 다시 보자'],
},
researcher: {
work: ['출처 한 번 더 확인', '비슷한 사례 있나', '데이터 vs 추측', '반증 케이스 찾자', '이거 일반론 아냐?'],
sit: ['검색 키워드 다듬기', '논문 한 편 더', '북마크 정리'],
},
designer: {
work: ['시각 위계 맞나', '여백 더 줄까', '컬러 톤 일관성', '모바일에선 어떻게', '사용자 흐름 막힘 없나'],
sit: ['inspiration 좀…', '컴포넌트 라이브러리 봐야', '피그마 정리'],
},
developer: {
work: ['엣지 케이스 빠짐', '이거 깨질 거 같은데', '함수 너무 길어', '테스트부터 쓸까', '에러 처리 누락'],
sit: ['이슈 목록 훑어', '리팩토링 백로그', '커밋 메시지 다듬기'],
},
qa: {
work: ['이 케이스는 어떻게', '입력 비어있으면', '동시성 문제 가능', 'race condition', '경계값 한 번 더'],
sit: ['테스트 시나리오 정리', '회귀 케이스 추가', '버그 재현 절차'],
},
inspector: {
work: ['기획 의도 일치 여부', '과한 over-engineering', '시나리오 누락', '측정 기준 명확한가', '재작업 여부 판단'],
sit: ['이전 검토 메모…', '체크리스트 정리', '기준 다듬기'],
},
support: {
work: ['일정 충돌 확인', '담당 명확한가', '리마인더 시점', '회의록 정리', '이해관계자 누락'],
sit: ['오늘 처리할 것…', '캘린더 한 번 더', '미답 답장 확인'],
},
};
function _innerThoughtFor(agentKey, mode){
const bucket = INNER_THOUGHTS[agentKey];
if(!bucket) return null;
const pool = bucket[mode] || bucket.work || [];
if(!pool.length) return null;
return pool[Math.floor(Math.random()*pool.length)];
}
// 검수 중 작성자가 inspector 옆에 있을 때 띄울 "긴장한 작성자" 속마음 pool.
const REVIEW_NERVOUS_THOUGHTS = [
'괜찮을까…', '재작업 안 나왔으면', '핵심은 다 들어갔지', '여기 빠뜨린 거 있나', '한 라운드면 통과해줘',
];
// 검수자가 keyword 형식으로 던지는 결론 어휘 — 로그 없을 때도 자체 emit.
const REVIEWER_KEYWORD_BANK = [
['시나리오 부족', '핵심 누락', '재작업 필요'],
['승인', '기준 충족', '진행 OK'],
['일반론', '구체화 필요', '예시 부족'],
['중복', '간결화', 'over-engineering'],
['엣지케이스', '경계값', '실패 케이스'],
['측정 기준', '성공 정의', 'KPI'],
];
// 6~9초 random 간격으로 활성 character 중 하나에서 속마음 emit. mode 가 work / sit 인 것만.
// 한 tick 에 *한 명만* 골라서 띄움 — 동시에 여러 풍선 뜨면 시각 부담.
function _innerThoughtTick(){
// Reviewing 중: 검수자 본인은 keyword 던지고, 옮겨와 있는 작성자는 nervous thought.
if(_prevStatus === 'reviewing'){
const inspectorRole = roleMap['inspector'];
if(inspectorRole && chars[inspectorRole]){
// 50% 확률로 검수자가 keyword 던지기.
if(Math.random() < 0.5){
const set = REVIEWER_KEYWORD_BANK[Math.floor(Math.random()*REVIEWER_KEYWORD_BANK.length)];
const txt = set.join(' · ');
_bubbleFromLog(inspectorRole, txt);
return;
}
}
// 작성자 측 긴장 표현.
for(const r of _movedToReviewer.keys()){
if(Math.random() < 0.7){
const t = REVIEW_NERVOUS_THOUGHTS[Math.floor(Math.random()*REVIEW_NERVOUS_THOUGHTS.length)];
_bubbleFromLog(r, t);
return;
}
}
}
// 일반: 작업 중 또는 sit 인 char 중 1명에서 속마음.
const candidates = Object.keys(chars).filter(k => {
const a = anim[k]; if(!a) return false;
return a.mode === 'work' || a.mode === 'sit';
});
if(candidates.length === 0) return;
const working = candidates.filter(k => anim[k].mode === 'work');
const pool = working.length > 0 ? working : candidates;
const role = pool[Math.floor(Math.random()*pool.length)];
const st = stationByKey[role]; if(!st) return;
const text = _innerThoughtFor(st.agentKey, anim[role].mode);
if(text) _bubbleFromLog(role, text);
}
setInterval(_innerThoughtTick, 7500);
// ── A. 상태 계층화 ──
// 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함.
// 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고,
@@ -588,6 +753,8 @@ function _pct(v){
return Math.max(0, Math.min(100, Math.round((typeof v === 'number' ? v : 0) * 100)));
}
let _prevStatus = null;
// 마지막으로 inspector 가 아니었던 active role — reviewing 진입 시 *작성자* 후보.
let _lastWriterRole = null;
let _lastRenderedLog = null;
function apply(s){
_lastState = s; // D. 컨텍스트 메뉴 / 세부보기에서 사용.
@@ -682,9 +849,29 @@ function apply(s){
if(st === 'reviewing') role = roleMap['inspector'] || role;
// 활성 outline은 *항상* 즉시 반영 (시뮬레이션 없이도 누가 일하는지 보임).
activate(role);
// inspector 가 아닌 active role 이 있으면 *작성자 후보* 로 기억 — 다음 reviewing 진입 시 이 사람이 검수자 옆으로 이동.
if(role && stationByKey[role] && stationByKey[role].agentKey !== 'inspector'){
_lastWriterRole = role;
}
// 시뮬레이션은 *status 전환 + critical 진입* 에서만.
const isPivot = CRITICAL_STATUSES.has(st);
const isTransition = st !== _prevStatus;
// ── Reviewing 진입 / 종료 시 작성자 이동 ──
// status='reviewing' 진입: 직전 작성자(_lastWriterRole) 캐릭터를 검수자 책상 옆으로 walk.
// status='reviewing' 종료: 옮겨놓은 작성자들을 모두 원래 자리로 복귀.
if(isTransition){
if(st === 'reviewing'){
const inspectorRole = roleMap['inspector'];
if(_lastWriterRole && inspectorRole && _lastWriterRole !== inspectorRole){
_walkToReviewer(_lastWriterRole, inspectorRole);
}
} else if(_prevStatus === 'reviewing'){
// 검수 종료 — 옮겨놓은 모든 char 를 원위치.
for(const r of Array.from(_movedToReviewer.keys())){
_restoreWriterHome(r);
}
}
}
if(isTransition){
// 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
Object.keys(chars).forEach(k => {