ded3eea7ce
주요 변경: [chunked writer 아키텍처 (v2.2.74~v2.2.75)] - 5-stage 다중 에이전트(planner/researcher/reflector/writer/synthesizer) 파이프라인 제거 → 단일 ChunkedWriter 의 outline → section[N] → polish 3-step 으로 교체. 본문 분석에서 추상화 손실 / 토큰 폭증 문제 해소 - 답변 길이 자동 분기: 짧은 prompt 는 fast-path direct 1회 호출, 본문 분석은 chunked. outline 빈 배열도 direct 폴백 [코드 리뷰 9개 항목 일괄 패치 (v2.2.76)] - /research polling hang 방어 (heartbeat + status 정규화 + 연속 실패 abort) - 회사 모드 dispatcher abort 신호를 AIService.chat 까지 전달 - bridgeFetch 에 onHeartbeat 콜백 도입 (slow endpoint 사용자 친화적) - dead code 정리: reflectionPersister.ts 제거 + enableReflection 등 좀비 config 키 - parseOutline 의 empty vs fallback reason 명시적 분리 - chatHandlers 의 회사 모드 케이스 ~325줄을 src/sidebar/companyHandlers.ts 로 분리 - Intent Alignment 라운드 한도 도달 시 smart 모드 자동 진행 - LM Studio doSwitch unload 실패 시 currentModel 정리 + load 강행 - retrieval informationDensity → queryCoverage 정합화 [/youtube 채널 지원 (v2.2.77~v2.2.82)] - 채널/플레이리스트 URL 자동 감지 + n:N 으로 영상 개수 지정 (최대 50) - 채널 루트 URL 에 /videos 탭 자동 append (yt-dlp enumeration 정상화) - 영상별 순차 처리 (queue 패턴) + i/N 진행 표시 + 마지막 통계 요약 - mode:info / mode:benchmark / mode:both 분석 모드 분기 - info: 영상 내용을 지식 카드로 추출 (튜토리얼·강의·뉴스용) - benchmark: 4-렌즈 대본 역기획서 (콘텐츠 제작 벤치마크용) - both: 둘 다 (기본) - bare keyword 도 허용: /youtube <url> n:1 info - bridge 에러 메시지 [object Object] 깨짐 수정 (구조화 에러 추출) - "패키지 없음" 등 환경 의존성 에러에 자동 가이드 첨부 [Astra: Setup Datacollect Dependencies 명령 추가 (v2.2.80)] - Python 자동 감지 + yt-dlp / youtube-transcript-api 자동 설치 - macOS PEP 668 환경 자동 폴백 (--user --break-system-packages) - /youtube 등에서 패키지 미설치 감지 시 "Install Now" 버튼 notification [테스트] - tests/agentEngine.test.ts 를 chunked flow 에 맞춰 전체 재작성 - tests/resilience_stress.test.ts Scenario B/D 를 role-aware mock 으로 갱신 - 399/399 통과 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
348 lines
18 KiB
TypeScript
348 lines
18 KiB
TypeScript
import { SidebarChatProvider } from '../sidebarProvider';
|
|
|
|
/**
|
|
* 1인 기업 모드 도메인 메시지 핸들러.
|
|
*
|
|
* 의도: chatHandlers 가 한때 모든 카테고리의 webview 메시지를 처리했는데, 회사
|
|
* 모드가 ~30개의 메시지 타입(getCompanyStatus / setCompanyAgentDisplay / resume /
|
|
* alignment / approval / pipeline / pixelOffice …)을 끌고 들어오면서 single
|
|
* file 이 700+ 줄로 부풀었다. chronicleHandlers 처럼 도메인별 분리 패턴이 이미
|
|
* 시작돼 있어서 회사 모드도 같은 모양으로 떼어낸다.
|
|
*
|
|
* 처리한 케이스는 true 반환 — chatHandlers 가 이걸 보고 LLM fallback 으로 안 흘림.
|
|
* 해당 도메인이 아니면 false → chatHandlers 가 그 다음 분기 진행.
|
|
*/
|
|
export async function handleCompanyMessage(
|
|
provider: SidebarChatProvider,
|
|
data: any,
|
|
): Promise<boolean> {
|
|
switch (data.type) {
|
|
case 'getCompanyStatus':
|
|
await provider._sendCompanyStatus();
|
|
return true;
|
|
case 'getCompanyAgents':
|
|
await provider._sendCompanyAgents();
|
|
return true;
|
|
case 'getCompanyResumable':
|
|
await provider._sendCompanyResumable();
|
|
return true;
|
|
case 'resumeCompanyTurn': {
|
|
// 사용자가 "이어서 진행" 칩을 눌렀을 때. timestamp만 받아서 디스크의
|
|
// _resume.json을 읽고 그 다음 stage부터 dispatch가 이어진다.
|
|
const ts = typeof data.timestamp === 'string' ? data.timestamp : '';
|
|
if (!ts) return true;
|
|
// userPrompt 인자는 resume 경로에서 무시되지만(plan은 디스크에서 복원)
|
|
// 시그니처 일관성을 위해 dummy 값을 전달.
|
|
void provider._runCompanyTurn('', ts);
|
|
return true;
|
|
}
|
|
case 'discardResumableSession': {
|
|
// 사용자가 명시적으로 재개 항목을 버리고 싶을 때 — resume 파일을 'failed'로
|
|
// 마킹해서 listResumable에서 자동 제외. markResumeStatus가 안전한 idempotent
|
|
// 작업이라 별도 검증 불필요.
|
|
const ts = typeof data.timestamp === 'string' ? data.timestamp : '';
|
|
if (!ts) return true;
|
|
try {
|
|
const { resolveSessionDir } = await import('../features/company');
|
|
const { markResumeStatus } = await import('../features/company/resumeStore');
|
|
markResumeStatus(resolveSessionDir(provider._context, ts), 'failed', 'discarded-by-user');
|
|
} catch { /* 무시 — 다음 푸시에서 자연 복구 */ }
|
|
await provider._sendCompanyResumable();
|
|
return true;
|
|
}
|
|
case 'setCompanyEnabled': {
|
|
const { setCompanyEnabled } = await import('../features/company');
|
|
await setCompanyEnabled(provider._context, !!data.value);
|
|
await provider._sendCompanyStatus();
|
|
return true;
|
|
}
|
|
case 'setCompanyName': {
|
|
const { setCompanyName } = await import('../features/company');
|
|
await setCompanyName(provider._context, typeof data.value === 'string' ? data.value : '');
|
|
await provider._sendCompanyStatus();
|
|
return true;
|
|
}
|
|
case 'setCompanyActiveAgents': {
|
|
const { setActiveAgents } = await import('../features/company');
|
|
const ids = Array.isArray(data.value)
|
|
? data.value.filter((v: unknown): v is string => typeof v === 'string')
|
|
: [];
|
|
await setActiveAgents(provider._context, ids);
|
|
await provider._sendCompanyStatus();
|
|
await provider._sendCompanyAgents();
|
|
return true;
|
|
}
|
|
case 'setCompanyAgentModel': {
|
|
const { setAgentModelOverride } = await import('../features/company');
|
|
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
|
|
const model = typeof data.model === 'string' ? data.model : '';
|
|
if (agentId) {
|
|
await setAgentModelOverride(provider._context, agentId, model);
|
|
await provider._sendCompanyAgents();
|
|
}
|
|
return true;
|
|
}
|
|
case 'setCompanyAgentDisplay': {
|
|
// 이름/역할/이모지/색상 override. 페이로드는 setCompanyAgentPrompt와
|
|
// 동일한 패턴 — null이면 전체 reset, 각 필드 빈 문자열이면 그 필드만 reset.
|
|
const { setAgentDisplayOverride } = await import('../features/company');
|
|
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
|
|
if (!agentId) return true;
|
|
const v = data.override;
|
|
const override = v === null
|
|
? null
|
|
: {
|
|
name: typeof v?.name === 'string' ? v.name : undefined,
|
|
role: typeof v?.role === 'string' ? v.role : undefined,
|
|
emoji: typeof v?.emoji === 'string' ? v.emoji : undefined,
|
|
color: typeof v?.color === 'string' ? v.color : undefined,
|
|
};
|
|
const result = await setAgentDisplayOverride(provider._context, agentId, override);
|
|
provider._view?.webview.postMessage({
|
|
type: 'setCompanyAgentDisplayResult',
|
|
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
|
|
});
|
|
if (result.ok) await provider._sendCompanyAgents();
|
|
return true;
|
|
}
|
|
case 'setCompanyAgentRoleCategory': {
|
|
// Override an agent's 직군. Empty / null payload value reverts to
|
|
// the def's own roleCategory. CEO is rejected by the backend.
|
|
const { setAgentRoleCategory } = await import('../features/company');
|
|
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
|
|
if (!agentId) return true;
|
|
const cat = (typeof data.value === 'string' && data.value.trim()) ? data.value.trim() : null;
|
|
const result = await setAgentRoleCategory(provider._context, agentId, cat as any);
|
|
provider._view?.webview.postMessage({
|
|
type: 'setCompanyAgentRoleCategoryResult',
|
|
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
|
|
});
|
|
if (result.ok) await provider._sendCompanyAgents();
|
|
return true;
|
|
}
|
|
case 'setCompanyAgentKnowledgeMix': {
|
|
// Per-agent Knowledge Mix override. `null`/missing value falls
|
|
// back to the global slider. The dispatcher reads this on the
|
|
// *next* turn — no restart required.
|
|
const { setAgentKnowledgeMix } = await import('../features/company');
|
|
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
|
|
if (!agentId) return true;
|
|
const raw = data.value;
|
|
const weight = (raw === null || raw === undefined || !Number.isFinite(Number(raw)))
|
|
? null
|
|
: Math.max(0, Math.min(100, Math.round(Number(raw))));
|
|
await setAgentKnowledgeMix(provider._context, agentId, weight);
|
|
await provider._sendCompanyAgents();
|
|
return true;
|
|
}
|
|
case 'setCompanyAgentPrompt': {
|
|
// Patch one agent's persona / specialty / tagline. Each field is
|
|
// optional in the payload; passing an *empty string* explicitly
|
|
// clears that field (back to the default from `agents.ts`).
|
|
// Sending `null` for the whole override resets every field at once.
|
|
const { setAgentPromptOverride } = await import('../features/company');
|
|
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
|
|
if (!agentId) return true;
|
|
const v = data.override;
|
|
const override = v === null
|
|
? null
|
|
: {
|
|
persona: typeof v?.persona === 'string' ? v.persona : undefined,
|
|
specialty: typeof v?.specialty === 'string' ? v.specialty : undefined,
|
|
tagline: typeof v?.tagline === 'string' ? v.tagline : undefined,
|
|
};
|
|
await setAgentPromptOverride(provider._context, agentId, override);
|
|
await provider._sendCompanyAgents();
|
|
return true;
|
|
}
|
|
case 'addCompanyAgent': {
|
|
// User-defined agent. Payload: { def: CompanyAgentDef }. Returns
|
|
// an `addCompanyAgentResult` so the UI overlay can keep its form
|
|
// open + show an error when validation fails (id collision etc.).
|
|
const { addCustomAgent } = await import('../features/company');
|
|
const def = data.def;
|
|
const result = await addCustomAgent(provider._context, def ?? {});
|
|
provider._view?.webview.postMessage({
|
|
type: 'addCompanyAgentResult',
|
|
value: result.ok
|
|
? { ok: true, agentId: def?.id }
|
|
: { ok: false, reason: result.reason },
|
|
});
|
|
if (result.ok) {
|
|
await provider._sendCompanyStatus();
|
|
await provider._sendCompanyAgents();
|
|
}
|
|
return true;
|
|
}
|
|
case 'deleteCompanyAgent': {
|
|
// Delete any agent (built-in via hide, custom via outright removal).
|
|
// Backend checks pipeline usage and refuses if any stage references it.
|
|
const { removeCompanyAgent } = await import('../features/company');
|
|
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
|
|
if (!agentId) return true;
|
|
const result = await removeCompanyAgent(provider._context, agentId);
|
|
provider._view?.webview.postMessage({
|
|
type: 'deleteCompanyAgentResult',
|
|
value: result.ok
|
|
? { ok: true, agentId, kind: result.kind }
|
|
: { ok: false, agentId, reason: result.reason },
|
|
});
|
|
if (result.ok) {
|
|
await provider._sendCompanyStatus();
|
|
await provider._sendCompanyAgents();
|
|
}
|
|
return true;
|
|
}
|
|
case 'restoreHiddenAgent': {
|
|
// Bring a previously-hidden built-in back into the manage panel.
|
|
const { restoreHiddenAgent } = await import('../features/company');
|
|
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
|
|
if (!agentId) return true;
|
|
const result = await restoreHiddenAgent(provider._context, agentId);
|
|
provider._view?.webview.postMessage({
|
|
type: 'restoreHiddenAgentResult',
|
|
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
|
|
});
|
|
if (result.ok) {
|
|
await provider._sendCompanyStatus();
|
|
await provider._sendCompanyAgents();
|
|
}
|
|
return true;
|
|
}
|
|
case 'getCompanyPipelines':
|
|
await provider._sendCompanyPipelines();
|
|
return true;
|
|
case 'upsertCompanyPipeline': {
|
|
const { upsertPipeline } = await import('../features/company');
|
|
const result = await upsertPipeline(provider._context, data.def ?? {});
|
|
provider._view?.webview.postMessage({
|
|
type: 'upsertCompanyPipelineResult',
|
|
value: result.ok ? { ok: true } : { ok: false, reason: result.reason },
|
|
});
|
|
if (result.ok) await provider._sendCompanyPipelines();
|
|
return true;
|
|
}
|
|
case 'deleteCompanyPipeline': {
|
|
const { deletePipeline } = await import('../features/company');
|
|
const pid = typeof data.pipelineId === 'string' ? data.pipelineId : '';
|
|
if (!pid) return true;
|
|
const result = await deletePipeline(provider._context, pid);
|
|
provider._view?.webview.postMessage({
|
|
type: 'deleteCompanyPipelineResult',
|
|
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
|
|
});
|
|
if (result.ok) await provider._sendCompanyPipelines();
|
|
return true;
|
|
}
|
|
case 'getCompanyPipelineTemplate': {
|
|
// Returns a template's stages so the editor can pre-fill the form.
|
|
const { getPipelineTemplate } = await import('../features/company');
|
|
const tplId = typeof data.templateId === 'string' ? data.templateId : '';
|
|
const tpl = getPipelineTemplate(tplId);
|
|
provider._view?.webview.postMessage({
|
|
type: 'companyPipelineTemplateContent',
|
|
value: tpl ? {
|
|
templateId: tpl.templateId,
|
|
suggestedPipelineId: tpl.suggestedPipelineId,
|
|
suggestedPipelineName: tpl.suggestedPipelineName,
|
|
stages: tpl.stages,
|
|
} : null,
|
|
});
|
|
return true;
|
|
}
|
|
case 'getPixelOfficeState':
|
|
// webview가 처음 로드되었거나 사용자가 토글 ON 했을 때 캐시된
|
|
// 현재 상태를 다시 받기 위한 요청. read-only.
|
|
provider.pixelOfficeResend();
|
|
return true;
|
|
case 'openPixelOfficePanel':
|
|
// 사이드바 mini Pixel Office의 ⛶ 버튼 → editor area에 전체보기 panel 열기.
|
|
provider.openPixelOfficePanel();
|
|
return true;
|
|
case 'respondCompanyAlignment': {
|
|
// alignment 카드 버튼: 'proceed' = 현 contract로 dispatch, 'cancel' = 폐기.
|
|
const decision = typeof data.decision === 'string' ? data.decision : '';
|
|
if (decision === 'proceed') {
|
|
await provider._proceedWithCurrentAlignment();
|
|
} else if (decision === 'cancel') {
|
|
provider.cancelPendingAlignment();
|
|
}
|
|
return true;
|
|
}
|
|
case 'respondCompanyApproval': {
|
|
// Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제.
|
|
// payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? }
|
|
const stageId = typeof data.stageId === 'string' ? data.stageId : '';
|
|
const decision = typeof data.decision === 'string' ? data.decision : '';
|
|
if (!stageId || !['approve', 'revise', 'abort'].includes(decision)) return true;
|
|
let payload: any;
|
|
if (decision === 'approve') payload = { kind: 'approve' };
|
|
else if (decision === 'abort') payload = { kind: 'abort' };
|
|
else payload = { kind: 'revise', comment: typeof data.comment === 'string' ? data.comment : '' };
|
|
provider.resolveApprovalGate(stageId, payload);
|
|
return true;
|
|
}
|
|
case 'setActiveCompanyPipeline': {
|
|
const { setActivePipeline } = await import('../features/company');
|
|
const pid = typeof data.pipelineId === 'string' && data.pipelineId.trim()
|
|
? data.pipelineId.trim()
|
|
: null;
|
|
const result = await setActivePipeline(provider._context, pid);
|
|
provider._view?.webview.postMessage({
|
|
type: 'setActiveCompanyPipelineResult',
|
|
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
|
|
});
|
|
if (result.ok) await provider._sendCompanyPipelines();
|
|
return true;
|
|
}
|
|
case 'setCompanyScopePreset': {
|
|
// 스코프 프리셋 클릭 — templateId (plan-only / dev-only / full-product-dev) 받아서:
|
|
// 1) suggestedPipelineId 의 pipeline 이 state.pipelines 에 없으면 template stamp
|
|
// 2) activePipelineId 를 그 id 로 설정
|
|
// 이미 stamp 된 pipeline 이라면 stage 사용자 편집을 *유지* (활성화만).
|
|
const { getPipelineTemplate, upsertPipeline, setActivePipeline, readCompanyState } =
|
|
await import('../features/company');
|
|
const tplId = typeof data.templateId === 'string' ? data.templateId : '';
|
|
const tpl = getPipelineTemplate(tplId);
|
|
if (!tpl) {
|
|
provider._view?.webview.postMessage({
|
|
type: 'setCompanyScopePresetResult',
|
|
value: { ok: false, reason: `알 수 없는 템플릿: ${tplId}` },
|
|
});
|
|
return true;
|
|
}
|
|
const state = readCompanyState(provider._context);
|
|
if (!state.pipelines || !state.pipelines[tpl.suggestedPipelineId]) {
|
|
const stampDef = {
|
|
id: tpl.suggestedPipelineId,
|
|
name: tpl.suggestedPipelineName,
|
|
// stage 는 deep clone — 템플릿 read-only 원본 보호.
|
|
stages: tpl.stages.map((s) => ({ ...s })),
|
|
};
|
|
const stamp = await upsertPipeline(provider._context, stampDef);
|
|
if (!stamp.ok) {
|
|
provider._view?.webview.postMessage({
|
|
type: 'setCompanyScopePresetResult',
|
|
value: { ok: false, reason: stamp.reason },
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
const activate = await setActivePipeline(provider._context, tpl.suggestedPipelineId);
|
|
provider._view?.webview.postMessage({
|
|
type: 'setCompanyScopePresetResult',
|
|
value: activate.ok
|
|
? { ok: true, pipelineId: tpl.suggestedPipelineId, templateId: tplId }
|
|
: { ok: false, reason: activate.reason },
|
|
});
|
|
if (activate.ok) {
|
|
await provider._sendCompanyStatus();
|
|
await provider._sendCompanyPipelines();
|
|
}
|
|
return true;
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
}
|