Files
connectai/src/sidebar/chatHandlers.ts
T
g1nation ded3eea7ce feat: v2.2.74 → v2.2.82 — chunked writer + 코드 리뷰 패치 + /youtube 확장
주요 변경:

[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>
2026-05-23 23:13:21 +09:00

418 lines
23 KiB
TypeScript

import * as vscode from 'vscode';
import * as path from 'path';
import { SidebarChatProvider } from '../sidebarProvider';
import { getActiveBrainProfile, logInfo } from '../utils';
import { pickConfigTarget } from '../lib/paths';
/**
* Handles chat-domain messages: prompts, model selection, sessions, streaming control,
* generic webview transport (export, settings, addMessage), action approvals, and the
* cross-cutting `ready` bootstrap.
*
* Returns true when the message was handled by this domain, false otherwise — the
* caller chains domain handlers until one accepts the message.
*/
export async function handleChatMessage(provider: SidebarChatProvider, data: any): Promise<boolean> {
switch (data.type) {
case 'prompt':
case 'promptWithFile':
logInfo(`[ASTRA-DEBUG] prompt case entered type=${data?.type} value=${JSON.stringify(String(data?.value ?? '').slice(0, 80))}`);
provider._lmStudio?.activity.bump();
// ── 📻 Datacollect Radio (slash 명령) 우선 분기 ──
// 주의: globalState.update보다 *먼저* 잡는다 — 글로벌 state가 ~1MB까지
// 누적된 환경에서 update가 느려 첫 prompt가 hang하는 사례 보고됨. slash
// 명령은 LLM을 우회하니 blank chat state 갱신도 필요 없음.
if (typeof data.value === 'string') {
const { isSlashCommand, handleSlashCommand } = await import('../features/datacollect/slashRouter');
const matched = isSlashCommand(data.value);
logInfo(`[ASTRA-DEBUG] slash check matched=${matched} hasView=${!!provider._view}`);
logInfo(`[SLASH] prompt received: ${JSON.stringify(data.value).slice(0, 100)} matched=${matched} hasView=${!!provider._view}`);
if (matched) {
if (!provider._view?.webview) {
const msg = '📻 Datacollect Radio: 채팅 webview가 활성 상태가 아닙니다. Astra 사이드바를 한 번 열고 다시 시도해 주세요.';
await vscode.window.showWarningMessage(msg);
logInfo(`[SLASH] webview not available — aborting`);
return true;
}
logInfo(`[SLASH] handleSlashCommand entering`);
await handleSlashCommand(data.value, provider._view.webview, provider._context);
logInfo(`[SLASH] handleSlashCommand returned`);
return true;
}
}
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
// ── 1인 기업 모드 우선 분기 ──
// 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜
// (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅
// 경로 + followup 라벨, (c) 신규 업무 → 풀 파이프라인 dispatch.
// classifier가 비활성화돼 있거나 호출 실패 시엔 안전하게 new_task로
// 폴백 — 즉 사용자가 의도한 작업 요청을 놓치는 일은 절대 없다.
if (provider.isCompanyModeEnabled() && typeof data.value === 'string' && data.value.trim()) {
let userPrompt = data.value.trim();
const { getConfig } = await import('../config');
const cfg = getConfig();
const { readCompanyState, resolveActivePipeline } = await import('../features/company');
const state = readCompanyState(provider._context);
// ── alignment 답변 라우팅 ──
// 사용자가 이전 메시지에서 alignment 카드를 받아 답변하는 중이면
// 이 메시지를 분류기/dispatch가 아니라 alignment 답변 핸들러로
// 보낸다. 답변이 처리되면서 자동으로 다음 라운드 또는 pipeline
// 으로 진행됨.
if (provider.isAlignmentPending()) {
await provider._handleAlignmentAnswer(userPrompt);
return true;
}
// ── 사용자 키워드 override ──
// 입력 맨 앞에 `[파이프라인:id]` 또는 `[pipeline:id]`가 있으면
// 분류기 무관하게 그 파이프라인 강제 + 그 키워드는 prompt에서
// 제거 후 dispatcher에 전달. id가 유효하지 않으면 무시(분류기 정상 경로).
let keywordOverrideId: string | undefined;
const keywordMatch = userPrompt.match(/^\s*\[(?:파이프라인|pipeline)\s*:\s*([a-z0-9_-]+)\s*\]\s*/i);
if (keywordMatch) {
const id = keywordMatch[1].toLowerCase();
if (state.pipelines?.[id]) {
keywordOverrideId = id;
userPrompt = userPrompt.slice(keywordMatch[0].length).trim() || userPrompt;
}
}
// ── alignment bypass 키워드 ──
// 입력 맨 앞 `[건너뛰기]` 또는 `[skip]` → alignment 단계 1회 우회.
// 사용자가 "지금은 빨리 가자"라고 명시한 경우에만 사용. prompt에서
// 키워드 제거.
let alignmentBypass = false;
const bypassMatch = userPrompt.match(/^\s*\[(?:건너뛰기|skip)\]\s*/i);
if (bypassMatch) {
alignmentBypass = true;
userPrompt = userPrompt.slice(bypassMatch[0].length).trim() || userPrompt;
}
if (cfg.companyDisableIntentClassifier) {
// 분류기 우회 모드 — 분류 단계는 건너뛰지만 alignment는 별도로
// 작동(사용자가 alignment off로 설정하지 않은 한). 분류기 끄는
// 이유는 보통 "잡담도 다 pipeline으로"인데 그럴수록 alignment
// 효과가 큼.
try { provider.pixelOfficeOnIntentClassified('new_task', userPrompt); } catch { /* noop */ }
if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) {
await provider._runCompanyTurn(userPrompt, undefined, keywordOverrideId);
} else {
await provider._runIntentAlignment({
userPrompt,
pipelineIdOverride: keywordOverrideId,
mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart',
roundsLimit: cfg.companyIntentAlignmentMaxRounds,
roundsAsked: 0,
});
}
return true;
}
const { classifyChatIntent } = await import('../features/company');
const { AIService } = await import('../core/services');
const last = provider.getLastCompanyTurnSummary();
const activePipeline = resolveActivePipeline(state);
// 사용 가능한 모든 파이프라인을 분류기 후보로 전달 — 단, 활성화돼
// 있어야 추천 의미가 있는 게 아니라 *정의돼 있기만 하면* 후보. 사용자가
// 평소엔 짧은 걸 활성화해 두고 가끔 풀 사이클 의도가 명확한 발화를
// 했을 때 분류기가 그쪽을 추천할 수 있게.
const allPipelines = Object.values(state.pipelines ?? {});
const verdict = await classifyChatIntent(
new AIService(),
userPrompt,
{
previousBrief: last?.brief,
previousReportTail: last?.reportTail,
previousTurnAt: last?.finishedAt,
activePipelineName: activePipeline?.name,
availablePipelines: allPipelines.length > 0
? allPipelines.map((p) => ({
id: p.id,
name: p.name,
stageCount: p.stages.length,
}))
: undefined,
},
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
);
// Pixel Office: 분류 결과를 UI layer로만 흘림. 아래 분기 자체엔 영향 없음.
try { provider.pixelOfficeOnIntentClassified(verdict.intent, userPrompt); } catch { /* noop */ }
if (verdict.intent === 'new_task') {
// 우선순위: (1) 사용자 키워드 (2) autoSelect가 켜져 있고 분류기 추천 있음 (3) 사용자 활성 파이프라인.
let effectiveOverride = keywordOverrideId;
if (!effectiveOverride && cfg.companyAutoSelectPipeline && verdict.suggestedPipelineId) {
effectiveOverride = verdict.suggestedPipelineId;
}
// 분류기가 추천을 냈지만 autoSelect가 꺼져 있을 땐 라벨로만 안내.
if (verdict.suggestedPipelineId && !effectiveOverride && !cfg.companyAutoSelectPipeline) {
const tip = state.pipelines?.[verdict.suggestedPipelineId];
if (tip) {
provider._view?.webview.postMessage({
type: 'companyIntentDecision',
value: {
intent: 'new_task',
reason: `🧭 추천 파이프라인: "${tip.name}" (자동 적용은 설정 토글)`,
label: '🛠️ 신규 업무',
},
});
}
} else if (effectiveOverride && effectiveOverride !== state.activePipelineId) {
const used = state.pipelines?.[effectiveOverride];
if (used) {
provider._view?.webview.postMessage({
type: 'companyIntentDecision',
value: {
intent: 'new_task',
reason: keywordOverrideId
? `🔧 키워드 override → "${used.name}"`
: `🧭 CEO 자동 선택 → "${used.name}"`,
label: '🛠️ 신규 업무',
},
});
}
}
// ── Intent Alignment 진입 ──
// off 모드이거나 bypass 키워드가 있으면 alignment 우회하고
// legacy 동작 (즉시 dispatch). 그 외엔 분석기 1라운드 돌려
// confidence에 따라 자동 진행 또는 카드 표시.
if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) {
await provider._runCompanyTurn(userPrompt, undefined, effectiveOverride);
} else {
await provider._runIntentAlignment({
userPrompt,
pipelineIdOverride: effectiveOverride,
mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart',
roundsLimit: cfg.companyIntentAlignmentMaxRounds,
roundsAsked: 0,
});
}
} else {
await provider._handleCompanyCasual(userPrompt, verdict.intent, verdict.reason, data);
}
return true;
}
try {
await provider._handlePrompt(data);
await provider._autoWriteChronicleAfterPrompt();
} finally {
// Persist the session even if _handlePrompt or the chronicle
// auto-write throws *after* the answer already streamed —
// otherwise the reply shows in the UI but never lands in the
// 기록(Chat History) list. This is the regression that made
// recent conversations stop appearing.
await provider._saveCurrentSession();
}
return true;
case 'activity':
provider._lmStudio?.activity.bump();
return true;
case 'ready':
await provider._sendBrainStatus();
await provider._sendBrainProfiles();
await provider._sendSessionList();
await provider._sendModels();
await provider._sendChronicleProjects();
await provider._restoreActiveSessionIntoView();
await provider._sendReadyStatus();
// Restore the Project Architecture chip + watcher if the active project
// was already running in architecture mode in a previous VS Code session.
await provider._sendArchitectureStatus();
// Restore the Company chip from globalState so the user sees the same
// mode they had on at last shutdown.
await provider._sendCompanyStatus();
// Pixel Office — 첫 로드 시 빈 idle 상태라도 한 번 push해서 webview가
// 영역 자체를 그릴 수 있게.
provider.pixelOfficeResend();
return true;
case 'getReadyStatus':
await provider._sendReadyStatus();
return true;
case 'createLessonFromConversation':
await vscode.commands.executeCommand('g1nation.lesson.fromConversation');
return true;
case 'manageLessons':
await vscode.commands.executeCommand('g1nation.lesson.manage');
return true;
case 'getModels':
await provider._sendModels();
return true;
case 'getSessions':
await provider._sendSessionList();
return true;
case 'newChat':
provider._currentSessionId = null;
provider._currentSessionBrainId = getActiveBrainProfile().id;
provider._agent.resetConversation();
await provider._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
await provider._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
// 직전 회사 turn 컨텍스트 캐시 비우기 — 새 세션은 followup 기준점이 없다.
provider.clearLastCompanyTurnSummary();
// 진행 중이던 alignment도 새 세션과 함께 폐기.
provider.cancelPendingAlignment();
provider.clearChat();
await provider._sendBrainStatus();
return true;
case 'stopGeneration':
// 1인 기업 모드는 AgentExecutor를 거치지 않으므로 별도 abort 경로.
// 두 경로 모두 신호를 보내 두면 중간에 모드 전환되어도 안전.
provider.abortCompanyTurn();
// 진행 중인 Intent Alignment도 같이 정리 — 사용자가 Stop 누르면
// 의도상 모든 대기 상태 해제.
provider.cancelPendingAlignment();
provider._agent.stop();
return true;
case 'loadSession':
// 세션 전환 시 직전 회사 turn 캐시 무효화 — 로드된 세션의 직전 회사
// 작업은 별도 파일에서 복원되어야 하지 메모리 캐시에 남은 다른
// 세션의 보고서가 새 세션 첫 메시지의 followup 판정을 오염시키면 안 됨.
provider.clearLastCompanyTurnSummary();
provider.cancelPendingAlignment();
await provider._loadSession(data.id);
return true;
case 'deleteSession':
await provider._deleteSession(data.id);
return true;
case 'openSettings':
// Route the sidebar gear button to Astra's own settings webview.
// Falls back to VS Code Settings if the view hasn't registered yet
// (e.g. during the very first activation pass) and surfaces any
// unexpected error so the user isn't stuck with a silent button.
try {
await vscode.commands.executeCommand('g1nation.settings.focus');
} catch (e: any) {
logInfo('openSettings: settings.focus failed, falling back to VS Code Settings.', { error: e?.message ?? String(e) });
try {
await vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
} catch (e2: any) {
vscode.window.showErrorMessage(`Astra Settings 열기 실패: ${e2?.message ?? e2}`);
}
}
return true;
case 'addMessage':
provider._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
return true;
case 'refreshModels':
await provider._sendModels(true);
return true;
case 'model': {
// Write to whichever scope already holds the value so a stale
// Workspace override doesn't shadow our Global update — that was
// the "sidebar shows e2b but Settings shows e4b" desync.
const { target } = pickConfigTarget('g1nation', 'defaultModel');
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, target);
logInfo(`Default model updated to: ${data.value}`, { target });
// Wipe any persistent LM Studio error segment from the readyBar — the
// next load attempt will repaint it if it also fails.
provider.clearLmStudioError();
provider._lmStudio?.lifecycle.onModelSelected(data.value);
return true;
}
case 'getKnowledgeMix': {
// Ship the current global Knowledge Mix to the webview so the slider can
// initialize. Per-agent overrides ride along with the agent map data.
const cfg = vscode.workspace.getConfiguration('g1nation');
const w = cfg.get<number>('knowledgeMix.secondBrainWeight', 50);
const clamped = Math.max(0, Math.min(100, Math.round(typeof w === 'number' ? w : 50)));
provider._view?.webview.postMessage({
type: 'knowledgeMix',
value: { weight: clamped, source: 'global' },
});
return true;
}
case 'setKnowledgeMix': {
const raw = typeof data.value === 'number' ? data.value : NaN;
if (!Number.isFinite(raw)) return true;
const clamped = Math.max(0, Math.min(100, Math.round(raw)));
// Use whichever scope already holds the value to avoid the same "Workspace
// override shadows Global update" desync that the `model` case guards against.
const { target } = pickConfigTarget('g1nation', 'knowledgeMix.secondBrainWeight');
await vscode.workspace.getConfiguration('g1nation').update('knowledgeMix.secondBrainWeight', clamped, target);
logInfo(`Knowledge Mix (global) updated to: ${clamped}`, { target });
return true;
}
// ── Project Architecture (Feature 2) ──────────────────────────────────
case 'getArchitectureStatus':
await provider._sendArchitectureStatus();
return true;
case 'openArchitectureDoc':
await provider._openArchitectureDoc();
return true;
case 'refreshArchitecture':
await provider._refreshArchitecture();
return true;
case 'detachArchitecture':
await provider._detachArchitecture();
return true;
case 'attachArchitecture':
// Re-enable architecture context for the current workspace —
// user clicked the inactive chip's [Attach] button.
await provider._attachArchitecture();
return true;
case 'activateArchitectureFromText': {
// Optional explicit-toggle path: webview can pass arbitrary text
// (e.g. the current input draft) for one-shot intent detection.
if (typeof data.text === 'string') {
await provider._tryActivateArchitectureFromText(data.text);
}
return true;
}
// ── 1인 기업 모드 메시지 라우팅 ────────────────────────────────────
// 별도 도메인 핸들러에 위임. 30+ 개의 회사 모드 메시지가 한 파일로 부풀어
// SRP 가 깨졌던 걸 분리. 처리 못 한 경우 false 반환 → 아래 default 로 흐름.
case 'getCompanyStatus':
case 'getCompanyAgents':
case 'getCompanyResumable':
case 'discardResumableSession':
case 'setCompanyEnabled':
case 'setCompanyName':
case 'setCompanyActiveAgents':
case 'setCompanyAgentModel':
case 'setCompanyAgentDisplay':
case 'setCompanyAgentRoleCategory':
case 'setCompanyAgentKnowledgeMix':
case 'setCompanyAgentPrompt':
case 'addCompanyAgent':
case 'deleteCompanyAgent':
case 'restoreHiddenAgent':
case 'getCompanyPipelines':
case 'upsertCompanyPipeline':
case 'deleteCompanyPipeline':
case 'getCompanyPipelineTemplate':
case 'getPixelOfficeState':
case 'openPixelOfficePanel':
case 'respondCompanyAlignment':
case 'respondCompanyApproval':
case 'setActiveCompanyPipeline':
case 'setCompanyScopePreset':
case 'resumeCompanyTurn': {
const { handleCompanyMessage } = await import('./companyHandlers');
return await handleCompanyMessage(provider, data);
}
case 'proactiveTrigger':
await provider._handleProactiveSuggestion(data.context);
return true;
case 'exportResponse': {
const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || '';
const defaultPath = path.join(workspacePath, 'g1_response.md');
const uri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(defaultPath),
filters: { 'Markdown': ['md'] }
});
if (uri) {
await vscode.workspace.fs.writeFile(uri, Buffer.from(data.text, 'utf8'));
vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`);
}
return true;
}
case 'approveAction':
await provider._agent.approveTransaction();
return true;
case 'rejectAction':
await provider._agent.rejectTransaction();
return true;
default:
return false;
}
}