dea5953f59
- 채팅 기록 목록 누락 수정: 후처리 예외로 _saveCurrentSession 이 건너뛰던 회귀를 try/finally 로 보장, _saveCurrentSession 자체도 throw 방지. 1인 기업 모드 업무 턴(_runCompanyTurn)도 요청/보고서 쌍으로 기록 (_saveCompanyTurnSession). - Self-Reflector 실행 검증 크로스플랫폼화: .py 는 python3 자동 탐지, .ts 는 로컬 node_modules/typescript/bin/tsc 직접 호출. - 버전 2.2.52 상향 + package-lock 동기화 + 재패키징. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
708 lines
38 KiB
TypeScript
708 lines
38 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':
|
|
console.error(`[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);
|
|
console.error(`[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);
|
|
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 });
|
|
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인 기업 모드 메시지 라우팅 ────────────────────────────────────
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
}
|