5048 lines
243 KiB
TypeScript
5048 lines
243 KiB
TypeScript
import * as vscode from 'vscode';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import * as os from 'os';
|
||
import {
|
||
_getBrainDir,
|
||
findBrainFiles,
|
||
buildApiUrl,
|
||
getActiveBrainProfile,
|
||
getBrainProfiles,
|
||
logError,
|
||
logInfo,
|
||
resolveEngine,
|
||
summarizeText,
|
||
openInEditorGroup
|
||
} from './utils';
|
||
import { getConfig } from './config';
|
||
import { AgentExecutor, ChatMessage } from './agent';
|
||
import { BridgeInterface } from './bridge';
|
||
import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle';
|
||
import type { ModelLifecycleManager } from './lmstudio/lifecycleManager';
|
||
import type { IActivityTracker } from './lmstudio/activityTracker';
|
||
import { handleChatMessage } from './sidebar/chatHandlers';
|
||
import { handleBrainMessage } from './sidebar/brainHandlers';
|
||
import { handleChronicleMessage } from './sidebar/chronicleHandlers';
|
||
import { handleAgentMessage } from './sidebar/agentHandlers';
|
||
import { getOrCreateAgentEntry, resolveScopeForAgent } from './skills/agentKnowledgeMap';
|
||
import { estimateModelParamsB } from './lib/contextManager';
|
||
import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader';
|
||
import {
|
||
buildOrRefreshArchitectureDoc,
|
||
architectureDocPathFor,
|
||
formatArchitectureContextForPrompt,
|
||
resolveActiveSubprojectRoot,
|
||
scanProject,
|
||
} from './features/projectArchitecture';
|
||
import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector';
|
||
import {
|
||
readCompanyState,
|
||
runCompanyTurn,
|
||
resumeCompanyTurn,
|
||
listResumableSessions,
|
||
summarizeForChip,
|
||
CompanyTurnEvent,
|
||
DispatcherDeps,
|
||
ApprovalDecision,
|
||
COMPANY_AGENTS,
|
||
COMPANY_AGENT_ORDER,
|
||
ROLE_CATEGORY_LABELS,
|
||
ROLE_CATEGORY_ORDER,
|
||
resolveAgent,
|
||
PIPELINE_TEMPLATES,
|
||
} from './features/company';
|
||
import { AIService } from './core/services';
|
||
|
||
export interface SidebarLmStudioDeps {
|
||
lifecycle: ModelLifecycleManager;
|
||
activity: IActivityTracker;
|
||
/** Returns the list of model identifiers currently loaded in LM Studio (cached). */
|
||
loadedModels: () => Promise<string[]>;
|
||
}
|
||
|
||
interface LastVisibleChatSnapshot {
|
||
history: ChatMessage[];
|
||
brainProfileId: string;
|
||
sessionId: string | null;
|
||
timestamp: number;
|
||
negativePrompt?: string;
|
||
}
|
||
|
||
interface ChatSession {
|
||
id: string;
|
||
title: string;
|
||
timestamp: number;
|
||
history: ChatMessage[];
|
||
brainProfileId: string;
|
||
negativePrompt?: string;
|
||
}
|
||
|
||
/**
|
||
* Sidebar UI Provider implementing BridgeInterface for BridgeServer
|
||
*/
|
||
export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface {
|
||
public static readonly viewType = 'g1nation-v2-view';
|
||
static readonly activeSessionStateKey = 'g1nation.activeSessionId';
|
||
static readonly lastVisibleChatStateKey = 'g1nation.lastVisibleChat';
|
||
static readonly blankChatStateKey = 'g1nation.blankChatActive';
|
||
static readonly lastAgentStateKey = 'g1nation.lastAgentPath';
|
||
static readonly chronicleProjectsStateKey = 'g1nation.chronicleProjects';
|
||
static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId';
|
||
static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature';
|
||
_view?: vscode.WebviewView;
|
||
_panel?: vscode.WebviewPanel;
|
||
/**
|
||
* Pixel Office "전체보기" — editor area에 띄운 별도 webview panel.
|
||
* 사이드바 mini 패널과 같은 `pixelOfficeUpdate` 메시지 스트림을 받고
|
||
* 더 큰 화면에서 직군별 캐릭터들을 사무실 배경 위에 배치해 보여준다.
|
||
* 닫혀 있으면 undefined — broadcast 시 안전하게 skip.
|
||
*/
|
||
private _pixelOfficePanel?: vscode.WebviewPanel;
|
||
public brainEnabled = true;
|
||
_currentSessionBrainId: string | null = null;
|
||
_currentNegativePrompt: string = '';
|
||
readonly _chronicle = new ProjectChronicleManager();
|
||
_modelDiscoveryInFlight = false;
|
||
_modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
|
||
static readonly MODELS_CACHE_TTL_MS = 30000;
|
||
|
||
/**
|
||
* AbortController for the currently-running 1인 기업 turn. Cleared when
|
||
* the turn ends (success or fail). The webview's Stop button routes
|
||
* through `stopGeneration`, which calls `abortCompanyTurn()` to flip this
|
||
* — the dispatcher's `signal` then short-circuits between phases.
|
||
*/
|
||
private _companyAbort?: AbortController;
|
||
|
||
/**
|
||
* Open approval gates. The dispatcher emits `phase: 'awaiting-approval'`
|
||
* for stages with `requiresApproval`, and waits on a Promise this map
|
||
* stores. The webview surfaces 승인 / 수정요청 / 중단 buttons; clicks
|
||
* route through `chatHandlers.respondCompanyApproval` which calls
|
||
* `resolveApprovalGate(stageId, decision)` here.
|
||
*
|
||
* Keyed by stageId — only one approval may be pending per stage at a
|
||
* time (sequential dispatch), but multiple stages across the same turn
|
||
* each get their own entry as they hit their gate. On turn abort we
|
||
* resolve all outstanding entries with `{ kind: 'abort' }` so the
|
||
* dispatcher unblocks cleanly.
|
||
*/
|
||
private _pendingApprovals = new Map<string, (d: import('./features/company/dispatcher').ApprovalDecision) => void>();
|
||
|
||
/**
|
||
* Snapshot of the last completed company turn — fed into the intent
|
||
* classifier so it can distinguish "follow-up on previous round" from
|
||
* "brand-new task". Reset to undefined when the chat is cleared / new
|
||
* session loaded. Only completed (non-aborted) reports populate this.
|
||
*/
|
||
private _lastCompanyTurnSummary?: {
|
||
brief: string;
|
||
reportTail: string;
|
||
finishedAt: number;
|
||
};
|
||
|
||
/**
|
||
* Intent Alignment 진행 중인 상태. new_task가 분류되면 분석기를 돌리고,
|
||
* confidence가 충분치 않으면 사용자에게 질문 카드를 띄운 뒤 이 슬롯에
|
||
* 현재 contract를 보관 → 다음 사용자 메시지를 *답변*으로 해석한다.
|
||
*
|
||
* 한 번에 한 alignment만 진행. 회사 모드는 sequential 방식이라 동시
|
||
* 다발 alignment가 생길 수 없음. 사용자가 도중 다른 chat을 던지면
|
||
* 그 메시지는 답변으로 합쳐진다 — "취소" 버튼이나 모드 토글로만 빠져
|
||
* 나갈 수 있게 하는 게 흐름상 안전.
|
||
*/
|
||
private _pendingAlignment?: {
|
||
userOriginalPrompt: string;
|
||
contract: import('./features/company').RequirementContract;
|
||
/** 이번 alignment 동안 사용자가 답한 누적 라운드 수 — 무한 라운드 방지. */
|
||
roundsAsked: number;
|
||
/** 이번 turn 한정 pipeline override (Phase 4 분류기에서 전달된 추천). */
|
||
pipelineIdOverride?: string;
|
||
};
|
||
|
||
/**
|
||
* Pixel Office 현재 작업 상태 캐시. 모든 emit/alignment hook이 한 슬롯에
|
||
* 모인 상태를 patch 형식으로 갱신 → broadcast. UI layer이므로 어떤 판단
|
||
* 로직에도 다시 영향 주지 않는다 — 단방향 read-only 흐름.
|
||
*/
|
||
private _pixelOfficeState?: import('./features/company').AgentWorkState;
|
||
/** 같은 말풍선 텍스트 연달아 안 나오게 추적용 (상태/이벤트 별). */
|
||
private _pixelOfficeLastBubble: Map<string, string> = new Map();
|
||
|
||
/**
|
||
* Astra Office 사용자 정의 레이아웃. workspace state에 저장되어 프로젝트별
|
||
* 다른 배치를 가질 수 있다. null/undefined면 webview가 디폴트 LAYOUT을 사용.
|
||
* 편집 모드에서 사용자가 드래그로 위치 조정 후 저장 → 이 슬롯 업데이트 →
|
||
* webview에 다시 broadcast.
|
||
*
|
||
* 데이터 shape:
|
||
* {
|
||
* cells: [{ roleKey, deskX, deskY, charX, charY }, ...],
|
||
* decos: [{ id, type, x, y }, ...]
|
||
* }
|
||
*/
|
||
static readonly pixelOfficeLayoutKey = 'g1nation.pixelOfficeLayout';
|
||
private _readPixelOfficeLayout(): unknown {
|
||
return this._context.workspaceState.get(SidebarChatProvider.pixelOfficeLayoutKey);
|
||
}
|
||
private async _writePixelOfficeLayout(layout: unknown): Promise<void> {
|
||
await this._context.workspaceState.update(SidebarChatProvider.pixelOfficeLayoutKey, layout);
|
||
}
|
||
private async _clearPixelOfficeLayout(): Promise<void> {
|
||
await this._context.workspaceState.update(SidebarChatProvider.pixelOfficeLayoutKey, undefined);
|
||
}
|
||
|
||
/** Phase B-2에서 chatHandlers가 alignment 진행 여부를 빠르게 확인하는 용도. */
|
||
isAlignmentPending(): boolean {
|
||
return !!this._pendingAlignment;
|
||
}
|
||
|
||
// ─────────────────────── Pixel Office collector ───────────────────────
|
||
//
|
||
// 이 섹션은 *기존 emit / alignment / classifier 결과를 가로채* 그대로
|
||
// 모니터링용 상태로 변환하는 단방향 hub. 어떤 메서드도 추가 LLM 콜을
|
||
// 만들지 않고, dispatcher / planner / chatHandlers의 어느 분기에도
|
||
// 영향을 주지 않는다. 그래서 이 섹션 전체를 통째로 지워도 회사 모드는
|
||
// 평소와 동일하게 동작해야 한다 — 그 invariant가 깨지면 위반이다.
|
||
|
||
/** webview로 보낼 직전 patch와 가벼운 reset/유틸. */
|
||
private _pixelOfficeBroadcast(patch: Partial<import('./features/company').AgentWorkState>, opts?: {
|
||
bubbleStatus?: import('./features/company').AgentStatus;
|
||
bubbleEvent?: import('./features/company').AgentEvent;
|
||
bubbleAgentId?: string;
|
||
}): void {
|
||
const cfg = getConfig();
|
||
if (!cfg.companyPixelOfficeEnabled) return;
|
||
const prev = this._pixelOfficeState ?? {
|
||
agentId: 'main',
|
||
agentName: 'Agent',
|
||
status: 'idle' as import('./features/company').AgentStatus,
|
||
recentLogs: [],
|
||
updatedAt: Date.now(),
|
||
};
|
||
const next: import('./features/company').AgentWorkState = {
|
||
...prev,
|
||
...patch,
|
||
recentLogs: patch.recentLogs ?? prev.recentLogs ?? [],
|
||
updatedAt: Date.now(),
|
||
};
|
||
this._pixelOfficeState = next;
|
||
|
||
// 말풍선 — 상태 또는 이벤트가 지정된 경우만 생성. 풀에서 직전에 쓴 텍스트
|
||
// 회피해서 같은 말 연달아 안 나오게.
|
||
const bubbles: import('./features/company').AgentBubble[] = [];
|
||
if (cfg.companyPixelOfficeBubbles) {
|
||
const aid = opts?.bubbleAgentId ?? next.agentId;
|
||
if (opts?.bubbleStatus) {
|
||
const lastKey = `status:${opts.bubbleStatus}`;
|
||
// 동적 import 대신 require로 — 메서드 내부에서 너무 늦게 await 걸지 않게.
|
||
const { getStatusBubbleText, makeBubble } = require('./features/company') as typeof import('./features/company');
|
||
const text = getStatusBubbleText(opts.bubbleStatus, this._pixelOfficeLastBubble.get(lastKey));
|
||
if (text) {
|
||
this._pixelOfficeLastBubble.set(lastKey, text);
|
||
bubbles.push(makeBubble({ agentId: aid, text, type: 'status' }));
|
||
}
|
||
}
|
||
if (opts?.bubbleEvent) {
|
||
const lastKey = `event:${opts.bubbleEvent}`;
|
||
const { getEventBubbleText, eventBubbleType, makeBubble } = require('./features/company') as typeof import('./features/company');
|
||
const text = getEventBubbleText(opts.bubbleEvent, this._pixelOfficeLastBubble.get(lastKey));
|
||
if (text) {
|
||
this._pixelOfficeLastBubble.set(lastKey, text);
|
||
bubbles.push(makeBubble({ agentId: aid, text, type: eventBubbleType(opts.bubbleEvent) }));
|
||
}
|
||
}
|
||
}
|
||
|
||
const payload = {
|
||
type: 'pixelOfficeUpdate' as const,
|
||
value: {
|
||
state: next,
|
||
bubbles,
|
||
config: {
|
||
enabled: cfg.companyPixelOfficeEnabled,
|
||
bubblesEnabled: cfg.companyPixelOfficeBubbles,
|
||
maxVisibleBubbles: 3,
|
||
bubbleDurationMs: 4500,
|
||
},
|
||
},
|
||
};
|
||
// 사이드바 mini panel + 전체보기 webview panel 둘 다 같은 데이터 받음.
|
||
this._view?.webview.postMessage(payload);
|
||
this._pixelOfficePanel?.webview.postMessage(payload);
|
||
}
|
||
|
||
/** recentLogs ring buffer push — webview에서 보여주는 "최근 로그". */
|
||
private _pixelOfficeAppendLog(line: string): string[] {
|
||
const cur = this._pixelOfficeState?.recentLogs ?? [];
|
||
const next = [...cur, line].slice(-6);
|
||
return next;
|
||
}
|
||
|
||
/** Intent classifier가 분류 직후 호출되어 한 줄 상태 진입을 표시. */
|
||
pixelOfficeOnIntentClassified(intent: 'chat' | 'followup' | 'new_task', userPrompt: string): void {
|
||
if (intent !== 'new_task') {
|
||
// 잡담/후속 — 회사 모드 표면적으로 일 안 하므로 'idle'로 잠시 복귀.
|
||
this._pixelOfficeBroadcast({
|
||
status: 'idle',
|
||
currentTask: userPrompt,
|
||
currentStep: intent === 'followup' ? '직전 라운드 후속 응답' : '잡담 응답',
|
||
message: undefined,
|
||
recentLogs: this._pixelOfficeAppendLog(`💬 분류: ${intent}`),
|
||
}, { bubbleStatus: 'idle' });
|
||
return;
|
||
}
|
||
// new_task — intake → analyzing 흐름의 첫 신호.
|
||
this._pixelOfficeBroadcast({
|
||
status: 'intake',
|
||
currentTask: userPrompt,
|
||
currentStep: '요청 접수',
|
||
recentLogs: this._pixelOfficeAppendLog(`📨 새 작업 요청 접수`),
|
||
}, { bubbleStatus: 'intake' });
|
||
}
|
||
|
||
/** Intent Alignment 분석 시작 (LLM 콜 직전). */
|
||
pixelOfficeOnAlignmentStart(userPrompt: string): void {
|
||
this._pixelOfficeBroadcast({
|
||
status: 'analyzing',
|
||
currentTask: this._pixelOfficeState?.currentTask || userPrompt,
|
||
currentStep: '요청 분석 중 (C·G·C·F 추출)',
|
||
message: undefined,
|
||
recentLogs: this._pixelOfficeAppendLog('🔍 의도 분석 시작'),
|
||
}, { bubbleStatus: 'analyzing' });
|
||
}
|
||
|
||
/** Alignment 결과를 받아 카드로 표시 직전. kind=auto-proceed면 곧장 planning 흐름. */
|
||
pixelOfficeOnAlignmentResult(kind: 'questions' | 'confirm' | 'auto-proceed', contract: import('./features/company').RequirementContract): void {
|
||
if (kind === 'auto-proceed') {
|
||
this._pixelOfficeBroadcast({
|
||
status: 'contract_ready',
|
||
currentStep: 'Requirement Contract 확정 (자동 진행)',
|
||
requirementContract: this._summariseContract(contract),
|
||
needUserInput: undefined,
|
||
recentLogs: this._pixelOfficeAppendLog('✅ 계약 자동 확정'),
|
||
}, {
|
||
bubbleStatus: 'contract_ready',
|
||
bubbleEvent: 'requirement_contract_created',
|
||
});
|
||
return;
|
||
}
|
||
if (kind === 'questions') {
|
||
this._pixelOfficeBroadcast({
|
||
status: 'need_clarification',
|
||
currentStep: '확인 질문 대기 중',
|
||
requirementContract: this._summariseContract(contract),
|
||
needUserInput: contract.openQuestions,
|
||
recentLogs: this._pixelOfficeAppendLog(`🤔 ${contract.openQuestions.length}개 질문 대기`),
|
||
}, {
|
||
bubbleStatus: 'need_clarification',
|
||
bubbleEvent: 'clarification_needed',
|
||
});
|
||
return;
|
||
}
|
||
// confirm — 질문 없음, 사용자 OK만 받으면 됨.
|
||
this._pixelOfficeBroadcast({
|
||
status: 'waiting_approval',
|
||
currentStep: '계약 확인 대기',
|
||
requirementContract: this._summariseContract(contract),
|
||
needUserInput: undefined,
|
||
awaitingApproval: '사용자 확인 — 그대로 진행할지 여부',
|
||
recentLogs: this._pixelOfficeAppendLog('🧭 계약 확인 카드 표시'),
|
||
}, { bubbleStatus: 'waiting_approval' });
|
||
}
|
||
|
||
/** Alignment 취소(사용자가 카드 닫음). */
|
||
pixelOfficeOnAlignmentCancelled(): void {
|
||
this._pixelOfficeBroadcast({
|
||
status: 'idle',
|
||
currentStep: '취소됨',
|
||
needUserInput: undefined,
|
||
awaitingApproval: undefined,
|
||
recentLogs: this._pixelOfficeAppendLog('🛑 작업 취소'),
|
||
});
|
||
}
|
||
|
||
private _summariseContract(c: import('./features/company').RequirementContract) {
|
||
return {
|
||
goal: c.goal || undefined,
|
||
context: c.context || undefined,
|
||
criteria: c.criteria,
|
||
format: c.format || undefined,
|
||
openQuestions: c.openQuestions,
|
||
confidence: c.confidence,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Dispatcher의 `CompanyTurnEvent`를 그대로 받아 Pixel Office 상태로 변환.
|
||
* `_runCompanyTurn`의 emit 콜백이 매 이벤트를 한 번 더 이쪽으로 흘려준다.
|
||
* 함수 안에서 어떤 dispatcher 분기도 다시 트리거하지 않는다 — read-only.
|
||
*/
|
||
pixelOfficeOnTurnEvent(ev: import('./features/company').CompanyTurnEvent): void {
|
||
switch (ev.phase) {
|
||
case 'plan-start':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'planning',
|
||
currentStep: '계획 수립',
|
||
recentLogs: this._pixelOfficeAppendLog('📋 plan 작성'),
|
||
}, { bubbleStatus: 'planning' });
|
||
return;
|
||
case 'plan-ready': {
|
||
// B. 미니 맵용 stage 리스트 초기화. 모두 pending.
|
||
const stages = (ev.plan?.tasks ?? []).map((t) => ({
|
||
label: (t.task || '').slice(0, 24) || '단계',
|
||
agent: t.agent,
|
||
status: 'pending' as const,
|
||
}));
|
||
this._pixelOfficeBroadcast({
|
||
status: 'planning',
|
||
currentStep: '계획 완료',
|
||
nextStep: ev.plan?.tasks?.[0]?.task,
|
||
message: ev.plan?.brief?.slice(0, 120),
|
||
recentLogs: this._pixelOfficeAppendLog(`📋 plan 완료 (${ev.plan?.tasks?.length ?? 0}개 task)`),
|
||
pipelineStages: stages,
|
||
}, { bubbleEvent: 'plan_completed' });
|
||
return;
|
||
}
|
||
case 'agent-start': {
|
||
// B. 현재 stage를 active로, 이전 stage들은 done으로.
|
||
const prev = this._pixelOfficeState?.pipelineStages;
|
||
const stages = prev ? prev.map((s, i) => ({
|
||
...s,
|
||
status: (i < ev.index ? 'done' : i === ev.index ? 'active' : 'pending') as 'done'|'active'|'pending',
|
||
})) : undefined;
|
||
this._pixelOfficeBroadcast({
|
||
status: 'executing',
|
||
currentStep: ev.task?.slice(0, 80),
|
||
nextStep: undefined,
|
||
message: `${ev.agentId} • ${ev.index + 1}/${ev.total}`,
|
||
progress: ev.total > 0 ? ev.index / ev.total : undefined,
|
||
recentLogs: this._pixelOfficeAppendLog(`▶ ${ev.agentId} start (${ev.index + 1}/${ev.total})`),
|
||
pipelineStages: stages,
|
||
}, {
|
||
bubbleAgentId: ev.agentId,
|
||
bubbleStatus: 'executing',
|
||
bubbleEvent: ev.index === 0 ? 'execution_started' : undefined,
|
||
});
|
||
return;
|
||
}
|
||
case 'agent-done':
|
||
// E. action-tag report가 있으면 ticker로 별도 push — 캐릭터가 *무슨
|
||
// 일을 했는지* 실시간 가시화. 사용자가 신뢰도/투명성을 즉시 체감.
|
||
if (Array.isArray(ev.output?.actionReport) && ev.output.actionReport.length > 0) {
|
||
const items = ev.output.actionReport.map((line: string) => ({
|
||
agentId: ev.agentId, text: line, ts: Date.now(),
|
||
}));
|
||
this._view?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } });
|
||
this._pixelOfficePanel?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } });
|
||
}
|
||
this._pixelOfficeBroadcast({
|
||
status: ev.output?.error ? 'error' : 'executing',
|
||
progress: ev.total > 0 ? (ev.index + 1) / ev.total : undefined,
|
||
recentLogs: this._pixelOfficeAppendLog(
|
||
ev.output?.error
|
||
? `❌ ${ev.agentId} ${ev.output.error}`
|
||
: `✓ ${ev.agentId} 완료`,
|
||
),
|
||
}, {
|
||
bubbleAgentId: ev.agentId,
|
||
bubbleEvent: ev.output?.error ? 'error_occurred' : undefined,
|
||
});
|
||
return;
|
||
case 'stage-loop':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'executing',
|
||
currentStep: `재시도: ${ev.from} → ${ev.to} (${ev.iteration}회)`,
|
||
recentLogs: this._pixelOfficeAppendLog(`🔁 loop ${ev.from}→${ev.to} #${ev.iteration}`),
|
||
}, { bubbleEvent: 'stage_loop_retry' });
|
||
return;
|
||
case 'review-start':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'reviewing',
|
||
currentStep: `검수 사이클 — ${ev.stageLabel}`,
|
||
message: `검수자: ${ev.inspectorAgentId} · 최대 ${ev.maxRounds}라운드`,
|
||
recentLogs: this._pixelOfficeAppendLog(`🔍 검수 시작: ${ev.stageLabel}`),
|
||
}, { bubbleStatus: 'reviewing' });
|
||
return;
|
||
case 'review-round':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'reviewing',
|
||
currentStep: `검수 라운드 ${ev.round} (${ev.inspectorVerdict}/${ev.ceoVerdict})`,
|
||
recentLogs: this._pixelOfficeAppendLog(
|
||
`R${ev.round} insp:${ev.inspectorVerdict} ceo:${ev.ceoVerdict}`,
|
||
),
|
||
}, {
|
||
bubbleAgentId: ev.inspectorAgentId,
|
||
bubbleEvent: ev.inspectorVerdict === 'pass' && ev.ceoVerdict === 'pass'
|
||
? 'review_passed'
|
||
: (ev.inspectorVerdict === 'revise' ? 'review_failed' : undefined),
|
||
});
|
||
return;
|
||
case 'review-end':
|
||
this._pixelOfficeBroadcast({
|
||
status: ev.final === 'aborted' ? 'error' : 'executing',
|
||
currentStep: ev.final === 'pass'
|
||
? `검수 통과 (${ev.rounds}라운드)`
|
||
: ev.final === 'maxed-out'
|
||
? `검수 한도 도달 — 진행 (${ev.rounds}라운드)`
|
||
: '검수 중단',
|
||
recentLogs: this._pixelOfficeAppendLog(`🔍 검수 종료: ${ev.final}`),
|
||
}, {
|
||
bubbleEvent: ev.final === 'pass' ? 'review_passed'
|
||
: ev.final === 'aborted' ? 'risky_change_detected'
|
||
: undefined,
|
||
});
|
||
return;
|
||
case 'awaiting-approval':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'waiting_approval',
|
||
currentStep: `승인 대기 — ${ev.stageLabel}`,
|
||
awaitingApproval: `${ev.stageLabel} 단계 완료 검토`,
|
||
recentLogs: this._pixelOfficeAppendLog(`✋ 승인 대기: ${ev.stageLabel}`),
|
||
}, {
|
||
bubbleStatus: 'waiting_approval',
|
||
bubbleEvent: 'approval_required',
|
||
});
|
||
return;
|
||
case 'approval-resolved':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'executing',
|
||
awaitingApproval: undefined,
|
||
currentStep: `승인 결과: ${ev.decision}`,
|
||
recentLogs: this._pixelOfficeAppendLog(`✋→ ${ev.decision}`),
|
||
});
|
||
return;
|
||
case 'report-start':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'reviewing',
|
||
currentStep: 'CEO 종합 보고서 작성 중',
|
||
recentLogs: this._pixelOfficeAppendLog('🧭 보고서 작성'),
|
||
});
|
||
return;
|
||
case 'report-done':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'done',
|
||
currentStep: ev.ok ? '보고서 완료' : '보고서 (fallback) 완료',
|
||
progress: 1,
|
||
recentLogs: this._pixelOfficeAppendLog(ev.ok ? '✅ 보고서 OK' : '⚠ fallback 보고서'),
|
||
}, {
|
||
bubbleStatus: 'done',
|
||
bubbleEvent: 'task_completed',
|
||
});
|
||
return;
|
||
case 'session-saved':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'done',
|
||
message: `세션 저장됨`,
|
||
recentLogs: this._pixelOfficeAppendLog('💾 세션 저장'),
|
||
});
|
||
return;
|
||
case 'aborted':
|
||
this._pixelOfficeBroadcast({
|
||
status: 'error',
|
||
currentStep: `중단: ${ev.reason}`,
|
||
recentLogs: this._pixelOfficeAppendLog(`🛑 abort: ${ev.reason}`),
|
||
}, {
|
||
bubbleStatus: 'error',
|
||
bubbleEvent: 'error_occurred',
|
||
});
|
||
return;
|
||
case 'telegram-mirror':
|
||
default:
|
||
return; // 시각화에 의미 약함 — log skip
|
||
}
|
||
}
|
||
|
||
/** webview가 처음 로드되거나 사용자가 토글을 다시 켰을 때 캐시된 상태 재전송. */
|
||
pixelOfficeResend(): void {
|
||
const cfg = getConfig();
|
||
const payload = (() => {
|
||
if (!cfg.companyPixelOfficeEnabled) {
|
||
return {
|
||
type: 'pixelOfficeUpdate' as const,
|
||
value: { state: null, bubbles: [], config: { enabled: false, bubblesEnabled: false, maxVisibleBubbles: 0, bubbleDurationMs: 0 } },
|
||
};
|
||
}
|
||
const state = this._pixelOfficeState ?? {
|
||
agentId: 'main', agentName: 'Agent',
|
||
status: 'idle' as import('./features/company').AgentStatus,
|
||
recentLogs: [],
|
||
updatedAt: Date.now(),
|
||
};
|
||
return {
|
||
type: 'pixelOfficeUpdate' as const,
|
||
value: {
|
||
state, bubbles: [],
|
||
config: {
|
||
enabled: cfg.companyPixelOfficeEnabled,
|
||
bubblesEnabled: cfg.companyPixelOfficeBubbles,
|
||
maxVisibleBubbles: 3,
|
||
bubbleDurationMs: 4500,
|
||
},
|
||
},
|
||
};
|
||
})();
|
||
this._view?.webview.postMessage(payload);
|
||
this._pixelOfficePanel?.webview.postMessage(payload);
|
||
}
|
||
|
||
/**
|
||
* editor area에 별도 Pixel Office 전체보기 panel을 띄움. 이미 열려 있으면
|
||
* 그 panel을 reveal. 사이드바 mini 패널과 동일한 데이터 스트림을 받지만
|
||
* 별도 HTML로 *사무실 그리드 + 직군별 캐릭터* 레이아웃을 보여준다.
|
||
*/
|
||
public openPixelOfficePanel(column: vscode.ViewColumn = vscode.ViewColumn.Beside): vscode.WebviewPanel {
|
||
if (this._pixelOfficePanel) {
|
||
this._pixelOfficePanel.reveal(column);
|
||
return this._pixelOfficePanel;
|
||
}
|
||
const panel = vscode.window.createWebviewPanel(
|
||
'astra.pixelOffice',
|
||
'Pixel Office',
|
||
column,
|
||
{ enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true },
|
||
);
|
||
this._pixelOfficePanel = panel;
|
||
panel.webview.html = this._buildPixelOfficeHtml(panel.webview);
|
||
// panel과 백엔드 사이의 메시지 채널 — 닫기/리프레시 + 레이아웃 편집.
|
||
panel.webview.onDidReceiveMessage((msg: any) => {
|
||
if (!msg || typeof msg !== 'object') return;
|
||
if (msg.type === 'getPixelOfficeState') this.pixelOfficeResend();
|
||
if (msg.type === 'closePixelOfficePanel') panel.dispose();
|
||
if (msg.type === 'getPixelOfficeLayout') {
|
||
// webview가 layout 요청 — 저장된 게 있으면 그것, 없으면 null.
|
||
panel.webview.postMessage({
|
||
type: 'pixelOfficeLayoutLoaded',
|
||
value: this._readPixelOfficeLayout() ?? null,
|
||
});
|
||
}
|
||
if (msg.type === 'savePixelOfficeLayout') {
|
||
// webview의 편집 모드에서 사용자가 저장 버튼 누름.
|
||
void this._writePixelOfficeLayout(msg.value).then(() => {
|
||
panel.webview.postMessage({
|
||
type: 'pixelOfficeLayoutSaved', value: { ok: true },
|
||
});
|
||
});
|
||
}
|
||
if (msg.type === 'resetPixelOfficeLayout') {
|
||
// 디폴트 LAYOUT으로 복귀.
|
||
void this._clearPixelOfficeLayout().then(() => {
|
||
panel.webview.postMessage({
|
||
type: 'pixelOfficeLayoutSaved', value: { ok: true, reset: true },
|
||
});
|
||
});
|
||
}
|
||
if (msg.type === 'pixelOfficeCommand') {
|
||
// D. 캐릭터 컨텍스트 메뉴 액션. 현재 abort만 처리 — Stop 버튼과
|
||
// 동일 경로(abortCompanyTurn + alignment 취소 + agent.stop)로
|
||
// 작업 즉시 중단. skip/pause 등은 dispatcher에 별도 API가 없어 미지원.
|
||
if (msg.cmd === 'abort') {
|
||
this.abortCompanyTurn();
|
||
this.cancelPendingAlignment();
|
||
this._agent.stop();
|
||
}
|
||
}
|
||
});
|
||
panel.onDidDispose(() => {
|
||
if (this._pixelOfficePanel === panel) this._pixelOfficePanel = undefined;
|
||
});
|
||
// 열자마자 현재 상태 한 번 push.
|
||
this.pixelOfficeResend();
|
||
return panel;
|
||
}
|
||
|
||
/**
|
||
* Pixel Office panel용 HTML 본문. 사이드바 mini와 같은 메시지 스키마를
|
||
* 받지만 그리는 방식이 전혀 달라(사무실 그리드 + 직군별 캐릭터) 사이드바와
|
||
* 분리된 별도 HTML을 둔다. 외부 CSS/JS 파일을 안 쓰고 한 파일에 묶어
|
||
* VS Code의 localResourceRoots 제약을 신경 안 쓰도록 설계.
|
||
*/
|
||
private _buildPixelOfficeHtml(webview: vscode.Webview): string {
|
||
const cspSource = webview.cspSource;
|
||
const derivedBase = webview.asWebviewUri(
|
||
vscode.Uri.joinPath(this._extensionUri, 'assets', 'pixelOffice', 'derived')
|
||
).toString();
|
||
return _pixelOfficePanelHtml(cspSource, { derivedBase });
|
||
}
|
||
|
||
/** Alignment 슬롯 비우기 — 사용자가 "취소"를 눌렀거나 turn 시작/종료 시점 호출. */
|
||
clearPendingAlignment(): void {
|
||
this._pendingAlignment = undefined;
|
||
}
|
||
|
||
/** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */
|
||
private _archWatcher?: vscode.FileSystemWatcher;
|
||
/** Debounce timer for the architecture watcher. */
|
||
private _archWatchDebounce?: NodeJS.Timeout;
|
||
/** Project ID the current watcher is watching — kept so we don't double-register. */
|
||
private _archWatchedProjectId?: string;
|
||
|
||
constructor(
|
||
readonly _extensionUri: vscode.Uri,
|
||
readonly _context: vscode.ExtensionContext,
|
||
readonly _agent: AgentExecutor,
|
||
readonly _lmStudio?: SidebarLmStudioDeps
|
||
) {
|
||
this._agent.setHistoryChangeListener((history) => {
|
||
void this._persistLastVisibleChat(history);
|
||
});
|
||
}
|
||
|
||
/** Surface LM Studio lifecycle errors (load/unload failures) to the chat UI as a non-fatal toast. */
|
||
public postLmStudioError(message: string): void {
|
||
this._view?.webview.postMessage({ type: 'lmStudioError', value: message });
|
||
}
|
||
|
||
public resolveWebviewView(
|
||
webviewView: vscode.WebviewView,
|
||
context: vscode.WebviewViewResolveContext,
|
||
_token: vscode.CancellationToken,
|
||
) {
|
||
this._initView(webviewView);
|
||
}
|
||
|
||
/**
|
||
* Open the chat as a standalone editor panel (Column 3 by default).
|
||
* Reuses the same view-init logic via a WebviewPanel→WebviewView adapter
|
||
* so the rest of the provider keeps using `this._view` unchanged.
|
||
*/
|
||
public openAsPanel(column: vscode.ViewColumn = vscode.ViewColumn.Three): vscode.WebviewPanel {
|
||
if (this._panel) {
|
||
this._panel.reveal(column);
|
||
return this._panel;
|
||
}
|
||
const panel = vscode.window.createWebviewPanel(
|
||
SidebarChatProvider.viewType,
|
||
'Astra Chat',
|
||
column,
|
||
{ enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true }
|
||
);
|
||
this._panel = panel;
|
||
const adapter = wrapPanelAsView(panel);
|
||
panel.onDidDispose(() => {
|
||
if (this._panel === panel) this._panel = undefined;
|
||
if (this._view === adapter) this._view = undefined;
|
||
});
|
||
this._initView(adapter);
|
||
return panel;
|
||
}
|
||
|
||
private _initView(webviewView: vscode.WebviewView) {
|
||
this._view = webviewView;
|
||
|
||
webviewView.webview.options = {
|
||
enableScripts: true,
|
||
localResourceRoots: [this._extensionUri]
|
||
};
|
||
|
||
// [State Persistence Fix] 사이드바가 다시 보여질 때 세팅값 자동 복원
|
||
let _lastVisibilityRefresh = 0;
|
||
webviewView.onDidChangeVisibility(() => {
|
||
if (!webviewView.visible) return;
|
||
const now = Date.now();
|
||
// 5초 이내에 이미 갱신했으면 건너뜀
|
||
if (now - _lastVisibilityRefresh < 5000) return;
|
||
_lastVisibilityRefresh = now;
|
||
|
||
logInfo('Astra view became visible, restoring state...');
|
||
void this._sendModels();
|
||
void this._sendBrainProfiles();
|
||
void this._sendAgentsList();
|
||
void this._sendReadyStatus();
|
||
});
|
||
|
||
webviewView.webview.html = this._getHtml(webviewView.webview);
|
||
this._agent.setWebview(webviewView.webview);
|
||
|
||
void this._restoreActiveSessionIntoView();
|
||
void this._sendReadyStatus();
|
||
|
||
webviewView.webview.onDidReceiveMessage(async (data) => {
|
||
if (await handleChatMessage(this, data)) return;
|
||
if (await handleBrainMessage(this, data)) return;
|
||
if (await handleChronicleMessage(this, data)) return;
|
||
if (await handleAgentMessage(this, data)) return;
|
||
logInfo(`Unhandled sidebar message: ${data?.type}`);
|
||
});
|
||
}
|
||
|
||
_currentSessionId: string | null = null;
|
||
|
||
async _restoreActiveSessionIntoView() {
|
||
if (!this._view) return;
|
||
|
||
const blankChatActive = this._context.globalState.get<boolean>(SidebarChatProvider.blankChatStateKey, false);
|
||
const currentHistory = this._agent.getHistory();
|
||
|
||
if (blankChatActive && currentHistory.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const activeSessionId = this._currentSessionId || this._context.globalState.get<string | null>(SidebarChatProvider.activeSessionStateKey, null);
|
||
if (activeSessionId) {
|
||
const loaded = await this._loadSession(activeSessionId, true);
|
||
if (loaded) return;
|
||
|
||
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
|
||
}
|
||
|
||
if (currentHistory.length > 0) {
|
||
this._view.webview.postMessage({ type: 'restoreHistory', value: currentHistory });
|
||
await this._persistLastVisibleChat(currentHistory);
|
||
return;
|
||
}
|
||
|
||
const snapshot = this._context.globalState.get<LastVisibleChatSnapshot | null>(SidebarChatProvider.lastVisibleChatStateKey, null);
|
||
if (snapshot?.history?.length) {
|
||
this._currentSessionId = snapshot.sessionId || null;
|
||
this._currentSessionBrainId = snapshot.brainProfileId || getActiveBrainProfile().id;
|
||
this._currentNegativePrompt = snapshot.negativePrompt || '';
|
||
await this._setActiveBrainProfile(this._currentSessionBrainId, true);
|
||
this._agent.setHistory(snapshot.history);
|
||
this._view.webview.postMessage({
|
||
type: 'restoreHistory',
|
||
value: snapshot.history,
|
||
negativePrompt: this._currentNegativePrompt
|
||
});
|
||
}
|
||
}
|
||
|
||
async _persistLastVisibleChat(history: ChatMessage[] = this._agent.getHistory()) {
|
||
if (history.length === 0) {
|
||
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
|
||
return;
|
||
}
|
||
|
||
const snapshot: LastVisibleChatSnapshot = {
|
||
history,
|
||
brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id,
|
||
sessionId: this._currentSessionId,
|
||
timestamp: Date.now(),
|
||
negativePrompt: this._currentNegativePrompt
|
||
};
|
||
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, snapshot);
|
||
}
|
||
|
||
async _saveCurrentSession() {
|
||
const history = this._agent.getHistory();
|
||
if (history.length === 0) return;
|
||
|
||
let sessions = this._getSessions();
|
||
const firstMsg = history.find(m => m.role === 'user')?.content;
|
||
const title = typeof firstMsg === 'string' ? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '') : 'New Chat';
|
||
const brainProfileId = this._currentSessionBrainId || getActiveBrainProfile().id;
|
||
|
||
if (!this._currentSessionId) {
|
||
this._currentSessionId = Date.now().toString();
|
||
sessions.unshift({
|
||
id: this._currentSessionId,
|
||
title,
|
||
timestamp: Date.now(),
|
||
history,
|
||
brainProfileId,
|
||
negativePrompt: this._currentNegativePrompt
|
||
});
|
||
} else {
|
||
const idx = sessions.findIndex(s => s.id === this._currentSessionId);
|
||
if (idx >= 0) {
|
||
sessions[idx].history = history;
|
||
sessions[idx].timestamp = Date.now();
|
||
sessions[idx].brainProfileId = brainProfileId;
|
||
sessions[idx].negativePrompt = this._currentNegativePrompt;
|
||
if (!sessions[idx].title || sessions[idx].title === 'New Chat') {
|
||
sessions[idx].title = title;
|
||
}
|
||
} else {
|
||
sessions.unshift({
|
||
id: this._currentSessionId,
|
||
title,
|
||
timestamp: Date.now(),
|
||
history,
|
||
brainProfileId,
|
||
negativePrompt: this._currentNegativePrompt
|
||
});
|
||
}
|
||
}
|
||
|
||
// Keep only last 50 sessions
|
||
if (sessions.length > 50) sessions = sessions.slice(0, 50);
|
||
|
||
await this._putSessions(sessions);
|
||
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, this._currentSessionId);
|
||
await this._persistLastVisibleChat(history);
|
||
await this._sendSessionList();
|
||
}
|
||
|
||
async _sendSessionList() {
|
||
if (!this._view) return;
|
||
const sessions = this._getSessions();
|
||
const list = sessions.map(s => ({
|
||
id: s.id,
|
||
title: s.title,
|
||
timestamp: s.timestamp,
|
||
brainProfileId: s.brainProfileId || '',
|
||
messageCount: s.history.length,
|
||
history: s.history
|
||
}));
|
||
this._view.webview.postMessage({ type: 'sessionList', value: list });
|
||
}
|
||
|
||
async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise<boolean> {
|
||
if (!id) {
|
||
logError('Session load requested without an id.');
|
||
this._view?.webview.postMessage({ type: 'error', value: 'Chat session id is missing.' });
|
||
return false;
|
||
}
|
||
|
||
const sessions = this._getSessions();
|
||
const session = sessions.find(s => s.id === id) || this._getSessionById(id);
|
||
if (session) {
|
||
const history = Array.isArray(session.history) ? session.history : [];
|
||
if (history.length === 0) {
|
||
logError('Session load failed because history is empty or invalid.', { id });
|
||
this._view?.webview.postMessage({ type: 'error', value: 'This chat session has no saved messages.' });
|
||
return false;
|
||
}
|
||
|
||
this._agent.stop();
|
||
this._currentSessionId = id;
|
||
this._currentNegativePrompt = session.negativePrompt || '';
|
||
const sessionBrainId = session.brainProfileId || getActiveBrainProfile().id;
|
||
await this._setActiveBrainProfile(sessionBrainId, true);
|
||
this._agent.setHistory(history);
|
||
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, id);
|
||
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
|
||
await this._persistLastVisibleChat(history);
|
||
this._view?.webview.postMessage({
|
||
type: 'sessionLoaded',
|
||
value: {
|
||
id,
|
||
title: session.title || 'Chat Session',
|
||
history,
|
||
negativePrompt: this._currentNegativePrompt
|
||
}
|
||
});
|
||
if (!skipSessionListRefresh) {
|
||
await this._sendSessionList();
|
||
}
|
||
logInfo('Chat session loaded.', { id, messages: history.length });
|
||
return true;
|
||
}
|
||
|
||
logError('Session load failed because id was not found.', { id, sessionCount: sessions.length });
|
||
this._view?.webview.postMessage({ type: 'error', value: 'Chat session was not found.' });
|
||
return false;
|
||
}
|
||
|
||
async _deleteSession(id: string) {
|
||
let sessions = this._getSessions();
|
||
sessions = sessions.filter(s => s.id !== id);
|
||
await this._putSessions(sessions);
|
||
if (this._currentSessionId === id) {
|
||
this._currentSessionId = null;
|
||
this._agent.resetConversation();
|
||
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
|
||
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
|
||
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
|
||
this.clearChat();
|
||
}
|
||
await this._sendSessionList();
|
||
}
|
||
|
||
_getSessions(): ChatSession[] {
|
||
const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
|
||
return rawSessions
|
||
.map((session, index): ChatSession | null => {
|
||
const history = Array.isArray(session?.history)
|
||
? session.history.filter((message: any) =>
|
||
message
|
||
&& (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
|
||
&& message.content !== undefined
|
||
)
|
||
: [];
|
||
|
||
if (!session?.id || history.length === 0) return null;
|
||
|
||
const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content;
|
||
const fallbackTitle = typeof firstMsg === 'string'
|
||
? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '')
|
||
: `Chat ${index + 1}`;
|
||
|
||
return {
|
||
id: String(session.id),
|
||
title: String(session.title || fallbackTitle),
|
||
timestamp: typeof session.timestamp === 'number' ? session.timestamp : Date.now(),
|
||
history,
|
||
brainProfileId: String(session.brainProfileId || getActiveBrainProfile().id),
|
||
negativePrompt: String(session.negativePrompt || '')
|
||
};
|
||
})
|
||
.filter((session): session is ChatSession => !!session)
|
||
.sort((a, b) => b.timestamp - a.timestamp)
|
||
.slice(0, 50);
|
||
}
|
||
|
||
_getSessionById(id: string): ChatSession | null {
|
||
const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
|
||
const raw = rawSessions.find((session: any) => String(session?.id) === String(id));
|
||
if (!raw) return null;
|
||
|
||
const history = Array.isArray(raw.history)
|
||
? raw.history.filter((message: any) =>
|
||
message
|
||
&& (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
|
||
&& message.content !== undefined
|
||
)
|
||
: [];
|
||
if (history.length === 0) return null;
|
||
|
||
const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content;
|
||
const fallbackTitle = typeof firstMsg === 'string'
|
||
? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '')
|
||
: 'Chat Session';
|
||
|
||
return {
|
||
id: String(raw.id),
|
||
title: String(raw.title || fallbackTitle),
|
||
timestamp: typeof raw.timestamp === 'number' ? raw.timestamp : Date.now(),
|
||
history,
|
||
brainProfileId: String(raw.brainProfileId || getActiveBrainProfile().id),
|
||
negativePrompt: String(raw.negativePrompt || '')
|
||
};
|
||
}
|
||
|
||
async _putSessions(sessions: ChatSession[]) {
|
||
await this._context.globalState.update('chat_sessions', sessions.slice(0, 50));
|
||
}
|
||
|
||
async _sendBrainStatus() {
|
||
if (!this._view) return;
|
||
const activeBrain = getActiveBrainProfile();
|
||
const brainDir = activeBrain.localBrainPath;
|
||
const files = findBrainFiles(brainDir);
|
||
this._view.webview.postMessage({
|
||
type: 'brainStatus',
|
||
value: {
|
||
count: files.length,
|
||
path: brainDir,
|
||
name: activeBrain.name,
|
||
description: activeBrain.description || '',
|
||
repo: activeBrain.secondBrainRepo || ''
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* One-line "current readiness" snapshot for the sidebar's status bar:
|
||
* engine online?, model loaded?, Brain file count, active Agent + mapped knowledge
|
||
* folder count, memory on/off, context window. Cheap — no network calls except the
|
||
* already-cached LM Studio loaded-models list and online flag.
|
||
*/
|
||
async _sendReadyStatus() {
|
||
if (!this._view) return;
|
||
let payload: any;
|
||
try {
|
||
const config = getConfig();
|
||
const engineKind = resolveEngine(config.ollamaUrl);
|
||
const activeBrain = getActiveBrainProfile();
|
||
let brainFiles = 0;
|
||
try { brainFiles = findBrainFiles(activeBrain.localBrainPath).length; } catch { /* ignore */ }
|
||
|
||
const agentPath = this._context.globalState.get<string>(SidebarChatProvider.lastAgentStateKey, 'none');
|
||
let agentName: string | null = null;
|
||
let scopeFolders = 0;
|
||
let mapped = false;
|
||
if (agentPath && agentPath !== 'none') {
|
||
agentName = path.basename(agentPath).replace(/\.md$/i, '');
|
||
try {
|
||
const scope = resolveScopeForAgent(agentPath, activeBrain.localBrainPath || '');
|
||
scopeFolders = scope.folders.length;
|
||
if (scope.agent?.name) agentName = scope.agent.name;
|
||
mapped = scope.source !== 'none';
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
let modelLoaded: boolean | null = null;
|
||
if (engineKind === 'lmstudio') {
|
||
try {
|
||
const loaded = (await this._lmStudio?.loadedModels()) || [];
|
||
modelLoaded = loaded.includes(config.defaultModel);
|
||
} catch { modelLoaded = null; }
|
||
}
|
||
|
||
const paramB = estimateModelParamsB(config.defaultModel);
|
||
const cappedForSmallModel = config.smallModelContextCap > 0
|
||
&& paramB !== null && paramB <= 4
|
||
&& config.contextLength > config.smallModelContextCap;
|
||
const effectiveContextLength = cappedForSmallModel ? config.smallModelContextCap : config.contextLength;
|
||
payload = {
|
||
engine: {
|
||
kind: engineKind,
|
||
label: engineKind === 'lmstudio' ? 'LM Studio' : 'Ollama',
|
||
online: this._modelsCache?.online ?? null,
|
||
},
|
||
model: { name: config.defaultModel, loaded: modelLoaded, paramB },
|
||
brain: { name: activeBrain.name, files: brainFiles },
|
||
agent: { name: agentName, scopeFolders, mapped },
|
||
memory: config.memoryEnabled,
|
||
multiAgent: config.multiAgentEnabled,
|
||
contextLength: effectiveContextLength,
|
||
nominalContextLength: config.contextLength,
|
||
cappedForSmallModel,
|
||
};
|
||
} catch (err: any) {
|
||
logError('Failed to build ready status.', { error: err?.message || String(err) });
|
||
return;
|
||
}
|
||
this._view.webview.postMessage({ type: 'readyStatus', value: payload });
|
||
}
|
||
|
||
async _sendBrainProfiles() {
|
||
if (!this._view) return;
|
||
const activeBrain = getActiveBrainProfile();
|
||
this._currentSessionBrainId = this._currentSessionBrainId || activeBrain.id;
|
||
const profiles = getBrainProfiles().map((profile) => ({
|
||
id: profile.id,
|
||
name: profile.name,
|
||
path: profile.localBrainPath,
|
||
description: profile.description || '',
|
||
repo: profile.secondBrainRepo || ''
|
||
}));
|
||
this._view.webview.postMessage({
|
||
type: 'brainProfiles',
|
||
value: {
|
||
activeBrainId: activeBrain.id,
|
||
profiles
|
||
}
|
||
});
|
||
void this._sendReadyStatus();
|
||
}
|
||
|
||
_postBrainProfiles(profiles: any[], activeBrainId: string) {
|
||
if (!this._view) return;
|
||
this._view.webview.postMessage({
|
||
type: 'brainProfiles',
|
||
value: {
|
||
activeBrainId,
|
||
profiles: profiles.map((p: any) => ({
|
||
id: p.id || '',
|
||
name: p.name || '',
|
||
path: p.localBrainPath || '',
|
||
description: p.description || '',
|
||
repo: p.secondBrainRepo || ''
|
||
}))
|
||
}
|
||
});
|
||
}
|
||
|
||
async _setActiveBrainProfile(profileId: string, silent: boolean = false) {
|
||
const profiles = getBrainProfiles();
|
||
const nextProfile = profiles.find((profile) => profile.id === profileId) || profiles[0];
|
||
if (!nextProfile) return;
|
||
|
||
await vscode.workspace.getConfiguration('g1nation').update('activeBrainId', nextProfile.id, vscode.ConfigurationTarget.Global);
|
||
this._currentSessionBrainId = nextProfile.id;
|
||
await this._sendBrainProfiles();
|
||
await this._sendBrainStatus();
|
||
|
||
if (!silent) {
|
||
this.injectSystemMessage(`**[Brain Switched]** ${nextProfile.name}\n\`${nextProfile.localBrainPath}\``);
|
||
}
|
||
}
|
||
|
||
async _manageBrains() {
|
||
const activeBrain = getActiveBrainProfile();
|
||
const choice = await vscode.window.showQuickPick([
|
||
{
|
||
label: 'Add Brain Folder',
|
||
description: 'Create a new brain profile from a local folder'
|
||
},
|
||
{
|
||
label: 'Open Active Brain Folder',
|
||
description: activeBrain.localBrainPath
|
||
},
|
||
{
|
||
label: 'Open Brain Settings',
|
||
description: 'Edit names, paths, repos, and descriptions'
|
||
}
|
||
], {
|
||
placeHolder: `Active Brain: ${activeBrain.name} (${activeBrain.localBrainPath})`
|
||
});
|
||
|
||
if (!choice) return;
|
||
|
||
if (choice.label === 'Add Brain Folder') {
|
||
await this._addBrainProfile();
|
||
return;
|
||
}
|
||
|
||
if (choice.label === 'Open Active Brain Folder') {
|
||
if (!fs.existsSync(activeBrain.localBrainPath)) {
|
||
fs.mkdirSync(activeBrain.localBrainPath, { recursive: true });
|
||
}
|
||
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(activeBrain.localBrainPath));
|
||
return;
|
||
}
|
||
|
||
vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation.brainProfiles');
|
||
}
|
||
|
||
async _addBrainProfile() {
|
||
const selected = await vscode.window.showOpenDialog({
|
||
canSelectFiles: false,
|
||
canSelectFolders: true,
|
||
canSelectMany: false,
|
||
openLabel: 'Use as Brain'
|
||
});
|
||
|
||
const folder = selected?.[0]?.fsPath;
|
||
if (!folder) return;
|
||
|
||
const defaultName = path.basename(folder) || 'New Brain';
|
||
const name = await vscode.window.showInputBox({
|
||
prompt: 'Name this brain profile',
|
||
value: defaultName,
|
||
validateInput: (value) => value.trim() ? null : 'Brain name is required.'
|
||
});
|
||
if (!name) return;
|
||
|
||
const description = await vscode.window.showInputBox({
|
||
prompt: 'Optional description shown in the Astra sidebar',
|
||
value: ''
|
||
});
|
||
|
||
const repo = await vscode.window.showInputBox({
|
||
prompt: 'Optional Second Brain Git repository URL',
|
||
value: ''
|
||
});
|
||
|
||
// Read raw settings directly to avoid virtual default-brain (injected in memory by getConfig())
|
||
// being saved into the settings file and corrupting the profile list on next load.
|
||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||
const existingRaw: any[] = cfg.get<any[]>('brainProfiles', []) || [];
|
||
|
||
const idBase = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'brain';
|
||
let id = idBase;
|
||
let suffix = 2;
|
||
while (existingRaw.some((p: any) => p.id === id)) {
|
||
id = `${idBase}-${suffix++}`;
|
||
}
|
||
|
||
const newProfile = {
|
||
id,
|
||
name: name.trim(),
|
||
localBrainPath: folder,
|
||
secondBrainRepo: (repo || '').trim(),
|
||
description: (description || '').trim()
|
||
};
|
||
const nextProfiles = [...existingRaw, newProfile];
|
||
|
||
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
|
||
await cfg.update('activeBrainId', id, vscode.ConfigurationTarget.Global);
|
||
this._currentSessionBrainId = id;
|
||
|
||
// cfg.update() is async and VSCode's config cache may not reflect the new value
|
||
// immediately, so we post the freshly-built profile list directly.
|
||
this._postBrainProfiles(nextProfiles, id);
|
||
await this._sendBrainStatus();
|
||
this.injectSystemMessage(`**[Brain Added]** ${name.trim()}\n\`${folder}\``);
|
||
}
|
||
|
||
async _editBrainProfile(profileId?: string) {
|
||
const currentProfiles = getBrainProfiles();
|
||
const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
|
||
if (!target) return;
|
||
|
||
const name = await vscode.window.showInputBox({
|
||
prompt: 'Edit brain profile name',
|
||
value: target.name,
|
||
validateInput: (value) => value.trim() ? null : 'Brain name is required.'
|
||
});
|
||
if (!name) return;
|
||
|
||
const folder = await vscode.window.showInputBox({
|
||
prompt: 'Edit local brain folder path',
|
||
value: target.localBrainPath,
|
||
validateInput: (value) => value.trim() ? null : 'Brain folder path is required.'
|
||
});
|
||
if (!folder) return;
|
||
|
||
const description = await vscode.window.showInputBox({
|
||
prompt: 'Edit optional description shown in the Astra sidebar',
|
||
value: target.description || ''
|
||
});
|
||
|
||
const repo = await vscode.window.showInputBox({
|
||
prompt: 'Edit optional Second Brain Git repository URL',
|
||
value: target.secondBrainRepo || ''
|
||
});
|
||
|
||
const nextProfiles = currentProfiles.map((profile) => profile.id === target.id
|
||
? {
|
||
...profile,
|
||
name: name.trim(),
|
||
localBrainPath: folder.trim(),
|
||
secondBrainRepo: (repo || '').trim(),
|
||
description: (description || '').trim()
|
||
}
|
||
: profile
|
||
);
|
||
|
||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
|
||
await cfg.update('activeBrainId', target.id, vscode.ConfigurationTarget.Global);
|
||
this._currentSessionBrainId = target.id;
|
||
this._postBrainProfiles(nextProfiles, target.id);
|
||
await this._sendBrainStatus();
|
||
this.injectSystemMessage(`**[Brain Updated]** ${name.trim()}\n\`${folder.trim()}\``);
|
||
}
|
||
|
||
async _deleteBrainProfile(profileId?: string) {
|
||
const currentProfiles = getBrainProfiles();
|
||
const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
|
||
if (!target) return;
|
||
|
||
if (currentProfiles.length <= 1) {
|
||
vscode.window.showWarningMessage('At least one brain profile is required.');
|
||
return;
|
||
}
|
||
|
||
const confirm = await vscode.window.showWarningMessage(
|
||
`Delete brain profile "${target.name}"? The folder itself will not be deleted.`,
|
||
{ modal: true },
|
||
'Delete Profile'
|
||
);
|
||
if (confirm !== 'Delete Profile') return;
|
||
|
||
const nextProfiles = currentProfiles.filter((profile) => profile.id !== target.id);
|
||
const nextActive = nextProfiles[0];
|
||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
|
||
await cfg.update('activeBrainId', nextActive.id, vscode.ConfigurationTarget.Global);
|
||
this._currentSessionBrainId = nextActive.id;
|
||
this._postBrainProfiles(nextProfiles, nextActive.id);
|
||
await this._sendBrainStatus();
|
||
this.injectSystemMessage(`**[Brain Deleted]** ${target.name}`);
|
||
}
|
||
|
||
async _saveWikiRaw() {
|
||
const history = this._agent.getHistory();
|
||
if (history.length === 0) {
|
||
vscode.window.showWarningMessage('There is no conversation to save as wiki raw data.');
|
||
return;
|
||
}
|
||
|
||
const activeBrain = getActiveBrainProfile();
|
||
const rawDir = path.join(activeBrain.localBrainPath, 'raw-data');
|
||
if (!fs.existsSync(rawDir)) {
|
||
fs.mkdirSync(rawDir, { recursive: true });
|
||
}
|
||
|
||
const category = await vscode.window.showInputBox({
|
||
prompt: 'Wiki raw data category',
|
||
value: 'Project Notes'
|
||
});
|
||
if (category === undefined) return;
|
||
|
||
const expectedValue = await vscode.window.showInputBox({
|
||
prompt: 'Expected value or future use',
|
||
value: 'Reusable as source material for a future wiki document.'
|
||
});
|
||
if (expectedValue === undefined) return;
|
||
|
||
const timestamp = new Date();
|
||
const slug = this._slugify(history.find((message) => message.role === 'user')?.content || 'conversation');
|
||
const fileName = `${this._formatTimestampForFile(timestamp)}-${slug}.md`;
|
||
const filePath = path.join(rawDir, fileName);
|
||
const markdown = this._buildWikiRawMarkdown(history, {
|
||
category: category.trim() || 'Project Notes',
|
||
expectedValue: expectedValue.trim() || 'Reusable as source material for a future wiki document.',
|
||
activeBrainName: activeBrain.name,
|
||
activeBrainPath: activeBrain.localBrainPath,
|
||
createdAt: timestamp.toISOString()
|
||
});
|
||
|
||
await vscode.workspace.fs.writeFile(vscode.Uri.file(filePath), Buffer.from(markdown, 'utf8'));
|
||
vscode.window.showInformationMessage(`Wiki raw data saved: ${path.basename(filePath)}`);
|
||
this.injectSystemMessage(`**[Wiki Raw Saved]** \`${filePath}\``);
|
||
}
|
||
|
||
_buildWikiRawMarkdown(history: ChatMessage[], meta: {
|
||
category: string;
|
||
expectedValue: string;
|
||
activeBrainName: string;
|
||
activeBrainPath: string;
|
||
createdAt: string;
|
||
}): string {
|
||
const firstUserMessage = history.find((message) => message.role === 'user')?.content || '';
|
||
const latestUserMessage = [...history].reverse().find((message) => message.role === 'user')?.content || '';
|
||
const latestAssistantMessage = [...history].reverse().find((message) => message.role === 'assistant')?.content || '';
|
||
const rationaleNotes = history
|
||
.filter((message) => message.role === 'assistant' && message.rationale)
|
||
.map((message, index) => [
|
||
`### Process Note ${index + 1}`,
|
||
message.rationale?.problem ? `- Problem: ${message.rationale.problem}` : '',
|
||
message.rationale?.goal ? `- Goal: ${message.rationale.goal}` : '',
|
||
message.rationale?.reasoning ? `- Reasoning: ${message.rationale.reasoning}` : ''
|
||
].filter(Boolean).join('\n'))
|
||
.join('\n\n');
|
||
|
||
const transcript = history
|
||
.map((message, index) => [
|
||
`### ${index + 1}. ${message.role.toUpperCase()}`,
|
||
'',
|
||
message.content.trim() || '(empty)'
|
||
].join('\n'))
|
||
.join('\n\n');
|
||
|
||
return [
|
||
'---',
|
||
`title: "${this._escapeYamlString(this._summarizeForTitle(firstUserMessage))}"`,
|
||
`category: "${this._escapeYamlString(meta.category)}"`,
|
||
`created_at: "${meta.createdAt}"`,
|
||
`source: "Astra conversation"`,
|
||
`brain: "${this._escapeYamlString(meta.activeBrainName)}"`,
|
||
'status: raw',
|
||
'---',
|
||
'',
|
||
`# ${this._summarizeForTitle(firstUserMessage)}`,
|
||
'',
|
||
'## Category',
|
||
meta.category,
|
||
'',
|
||
'## What This Contains',
|
||
this._summarizeTextForWiki(latestAssistantMessage || firstUserMessage),
|
||
'',
|
||
'## Expected Value',
|
||
meta.expectedValue,
|
||
'',
|
||
'## Discovery Process',
|
||
rationaleNotes || 'No explicit rationale metadata was captured. Use the transcript below to reconstruct the reasoning path.',
|
||
'',
|
||
'## Conclusion',
|
||
[
|
||
'This conclusion was derived from the latest user request and assistant response.',
|
||
'',
|
||
`- Latest user request: ${this._summarizeTextForWiki(latestUserMessage)}`,
|
||
`- Latest assistant conclusion: ${this._summarizeTextForWiki(latestAssistantMessage)}`
|
||
].join('\n'),
|
||
'',
|
||
'## Coding Implementation Notes',
|
||
'Use this section to turn the raw transcript into a wiki-ready implementation note. Capture which files changed, why they changed, and what verification was run.',
|
||
'',
|
||
'## Source Brain',
|
||
`- Name: ${meta.activeBrainName}`,
|
||
`- Path: ${meta.activeBrainPath}`,
|
||
'',
|
||
'## Raw Conversation Transcript',
|
||
transcript,
|
||
''
|
||
].join('\n');
|
||
}
|
||
|
||
_formatTimestampForFile(date: Date): string {
|
||
return date.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '');
|
||
}
|
||
|
||
_slugify(value: string): string {
|
||
const slug = value
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9가-힣]+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
.slice(0, 48);
|
||
return slug || 'conversation';
|
||
}
|
||
|
||
_summarizeForTitle(value: string): string {
|
||
const normalized = value.replace(/\s+/g, ' ').trim();
|
||
if (!normalized) return 'Astra Conversation Raw Data';
|
||
return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized;
|
||
}
|
||
|
||
_summarizeTextForWiki(value: string): string {
|
||
const normalized = value.replace(/\s+/g, ' ').trim();
|
||
if (!normalized) return 'Not captured.';
|
||
return normalized.length > 500 ? `${normalized.slice(0, 500)}...` : normalized;
|
||
}
|
||
|
||
_escapeYamlString(value: string): string {
|
||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
}
|
||
|
||
// --- BridgeInterface Methods ---
|
||
|
||
public injectSystemMessage(msg: string): void {
|
||
this._view?.webview.postMessage({ type: 'streamStart' });
|
||
this._view?.webview.postMessage({ type: 'streamChunk', value: msg });
|
||
this._view?.webview.postMessage({ type: 'streamEnd' });
|
||
}
|
||
|
||
public getHistoryText(): string {
|
||
return "Conversation history placeholder for evaluation.";
|
||
}
|
||
|
||
public sendPromptFromExtension(prompt: string): void {
|
||
if (this._view) {
|
||
this._view.show?.(true);
|
||
this._view.webview.postMessage({ type: 'injectPrompt', value: prompt });
|
||
}
|
||
}
|
||
|
||
public findBrainFiles(dir: string): string[] {
|
||
return findBrainFiles(dir);
|
||
}
|
||
|
||
// --- End BridgeInterface ---
|
||
|
||
public focusInput() {
|
||
this._view?.webview.postMessage({ type: 'focusInput' });
|
||
}
|
||
|
||
public clearChat() {
|
||
this._view?.webview.postMessage({ type: 'clearChat' });
|
||
}
|
||
|
||
public async syncBrain() {
|
||
const activeBrain = getActiveBrainProfile();
|
||
const brainDir = activeBrain.localBrainPath;
|
||
if (!fs.existsSync(brainDir)) {
|
||
vscode.window.showErrorMessage("Second Brain directory not found.");
|
||
return;
|
||
}
|
||
vscode.window.withProgress({
|
||
location: vscode.ProgressLocation.Notification,
|
||
title: "Astra: Syncing Second Brain...",
|
||
cancellable: false
|
||
}, async () => {
|
||
try {
|
||
const { execSync } = require('child_process');
|
||
execSync(`git add .`, { cwd: brainDir });
|
||
execSync(`git commit -m "[G1-Sync] Manual knowledge update"`, { cwd: brainDir });
|
||
execSync(`git push`, { cwd: brainDir });
|
||
vscode.window.showInformationMessage("Second Brain synced successfully.");
|
||
} catch (err: any) {
|
||
vscode.window.showInformationMessage("Brain Synced: Local knowledge is up-to-date (no changes to push).");
|
||
}
|
||
});
|
||
}
|
||
|
||
_getChronicleProjects(): ProjectProfile[] {
|
||
const raw = this._context.globalState.get<ProjectProfile[]>(SidebarChatProvider.chronicleProjectsStateKey, []) || [];
|
||
const valid = raw.filter((profile: ProjectProfile) =>
|
||
profile
|
||
&& typeof profile.projectId === 'string'
|
||
&& typeof profile.projectName === 'string'
|
||
&& typeof profile.recordRoot === 'string'
|
||
);
|
||
|
||
if (valid.length > 0) return valid;
|
||
|
||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||
if (!workspaceRoot) return [];
|
||
|
||
const now = new Date().toISOString();
|
||
const projectName = path.basename(workspaceRoot) || 'Current Project';
|
||
return [{
|
||
projectId: this._slugify(projectName),
|
||
projectName,
|
||
projectRoot: workspaceRoot,
|
||
recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
|
||
description: 'Auto-detected current workspace project.',
|
||
corePurpose: 'Capture project planning, decisions, development notes, bugs, and retrospectives as Markdown.',
|
||
targetUsers: ['Project developer'],
|
||
avoidDirections: ['Do not tightly couple records to chat execution internals.'],
|
||
detailLevel: 'standard',
|
||
createdAt: now,
|
||
updatedAt: now
|
||
}];
|
||
}
|
||
|
||
async _putChronicleProjects(projects: ProjectProfile[]) {
|
||
await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects);
|
||
}
|
||
|
||
/**
|
||
* One-time repair for chronicle projects that were stored before the
|
||
* multi-subproject fix landed.
|
||
*
|
||
* Old activation code always treated `workspaceFolders[0]` (= the open
|
||
* parent folder, e.g. `/.../Antigravity`) as the project root, so every
|
||
* subproject the user activated (`ConnectAI`, `Datacollector_MAC`,
|
||
* `Skybound`, …) ended up with `projectRoot === <parent>` in globalState.
|
||
* That breaks reload: `_ensureActiveProjectForWorkspace` case 2 matches
|
||
* the corrupted entry on every boot and force-switches the active
|
||
* project to whichever bad row it found first.
|
||
*
|
||
* The repair: for each chronicle project whose `projectName` does NOT
|
||
* match the basename of its stored `projectRoot` (i.e. it can't actually
|
||
* be the project root), and where a same-name subfolder exists under
|
||
* the current workspace, retarget the entry to that subfolder. Idempotent
|
||
* — re-running it after the first pass changes nothing.
|
||
*/
|
||
async _repairCorruptedChronicleProjectRoots(): Promise<void> {
|
||
const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||
if (!wsRoot) return;
|
||
const projects = this._getChronicleProjects();
|
||
if (projects.length === 0) return;
|
||
const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase();
|
||
const wsRootNorm = norm(wsRoot);
|
||
let changed = false;
|
||
const repaired = projects.map((p) => {
|
||
if (!p.projectName || !p.projectRoot) return p;
|
||
const rootBase = path.basename(p.projectRoot).toLowerCase();
|
||
const nameMatches = rootBase === p.projectName.toLowerCase();
|
||
if (nameMatches) return p; // root basename agrees with name → trust it
|
||
// Only repair entries that look like they were silently captured at
|
||
// the workspace parent. Anything else (different machine, custom
|
||
// path) we leave alone.
|
||
if (norm(p.projectRoot) !== wsRootNorm) return p;
|
||
const candidate = path.join(wsRoot, p.projectName);
|
||
try {
|
||
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) return p;
|
||
} catch {
|
||
return p;
|
||
}
|
||
const newDocPath = path.join(candidate, '.astra', 'project-context', 'architecture.md');
|
||
changed = true;
|
||
return {
|
||
...p,
|
||
projectRoot: candidate,
|
||
recordRoot: path.join(candidate, 'docs', 'records', p.projectName),
|
||
architectureDocPath: fs.existsSync(newDocPath) ? newDocPath : p.architectureDocPath,
|
||
};
|
||
});
|
||
if (!changed) return;
|
||
await this._putChronicleProjects(repaired);
|
||
const fixedCount = repaired.filter((p, i) => p !== projects[i]).length;
|
||
logInfo('architecture: repaired chronicle projects with wrong projectRoot.', {
|
||
wsRoot, count: fixedCount,
|
||
});
|
||
}
|
||
|
||
// ─── Project Architecture Context (Feature 2) ──────────────────────────────
|
||
//
|
||
// Activation flow:
|
||
// 1. Chat preprocessor (or an explicit "Activate" button) calls
|
||
// _tryActivateArchitectureFromText(latestUserMessage).
|
||
// 2. If the text yields a known/inferable project, we set it active,
|
||
// ensure the architecture doc exists, register the file watcher,
|
||
// and broadcast the state to the webview as a chip.
|
||
// 3. On every subsequent prompt, _handlePrompt reads
|
||
// _buildProjectArchitectureContext() and injects it into the model
|
||
// call. Detach → empty context + watcher disposed.
|
||
|
||
/** True if the active project has its architecture doc auto-attached. */
|
||
_isArchitectureAutoAttached(): boolean {
|
||
const p = this._getActiveChronicleProject();
|
||
return !!(p && p.architectureDocPath && p.architectureAutoAttach !== false);
|
||
}
|
||
|
||
/**
|
||
* Try to resolve a project handle from arbitrary user text. Combines:
|
||
* • Korean / English natural-language activation phrasing.
|
||
* • Absolute filesystem paths.
|
||
* • The existing Chronicle project list as ground truth for name matches.
|
||
*/
|
||
_detectProjectFromText(text: string): KnownProject | null {
|
||
const known = this._getChronicleProjects().map<KnownProject>((p) => ({
|
||
projectId: p.projectId,
|
||
projectName: p.projectName,
|
||
projectRoot: p.projectRoot,
|
||
}));
|
||
const hit = detectProjectIntent(text || '', known);
|
||
return hit?.project ?? null;
|
||
}
|
||
|
||
/**
|
||
* Activate (or refresh) architecture context for the project resolved from
|
||
* `text`. No-op when no project is detected. Returns the activated profile
|
||
* id, or `null` if nothing changed. Side-effects: writes the architecture
|
||
* doc, marks the project active, broadcasts the chip state.
|
||
*/
|
||
async _tryActivateArchitectureFromText(text: string): Promise<string | null> {
|
||
const detected = this._detectProjectFromText(text);
|
||
if (!detected) return null;
|
||
return this._activateArchitectureForProject(detected.projectId, {
|
||
fallbackName: detected.projectName,
|
||
fallbackRoot: detected.projectRoot,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Make `projectId` the active project, ensure its architecture doc exists,
|
||
* and register the file watcher. If the project isn't in the chronicle
|
||
* store yet (path-only match), materialise a minimal profile so subsequent
|
||
* turns can find it.
|
||
*/
|
||
async _activateArchitectureForProject(
|
||
projectId: string,
|
||
opts: { fallbackName?: string; fallbackRoot?: string } = {}
|
||
): Promise<string | null> {
|
||
const projects = this._getChronicleProjects();
|
||
let profile = projects.find((p) => p.projectId === projectId);
|
||
|
||
// Materialise a stub when the user references a project by path that
|
||
// isn't yet registered. We use the path's basename as the name and the
|
||
// standard records location as recordRoot so existing Chronicle code
|
||
// keeps working.
|
||
if (!profile) {
|
||
const root = opts.fallbackRoot || '';
|
||
if (!root) {
|
||
logError('architecture: cannot activate without project root.', { projectId });
|
||
return null;
|
||
}
|
||
const name = opts.fallbackName || path.basename(root) || projectId;
|
||
const now = new Date().toISOString();
|
||
profile = {
|
||
projectId,
|
||
projectName: name,
|
||
projectRoot: root,
|
||
recordRoot: path.join(root, 'docs', 'records', name),
|
||
description: 'Auto-created by Project Architecture activation.',
|
||
corePurpose: '',
|
||
detailLevel: 'standard',
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
projects.push(profile);
|
||
await this._putChronicleProjects(projects);
|
||
}
|
||
|
||
if (!profile.projectRoot) {
|
||
logError('architecture: profile has no projectRoot; cannot scan.', { projectId });
|
||
return null;
|
||
}
|
||
|
||
// Generate or refresh the doc. Always idempotent — the generator
|
||
// preserves user-owned sections.
|
||
const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
|
||
const now = new Date().toISOString();
|
||
const updated: ProjectProfile = {
|
||
...profile,
|
||
architectureDocPath: result.docPath,
|
||
// [BUGFIX] Previously used `?? true`, but `??` only fallbacks on null/undefined.
|
||
// After a user Detach, `architectureAutoAttach === false`, so `false ?? true`
|
||
// stays `false` and the chip stays "detached — click Attach to re-enable"
|
||
// forever no matter how many times the user clicks Attach. The activation
|
||
// path is an explicit user intent to re-enable, so force `true` here.
|
||
architectureAutoAttach: true,
|
||
architectureAutoUpdate: profile.architectureAutoUpdate ?? true,
|
||
architectureLastUpdated: now,
|
||
architectureLastScanSignature: result.scan.signature,
|
||
updatedAt: now,
|
||
};
|
||
const next = projects.map((p) => (p.projectId === updated.projectId ? updated : p));
|
||
await this._putChronicleProjects(next);
|
||
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, projectId);
|
||
|
||
// (Re)register the watcher for this project.
|
||
this._registerArchitectureWatcher(updated);
|
||
|
||
// Tell the webview to show / refresh the chip.
|
||
await this._sendArchitectureStatus();
|
||
logInfo('architecture: activated.', {
|
||
projectId, docPath: result.docPath, created: result.created,
|
||
});
|
||
return projectId;
|
||
}
|
||
|
||
/** Detach project mode: stop auto-attaching the doc and dispose the watcher. */
|
||
async _detachArchitecture(): Promise<void> {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile) {
|
||
this._disposeArchitectureWatcher();
|
||
await this._sendArchitectureStatus();
|
||
return;
|
||
}
|
||
const projects = this._getChronicleProjects();
|
||
const next = projects.map((p) => p.projectId === profile.projectId
|
||
? { ...p, architectureAutoAttach: false }
|
||
: p);
|
||
await this._putChronicleProjects(next);
|
||
this._disposeArchitectureWatcher();
|
||
await this._sendArchitectureStatus();
|
||
logInfo('architecture: detached.', { projectId: profile.projectId });
|
||
}
|
||
|
||
/**
|
||
* Force a refresh of the architecture doc for the active project.
|
||
*
|
||
* Always rewrites the auto-managed block (so the "Last Refresh" stamp +
|
||
* stats reflect the click). Emits an `architectureRefreshResult` event
|
||
* with the per-file work breakdown — that's what makes the operation
|
||
* visibly trustworthy in the UI (no more "0.1s, nothing visible").
|
||
*/
|
||
async _refreshArchitecture(): Promise<void> {
|
||
const startedAt = Date.now();
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile || !profile.projectRoot) {
|
||
this._view?.webview.postMessage({
|
||
type: 'architectureRefreshFailed',
|
||
value: { reason: 'no-active-project' },
|
||
});
|
||
return;
|
||
}
|
||
const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
|
||
const now = new Date().toISOString();
|
||
const projects = this._getChronicleProjects();
|
||
const next = projects.map((p) => p.projectId === profile.projectId
|
||
? {
|
||
...p,
|
||
architectureDocPath: result.docPath,
|
||
architectureLastUpdated: now,
|
||
architectureLastScanSignature: result.scan.signature,
|
||
updatedAt: now,
|
||
}
|
||
: p);
|
||
await this._putChronicleProjects(next);
|
||
await this._sendArchitectureStatus();
|
||
// Tell the webview exactly what the scan did so the user can
|
||
// trust the "Refresh" button actually ran. The three numbers
|
||
// (newly / cached / deleted) together explain whether the doc
|
||
// changed or just had its timestamp bumped.
|
||
this._view?.webview.postMessage({
|
||
type: 'architectureRefreshResult',
|
||
value: {
|
||
projectName: profile.projectName,
|
||
docPath: result.docPath,
|
||
newlyAnalyzed: result.refreshStats.newlyAnalyzed,
|
||
cached: result.refreshStats.cached,
|
||
deleted: result.refreshStats.deleted.length,
|
||
durationMs: Date.now() - startedAt,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Re-attach the architecture context for the active project after a
|
||
* prior Detach. Rebuilds the doc (so the user gets a fresh scan),
|
||
* flips `architectureAutoAttach=true`, re-registers the watcher, and
|
||
* broadcasts the chip back to its active state. The complement of
|
||
* `_detachArchitecture`.
|
||
*/
|
||
async _attachArchitecture(): Promise<void> {
|
||
// `_ensureActiveProjectForWorkspace` guarantees the active project
|
||
// matches the current VS Code workspace — without that, hitting
|
||
// Attach right after opening a different folder would silently
|
||
// attach to whatever was last active in the *previous* workspace.
|
||
const profile = await this._ensureActiveProjectForWorkspace();
|
||
if (!profile || !profile.projectRoot) {
|
||
this._view?.webview.postMessage({
|
||
type: 'architectureRefreshFailed',
|
||
value: { reason: 'no-active-project' },
|
||
});
|
||
return;
|
||
}
|
||
await this._activateArchitectureForProject(profile.projectId, {
|
||
fallbackName: profile.projectName,
|
||
fallbackRoot: profile.projectRoot,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Make sure the active chronicle project actually corresponds to the
|
||
* folder the user has open in VS Code. Three cases:
|
||
*
|
||
* 1. Active project already matches workspace → return it as-is.
|
||
* 2. A *different* chronicle project matches the workspace → flip
|
||
* the active id to that one (the user switched folders since
|
||
* last session).
|
||
* 3. No chronicle project matches → synthesise a new one from the
|
||
* workspace folder name + register it.
|
||
*
|
||
* Returns the (possibly newly created) active project, or `null` when
|
||
* no workspace is open. Idempotent — calling repeatedly with no change
|
||
* is free.
|
||
*/
|
||
async _ensureActiveProjectForWorkspace(): Promise<ProjectProfile | null> {
|
||
const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||
if (!wsRoot) return null;
|
||
// When the parent folder contains several subprojects, use the active
|
||
// editor's location to pick the *effective* subproject root instead of
|
||
// always reporting the parent.
|
||
const hint = vscode.window.activeTextEditor?.document.uri.fsPath
|
||
?? vscode.window.visibleTextEditors[0]?.document.uri.fsPath;
|
||
const resolved = resolveActiveSubprojectRoot(wsRoot, hint);
|
||
const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase();
|
||
const active = this._getActiveChronicleProject();
|
||
const projects = this._getChronicleProjects();
|
||
// Did the editor hint actually point at a nested subproject? If the
|
||
// resolver fell back to the workspace root (no nested marker, or the
|
||
// Astra sidebar itself was focused so no editor existed), we must NOT
|
||
// clobber the user's current active project — they may have set it by
|
||
// hand via the project picker, or by typing a project intent. Auto-
|
||
// switching is only safe when there is a clear editor-driven signal.
|
||
const isNestedHit = !!resolved && norm(resolved) !== norm(wsRoot);
|
||
if (!isNestedHit && active) {
|
||
return active;
|
||
}
|
||
const workspaceRoot = isNestedHit ? resolved : wsRoot;
|
||
if (active && active.projectRoot && norm(active.projectRoot) === norm(workspaceRoot)) {
|
||
return active;
|
||
}
|
||
// Case 2: another chronicle project matches → switch active to it
|
||
const matching = projects.find((p) => norm(p.projectRoot) === norm(workspaceRoot));
|
||
if (matching) {
|
||
await this._context.globalState.update(
|
||
SidebarChatProvider.activeChronicleProjectStateKey,
|
||
matching.projectId,
|
||
);
|
||
logInfo('architecture: switched active project to match workspace.', {
|
||
from: active?.projectId,
|
||
to: matching.projectId,
|
||
});
|
||
return matching;
|
||
}
|
||
// Case 3: synthesise a fresh entry for this workspace
|
||
const projectName = path.basename(workspaceRoot) || 'Current Project';
|
||
const projectId = this._slugify(projectName);
|
||
const now = new Date().toISOString();
|
||
const profile: ProjectProfile = {
|
||
projectId,
|
||
projectName,
|
||
projectRoot: workspaceRoot,
|
||
recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
|
||
description: 'Auto-detected from workspace folder.',
|
||
corePurpose: '',
|
||
detailLevel: 'standard',
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
const nextProjects = projects.filter((p) => p.projectId !== projectId).concat(profile);
|
||
await this._putChronicleProjects(nextProjects);
|
||
await this._context.globalState.update(
|
||
SidebarChatProvider.activeChronicleProjectStateKey,
|
||
projectId,
|
||
);
|
||
logInfo('architecture: registered new project from workspace.', {
|
||
projectId, projectRoot: workspaceRoot,
|
||
});
|
||
return profile;
|
||
}
|
||
|
||
/**
|
||
* Build the `projectArchitectureContext` string for the active prompt.
|
||
* Returns empty string when auto-attach is off or the doc is missing —
|
||
* agent.ts then treats it as "no block" and emits nothing extra.
|
||
*/
|
||
_buildProjectArchitectureContext(): string {
|
||
const p = this._getActiveChronicleProject();
|
||
if (!p || p.architectureAutoAttach === false || !p.architectureDocPath) return '';
|
||
if (!fs.existsSync(p.architectureDocPath)) return '';
|
||
return formatArchitectureContextForPrompt({
|
||
projectName: p.projectName,
|
||
docPath: p.architectureDocPath,
|
||
// Pass the project root so the `Source:` header in the prompt is
|
||
// workspace-relative — keeps the prompt portable across machines.
|
||
projectRoot: p.projectRoot,
|
||
lastUpdated: p.architectureLastUpdated,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Webview chip data. Three states:
|
||
*
|
||
* 1. **active** — project mode is on; doc is being auto-attached.
|
||
* 2. **inactive** — there's a project + workspace, but architecture
|
||
* is either never-activated or user-detached.
|
||
* The chip shows an `[Attach]` button instead of
|
||
* hiding entirely, so users always have a one-
|
||
* click path back into project mode.
|
||
* 3. **hidden** — no workspace open and no project at all.
|
||
*
|
||
* Also does an auto-activation pass for the *fresh-workspace* case:
|
||
* when the active project has no `architectureDocPath` yet AND the
|
||
* user hasn't explicitly detached, we generate the doc + flip
|
||
* `autoAttach=true` so the user opens a new folder and immediately
|
||
* sees the architecture context working. Existing detach choices are
|
||
* always respected.
|
||
*/
|
||
async _sendArchitectureStatus(): Promise<void> {
|
||
if (!this._view) return;
|
||
// Always sync the active project to the current VS Code workspace
|
||
// before reporting — otherwise switching workspaces leaves the
|
||
// chip pointing at the *previous* project's doc.
|
||
const p = await this._ensureActiveProjectForWorkspace();
|
||
if (!p) {
|
||
this._view.webview.postMessage({ type: 'architectureStatus', value: { active: false } });
|
||
return;
|
||
}
|
||
const wasDetached = p.architectureAutoAttach === false;
|
||
const hasDoc = !!(p.architectureDocPath && fs.existsSync(p.architectureDocPath));
|
||
|
||
// Auto-activation for fresh workspaces: never been activated AND
|
||
// never been detached → kick off a build and re-broadcast. Single
|
||
// recursion is safe because the post-activate state will hit the
|
||
// `active` branch below.
|
||
if (!hasDoc && !wasDetached && p.projectRoot) {
|
||
try {
|
||
await this._activateArchitectureForProject(p.projectId, {
|
||
fallbackName: p.projectName,
|
||
fallbackRoot: p.projectRoot,
|
||
});
|
||
return; // _activateArchitectureForProject sends its own status
|
||
} catch (e: any) {
|
||
logError('architecture: auto-activate failed.', { error: e?.message ?? String(e) });
|
||
// Fall through to the inactive state so the user still sees an Attach button.
|
||
}
|
||
}
|
||
|
||
const fullyActive = hasDoc && !wasDetached;
|
||
if (fullyActive) {
|
||
this._view.webview.postMessage({
|
||
type: 'architectureStatus',
|
||
value: {
|
||
active: true,
|
||
projectId: p.projectId,
|
||
projectName: p.projectName,
|
||
docPath: p.architectureDocPath,
|
||
lastUpdated: p.architectureLastUpdated || '',
|
||
autoUpdate: p.architectureAutoUpdate !== false,
|
||
},
|
||
});
|
||
// Re-register the watcher in case it was disposed (e.g. workspace switch).
|
||
this._registerArchitectureWatcher(p);
|
||
} else {
|
||
// Inactive but attachable: surface the project name + an Attach hook.
|
||
this._view.webview.postMessage({
|
||
type: 'architectureStatus',
|
||
value: {
|
||
active: false,
|
||
canAttach: !!p.projectRoot,
|
||
projectId: p.projectId,
|
||
projectName: p.projectName,
|
||
// Distinguishes "never activated" from "detached" so the
|
||
// chip can choose the right label ("Activate" vs "Re-attach").
|
||
detached: wasDetached,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── 1인 기업 (Company) Mode ────────────────────────────────────────────
|
||
//
|
||
// When `companyState.enabled` is true, prompts coming through the chat
|
||
// handler are routed to `_runCompanyTurn` instead of the normal
|
||
// AgentExecutor path. The dispatcher emits `companyTurnUpdate` events as
|
||
// each phase progresses; the webview shows a step-by-step header for
|
||
// CEO planning, each specialist's dispatch, and the final synthesis.
|
||
|
||
/** True iff company mode is active. Cheap — read from globalState. */
|
||
isCompanyModeEnabled(): boolean {
|
||
return readCompanyState(this._context).enabled;
|
||
}
|
||
|
||
/**
|
||
* Abort the currently-running 1인 기업 turn if any. Returns true when an
|
||
* abort was actually fired (so the chat handler can skip `agent.stop()`
|
||
* — the company path never touches AgentExecutor). The dispatcher will
|
||
* see `signal.aborted` at its next phase boundary and emit
|
||
* `phase: 'aborted'`; `_runCompanyTurn`'s finally clause then posts
|
||
* `streamEnd` so the UI unlocks.
|
||
*/
|
||
abortCompanyTurn(): boolean {
|
||
if (!this._companyAbort) return false;
|
||
this._companyAbort.abort();
|
||
// 승인 게이트 대기 중인 모든 stage를 'abort'로 해제. 안 하면 dispatcher가
|
||
// 영원히 await 상태로 남아 turn이 절대 종료 안 됨.
|
||
for (const resolve of this._pendingApprovals.values()) {
|
||
try { resolve({ kind: 'abort' }); } catch { /* noop */ }
|
||
}
|
||
this._pendingApprovals.clear();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Clear the cached "last completed company turn" — called when the user
|
||
* starts a new chat / loads a different session. Without this, an old
|
||
* report would bleed into the next session's intent classifications and
|
||
* make stale "follow-up" verdicts.
|
||
*/
|
||
clearLastCompanyTurnSummary(): void {
|
||
this._lastCompanyTurnSummary = undefined;
|
||
}
|
||
|
||
/** Read accessor for the intent classifier. May return undefined on cold start. */
|
||
getLastCompanyTurnSummary() {
|
||
return this._lastCompanyTurnSummary;
|
||
}
|
||
|
||
/**
|
||
* Intent Alignment 1라운드 실행. new_task 분류 직후 (또는 사용자 답변 후)
|
||
* 호출되며 LLM 분석기를 한 번 돌려 contract를 채운다. confidence가 'high'
|
||
* (또는 strict 모드 아니고 medium)이면 곧장 pipeline dispatch로 넘어가고,
|
||
* 그 외엔 webview에 카드를 띄워 사용자 응답을 기다린다(_pendingAlignment).
|
||
*
|
||
* mode:
|
||
* - 'smart': high → 자동 dispatch, medium → 사용자 확인 카드, low → 질문 카드
|
||
* - 'strict': confidence 무관 항상 사용자 확인 카드
|
||
*
|
||
* roundsLimit를 넘기면 더 묻지 않고 현재 contract로 카드(확인)만 띄움.
|
||
*/
|
||
async _runIntentAlignment(opts: {
|
||
userPrompt: string;
|
||
previousContract?: import('./features/company').RequirementContract;
|
||
previousAnswers?: Array<{ q: string; a: string }>;
|
||
pipelineIdOverride?: string;
|
||
mode: 'smart' | 'strict';
|
||
roundsLimit: number;
|
||
roundsAsked: number;
|
||
}): Promise<void> {
|
||
const { analyzeIntent, readCompanyState, resolveActivePipeline, listActiveAgentsByCategory } =
|
||
await import('./features/company');
|
||
const { AIService } = await import('./core/services');
|
||
const cfg = getConfig();
|
||
const state = readCompanyState(this._context);
|
||
const activePipeline = resolveActivePipeline(state);
|
||
// 활성 직군 — 분석기가 "이 회사가 어떤 일을 할 수 있나"를 알아야
|
||
// goal/format을 그 능력에 맞춰 추출할 수 있다.
|
||
const byCat = listActiveAgentsByCategory(state);
|
||
const availableRoleCategories = Object.entries(byCat)
|
||
.filter(([, list]) => list.length > 0)
|
||
.map(([cat]) => cat);
|
||
|
||
// Pixel Office: 분석 시작 표시 (LLM 콜 직전).
|
||
try { this.pixelOfficeOnAlignmentStart(opts.userPrompt); } catch { /* noop */ }
|
||
const analysis = await analyzeIntent(
|
||
new AIService(),
|
||
{
|
||
userOriginalPrompt: opts.userPrompt,
|
||
previousAnswers: opts.previousAnswers,
|
||
previousContract: opts.previousContract,
|
||
activePipelineName: activePipeline?.name,
|
||
availableRoleCategories,
|
||
},
|
||
// 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
|
||
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
|
||
);
|
||
|
||
const contract = analysis.contract;
|
||
const mode = opts.mode;
|
||
const reachedLimit = opts.roundsAsked >= opts.roundsLimit;
|
||
|
||
// 자동 진행 조건: smart 모드 + high confidence + open question 없음.
|
||
// strict 모드면 절대 자동 진행 안 함 — 항상 사용자 확인.
|
||
const canAutoProceed = mode === 'smart'
|
||
&& contract.confidence === 'high'
|
||
&& contract.openQuestions.length === 0;
|
||
|
||
if (canAutoProceed) {
|
||
// contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
|
||
this._view?.webview.postMessage({
|
||
type: 'companyAlignmentCard',
|
||
value: {
|
||
kind: 'auto-proceed',
|
||
contract,
|
||
},
|
||
});
|
||
try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ }
|
||
this._pendingAlignment = undefined;
|
||
await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract);
|
||
return;
|
||
}
|
||
|
||
// 그 외 — 카드 표시 + 사용자 응답 대기. 라운드 한도 도달했거나
|
||
// openQuestions가 비어 있으면 "확인" 카드(질문 없음, 진행/취소 버튼만).
|
||
const askMode = reachedLimit || contract.openQuestions.length === 0
|
||
? 'confirm' : 'questions';
|
||
this._view?.webview.postMessage({
|
||
type: 'companyAlignmentCard',
|
||
value: {
|
||
kind: askMode,
|
||
contract,
|
||
roundsAsked: opts.roundsAsked,
|
||
roundsLimit: opts.roundsLimit,
|
||
},
|
||
});
|
||
try { this.pixelOfficeOnAlignmentResult(askMode, contract); } catch { /* noop */ }
|
||
this._pendingAlignment = {
|
||
userOriginalPrompt: opts.userPrompt,
|
||
contract,
|
||
roundsAsked: opts.roundsAsked,
|
||
pipelineIdOverride: opts.pipelineIdOverride,
|
||
};
|
||
// streamEnd 보내야 채팅 input 잠금이 풀려 사용자가 답변을 칠 수 있음.
|
||
// 평소 1인 기업 turn은 _runCompanyTurn finally에서 보내지만, alignment는
|
||
// dispatcher를 안 거치고 사용자 입력으로 unlock해야 하므로 명시적으로 push.
|
||
this._view?.webview.postMessage({ type: 'streamEnd' });
|
||
void this._sendReadyStatus();
|
||
}
|
||
|
||
/**
|
||
* 사용자가 alignment 카드 상태에서 채팅 입력(답변)을 보낸 경우 호출.
|
||
* 답변을 contract에 합쳐 분석기 재호출, 라운드를 한 칸 늘림.
|
||
*/
|
||
async _handleAlignmentAnswer(userMessage: string): Promise<void> {
|
||
const pending = this._pendingAlignment;
|
||
if (!pending) return;
|
||
const cfg = getConfig();
|
||
const mode = (cfg.companyIntentAlignmentMode === 'strict') ? 'strict' : 'smart';
|
||
const roundsLimit = Math.max(1, Math.min(5, cfg.companyIntentAlignmentMaxRounds ?? 3));
|
||
// 사용자 답변을 미해결 질문들에 일괄 매핑 — 작은 모델이 자연어로 통째로
|
||
// 답하기 마련이라 질문별로 분리 안 함. 그냥 "이번 라운드 사용자 추가
|
||
// 답변" 한 덩어리로 넣어주면 분석기가 다음 라운드에 그걸 보고 알아서
|
||
// 갱신한다.
|
||
const compositeAnswer = userMessage.trim();
|
||
const updatedAnswers = [
|
||
...pending.contract.answeredQuestions,
|
||
{ q: pending.contract.openQuestions.join(' / ') || '(추가 정보 요청)', a: compositeAnswer },
|
||
];
|
||
// 슬롯 비워두고 alignment 다시 돌림 — 새 결과가 다시 _pendingAlignment를
|
||
// 채울 것이고, 자동 진행 조건 충족 시 pipeline까지 갈 수도 있다.
|
||
this._pendingAlignment = undefined;
|
||
await this._runIntentAlignment({
|
||
userPrompt: pending.userOriginalPrompt,
|
||
previousContract: pending.contract,
|
||
previousAnswers: updatedAnswers,
|
||
pipelineIdOverride: pending.pipelineIdOverride,
|
||
mode,
|
||
roundsLimit,
|
||
roundsAsked: pending.roundsAsked + 1,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 사용자가 카드의 "✅ 진행" 버튼을 눌러 현 contract 그대로 dispatch
|
||
* 시키고 싶을 때. 슬롯 비우고 pipeline.
|
||
*/
|
||
async _proceedWithCurrentAlignment(): Promise<void> {
|
||
const pending = this._pendingAlignment;
|
||
if (!pending) return;
|
||
this._pendingAlignment = undefined;
|
||
await this._runCompanyTurn(
|
||
pending.userOriginalPrompt,
|
||
undefined,
|
||
pending.pipelineIdOverride,
|
||
pending.contract,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때.
|
||
* 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌.
|
||
*/
|
||
cancelPendingAlignment(): void {
|
||
if (!this._pendingAlignment) return;
|
||
this._pendingAlignment = undefined;
|
||
this._view?.webview.postMessage({ type: 'companyAlignmentCard', value: { kind: 'cancelled' } });
|
||
this._view?.webview.postMessage({ type: 'streamEnd' });
|
||
try { this.pixelOfficeOnAlignmentCancelled(); } catch { /* noop */ }
|
||
void this._sendReadyStatus();
|
||
}
|
||
|
||
/**
|
||
* Casual chat path inside 1인 기업 모드 — used when the intent classifier
|
||
* routes a message to `chat` or `followup` instead of `new_task`. We
|
||
* deliberately *don't* spin up the dispatcher here: that surface is for
|
||
* multi-step work. Instead we route through the normal chat path (same
|
||
* AgentExecutor.handlePrompt used outside company mode) so streaming UI,
|
||
* brain retrieval, and the action-tag executor all work as users expect.
|
||
*
|
||
* The `reason` from the classifier is surfaced as a small label so the
|
||
* user can tell *why* their message wasn't treated as a new task — if
|
||
* they meant it as one, they can rephrase or override with a keyword
|
||
* like "파이프라인 돌려" / "기획해줘".
|
||
*/
|
||
async _handleCompanyCasual(prompt: string, intent: 'chat' | 'followup', reason: string, originalData: any): Promise<void> {
|
||
// 사용자에게 "왜 이게 가벼운 응답으로 갔는지" 보여주는 한 줄 라벨.
|
||
// 잘못 분류된 거라면 사용자가 즉시 인지하고 다시 말할 수 있어야 한다.
|
||
const label = intent === 'followup' ? '💬 후속 대화' : '💬 대화';
|
||
this._view?.webview.postMessage({
|
||
type: 'companyIntentDecision',
|
||
value: { intent, reason, label },
|
||
});
|
||
await this._handlePrompt(originalData);
|
||
await this._autoWriteChronicleAfterPrompt();
|
||
await this._saveCurrentSession();
|
||
}
|
||
|
||
/**
|
||
* Called by chatHandlers when the user clicks an approval card button.
|
||
* Resolves the dispatcher's awaitApproval promise for `stageId`. Idempotent
|
||
* — extra clicks after the first one are silently dropped.
|
||
*/
|
||
resolveApprovalGate(stageId: string, decision: import('./features/company/dispatcher').ApprovalDecision): boolean {
|
||
const resolve = this._pendingApprovals.get(stageId);
|
||
if (!resolve) return false;
|
||
this._pendingApprovals.delete(stageId);
|
||
try { resolve(decision); } catch { /* noop */ }
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Push the full pipeline catalogue + active id to the webview so the
|
||
* editor overlay can render the cards. Pipelines are user-defined
|
||
* (no built-ins) so an empty list is the default for new users.
|
||
*/
|
||
async _sendCompanyPipelines(): Promise<void> {
|
||
if (!this._view) return;
|
||
const state = readCompanyState(this._context);
|
||
// 직군별 활성 에이전트도 같이 — 파이프라인 에디터가 "직군 → 담당자"
|
||
// cascading dropdown을 채울 때 이 페이로드만 보고 그릴 수 있게.
|
||
const { listActiveAgentsByCategory } = await import('./features/company');
|
||
const byCategory = listActiveAgentsByCategory(state);
|
||
// CompanyAgentDef를 통째로 보내는 대신 UI에 필요한 필드만 추려서.
|
||
const slimByCategory: Record<string, Array<{ id: string; name: string; emoji: string }>> = {};
|
||
for (const [cat, defs] of Object.entries(byCategory)) {
|
||
slimByCategory[cat] = defs.map((d) => ({ id: d.id, name: d.name, emoji: d.emoji }));
|
||
}
|
||
// 템플릿 카탈로그 — 가벼운 메타데이터만 (stages는 stamp 시점에 한 번
|
||
// 더 요청). UI는 dropdown 옵션 텍스트만 필요.
|
||
const templates = PIPELINE_TEMPLATES.map((t) => ({
|
||
templateId: t.templateId,
|
||
name: t.name,
|
||
description: t.description,
|
||
stageCount: t.stages.length,
|
||
suggestedPipelineId: t.suggestedPipelineId,
|
||
suggestedPipelineName: t.suggestedPipelineName,
|
||
}));
|
||
this._view.webview.postMessage({
|
||
type: 'companyPipelines',
|
||
value: {
|
||
pipelines: state.pipelines ?? {},
|
||
activePipelineId: state.activePipelineId ?? null,
|
||
roleCategoryLabels: ROLE_CATEGORY_LABELS,
|
||
roleCategoryOrder: ROLE_CATEGORY_ORDER,
|
||
activeAgentsByCategory: slimByCategory,
|
||
templates,
|
||
},
|
||
});
|
||
}
|
||
|
||
/** Send the chip state (active flag + agent count + name) to the webview. */
|
||
async _sendCompanyStatus(): Promise<void> {
|
||
if (!this._view) return;
|
||
const state = readCompanyState(this._context);
|
||
this._view.webview.postMessage({
|
||
type: 'companyStatus',
|
||
value: {
|
||
enabled: state.enabled,
|
||
companyName: state.companyName,
|
||
summary: summarizeForChip(state),
|
||
activeAgentIds: state.activeAgentIds,
|
||
modelOverrides: state.modelOverrides,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Push the full agent catalogue when the manage panel opens. Each entry
|
||
* carries both the *default* (from `agents.ts`) and *override* (from
|
||
* globalState) fields so the UI can show the user what they've edited,
|
||
* gray out unchanged fields, and offer a Reset button per agent.
|
||
*/
|
||
async _sendCompanyAgents(): Promise<void> {
|
||
if (!this._view) return;
|
||
const state = readCompanyState(this._context);
|
||
const cfg = getConfig();
|
||
const globalWeight = cfg.knowledgeMixSecondBrainWeight ?? 50;
|
||
// Built-ins first (insertion order from agents.ts), then user-added
|
||
// customs in their own order. `custom: true` lets the UI render a
|
||
// delete button differently for user-added entries.
|
||
const hiddenSet = new Set(state.hiddenBuiltinIds ?? []);
|
||
const builtinIds = COMPANY_AGENT_ORDER.filter((id) => !!COMPANY_AGENTS[id] && !hiddenSet.has(id));
|
||
const customIds = state.customAgents ? Object.keys(state.customAgents) : [];
|
||
const orderedIds = [...builtinIds, ...customIds];
|
||
// 파이프라인에서 사용 중인 에이전트 id별로 사용처(파이프라인 이름) 목록을 미리 계산.
|
||
// 카드의 삭제 버튼 비활성화 + tooltip에 사용 중인 파이프라인 이름을 노출하는 데 사용.
|
||
const pipelineUsage: Record<string, string[]> = {};
|
||
for (const p of Object.values(state.pipelines ?? {})) {
|
||
for (const s of p.stages) {
|
||
if (!s.agentId) continue;
|
||
(pipelineUsage[s.agentId] = pipelineUsage[s.agentId] ?? []).push(p.name || p.id);
|
||
}
|
||
}
|
||
const renderEntry = (id: string) => {
|
||
const builtin = COMPANY_AGENTS[id];
|
||
const custom = state.customAgents?.[id];
|
||
const baseDef = builtin ?? custom;
|
||
if (!baseDef) return null;
|
||
// 직군 override 적용된 effective def. 카드의 드롭다운이 옳은 선택값을
|
||
// 보이려면 override 결과를 보내야 한다.
|
||
const effective = resolveAgent(state, id) ?? baseDef;
|
||
const isCustom = !builtin;
|
||
const override = state.promptOverrides[id] || {};
|
||
const kmOverride = state.knowledgeMixOverrides[id];
|
||
const hasKmOverride = typeof kmOverride === 'number';
|
||
const roleOverride = state.roleCategoryOverrides?.[id];
|
||
const displayOv = state.displayOverrides?.[id] || {};
|
||
return {
|
||
id,
|
||
name: effective.name,
|
||
role: effective.role,
|
||
emoji: effective.emoji,
|
||
color: effective.color,
|
||
alwaysOn: !!effective.alwaysOn,
|
||
custom: isCustom,
|
||
active: id === 'ceo' || state.activeAgentIds.includes(id),
|
||
modelOverride: state.modelOverrides[id] || '',
|
||
defaultTagline: baseDef.tagline,
|
||
defaultSpecialty: baseDef.specialty,
|
||
defaultPersona: baseDef.persona || '',
|
||
tagline: override.tagline || baseDef.tagline,
|
||
specialty: override.specialty || baseDef.specialty,
|
||
persona: override.persona || baseDef.persona || '',
|
||
personaOverridden: !!override.persona,
|
||
specialtyOverridden: !!override.specialty,
|
||
taglineOverridden: !!override.tagline,
|
||
// 디스플레이 override — Edit 폼이 default vs current를 비교해
|
||
// dirty 표시할 수 있도록 baseDef의 원본 값도 같이 보낸다.
|
||
defaultName: baseDef.name,
|
||
defaultRole: baseDef.role,
|
||
defaultEmoji: baseDef.emoji,
|
||
defaultColor: baseDef.color,
|
||
nameOverridden: !!displayOv.name,
|
||
roleOverridden: !!displayOv.role,
|
||
emojiOverridden: !!displayOv.emoji,
|
||
colorOverridden: !!displayOv.color,
|
||
// 직군: effective(override 반영) + def 기본값 + override 플래그
|
||
roleCategory: effective.roleCategory,
|
||
defaultRoleCategory: baseDef.roleCategory,
|
||
roleCategoryOverridden: !!roleOverride && roleOverride !== baseDef.roleCategory,
|
||
knowledgeMixOverride: hasKmOverride ? kmOverride : null,
|
||
effectiveKnowledgeMixWeight: hasKmOverride ? kmOverride : globalWeight,
|
||
// 파이프라인 사용 현황. 비어 있으면 삭제 가능, 한 개라도 있으면 UI에서
|
||
// 삭제 버튼을 disabled로 처리하고 tooltip에 사용 중인 파이프라인을 노출.
|
||
usedInPipelines: pipelineUsage[id] ? [...new Set(pipelineUsage[id])] : [],
|
||
};
|
||
};
|
||
const agents = orderedIds.map(renderEntry).filter((x): x is NonNullable<ReturnType<typeof renderEntry>> => !!x);
|
||
// 숨김 처리된 빌트인 — 사용자에게 "복원" 옵션을 제공하기 위해 한 줄 메타로 같이 보낸다.
|
||
const hiddenBuiltins = (state.hiddenBuiltinIds ?? [])
|
||
.map((id) => {
|
||
const def = COMPANY_AGENTS[id];
|
||
if (!def) return null;
|
||
return { id, name: def.name, role: def.role, emoji: def.emoji };
|
||
})
|
||
.filter((x): x is { id: string; name: string; role: string; emoji: string } => !!x);
|
||
this._view.webview.postMessage({
|
||
type: 'companyAgents',
|
||
value: {
|
||
companyName: state.companyName,
|
||
globalKnowledgeMixWeight: globalWeight,
|
||
agents,
|
||
hiddenBuiltins,
|
||
// 직군 라벨 사전 + 표시 순서. 웹뷰는 enum 값을 모르므로
|
||
// 백엔드가 정한 라벨/순서를 같이 보내 UI 일관성을 유지.
|
||
roleCategoryLabels: ROLE_CATEGORY_LABELS,
|
||
roleCategoryOrder: ROLE_CATEGORY_ORDER,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Drive one full company turn. Caller is the chat handler; it's already
|
||
* persisted the user message and started a streaming bubble. We feed
|
||
* progress events back as `companyTurnUpdate` messages so the same bubble
|
||
* fills in as each agent finishes.
|
||
*/
|
||
async _runCompanyTurn(
|
||
userPrompt: string,
|
||
resumeTimestamp?: string,
|
||
pipelineIdOverride?: string,
|
||
requirementContract?: import('./features/company').RequirementContract,
|
||
): Promise<void> {
|
||
const cfg = getConfig();
|
||
const ai = new AIService();
|
||
// plan-ready / report-done 이벤트를 가로채 직전 turn 요약을 캐시에
|
||
// 저장. 다음 메시지의 intent classifier가 "이건 followup인가?" 판정에
|
||
// 사용한다. plan-ready로 brief를, report-done으로 보고서 끝부분을
|
||
// 잡아낸다 — turn이 중간 abort되면 plan만 남고 reportTail은 비어
|
||
// 있게 되는데, 그 상태로도 followup 매칭에는 충분히 도움된다.
|
||
let stagingBrief = '';
|
||
const emit = (event: CompanyTurnEvent) => {
|
||
this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event });
|
||
if (event.phase === 'plan-ready') {
|
||
stagingBrief = event.plan?.brief || '';
|
||
} else if (event.phase === 'report-done') {
|
||
const tail = (event.report || '').trim().slice(-600);
|
||
this._lastCompanyTurnSummary = {
|
||
brief: stagingBrief,
|
||
reportTail: tail,
|
||
finishedAt: Date.now(),
|
||
};
|
||
}
|
||
// Pixel Office hub — 같은 이벤트를 *추가로* read-only 변환. dispatcher
|
||
// 흐름엔 영향 없음(post 호출만 함).
|
||
try { this.pixelOfficeOnTurnEvent(event); } catch { /* never break the turn */ }
|
||
};
|
||
// Fresh AbortController per turn — the Stop button routes through
|
||
// `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks
|
||
// `signal.aborted` between phases and short-circuits cleanly.
|
||
const abort = new AbortController();
|
||
this._companyAbort = abort;
|
||
try {
|
||
const deps: DispatcherDeps = {
|
||
context: this._context,
|
||
ai,
|
||
defaultModel: cfg.defaultModel || 'gemma4:e2b',
|
||
// Knowledge Mix wiring so company specialists *also* see the
|
||
// user's Second Brain — same global default + per-agent
|
||
// override semantics the chat path uses. Without this the
|
||
// Knowledge Mix slider had no effect on company turns.
|
||
globalKnowledgeMixWeight: cfg.knowledgeMixSecondBrainWeight ?? 50,
|
||
brainFileBaseline: cfg.memoryLongTermFiles ?? 6,
|
||
// Hand the dispatcher a thunk into ConnectAI's action-tag
|
||
// executor so specialist outputs like `<create_file>` actually
|
||
// hit disk. Without this, agents would *claim* to create
|
||
// files while nothing happened — the exact bug we just fixed.
|
||
executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
|
||
signal: abort.signal,
|
||
onEvent: emit,
|
||
// 승인 게이트 bridge — dispatcher가 호출하면 Promise를 만들어
|
||
// resolver를 _pendingApprovals에 보관 후 await. 사용자가 카드 버튼을
|
||
// 누르면 chatHandlers가 resolveApprovalGate(stageId, decision)을 호출
|
||
// 하고 그 resolve가 이 await을 풀어준다.
|
||
awaitApproval: ({ stageId }: { stageId: string; stageLabel: string }) =>
|
||
new Promise<ApprovalDecision>((resolve) => {
|
||
if (abort.signal.aborted) {
|
||
resolve({ kind: 'abort' });
|
||
return;
|
||
}
|
||
this._pendingApprovals.set(stageId, resolve);
|
||
}),
|
||
// 이번 turn 한정 pipeline override. chatHandlers가 의도 분류기
|
||
// 추천 또는 사용자 키워드 detection 결과를 채워서 넘긴다.
|
||
pipelineIdOverride,
|
||
// Intent Alignment에서 도출된 사용자 합의 contract. Phase D에서
|
||
// planner / specialist / reviewer prompt 모두에 주입됨. 없으면
|
||
// legacy 동작 (alignment 단계 자체를 거치지 않은 경우 또는 사용자가
|
||
// off 모드로 설정한 경우).
|
||
requirementContract,
|
||
};
|
||
// 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를
|
||
// 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을
|
||
// 돌려주면(파일 없음·이미 완료 등) 사용자에게 알리고 종료.
|
||
if (resumeTimestamp) {
|
||
const result = await resumeCompanyTurn(resumeTimestamp, deps);
|
||
if (!result) {
|
||
this._view?.webview.postMessage({
|
||
type: 'error',
|
||
value: '재개 가능한 세션 정보를 찾지 못했습니다 (이미 완료되었거나 파일이 손상되었을 수 있습니다).',
|
||
});
|
||
}
|
||
} else {
|
||
await runCompanyTurn(userPrompt, deps);
|
||
}
|
||
} catch (e: any) {
|
||
logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) });
|
||
this._view?.webview.postMessage({
|
||
type: 'error',
|
||
value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`,
|
||
});
|
||
} finally {
|
||
if (this._companyAbort === abort) this._companyAbort = undefined;
|
||
// The webview's send button is locked into the "generating" state
|
||
// when the user submits; it only unlocks on `streamEnd`. The
|
||
// normal chat path posts that from inside AgentExecutor, but
|
||
// the company turn never touches AgentExecutor, so we have to
|
||
// post it ourselves here — otherwise the input stays disabled
|
||
// with the red Stop button after the round completes.
|
||
this._view?.webview.postMessage({ type: 'streamEnd' });
|
||
void this._sendReadyStatus();
|
||
// turn이 끝났으면(완료든 abort든) resume 가능 세션 목록을 새로 푸시 —
|
||
// 방금 abort된 세션이 곧장 목록에 떠야 하므로.
|
||
void this._sendCompanyResumable();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Webview에 "이어서 진행할 수 있는 세션" 목록을 push. 관리 패널이 열릴 때와 turn이
|
||
* 끝날 때마다 호출됨. 빈 목록도 그대로 보내서 UI가 섹션을 자동으로 숨길 수 있게 함.
|
||
*/
|
||
async _sendCompanyResumable(): Promise<void> {
|
||
if (!this._view) return;
|
||
try {
|
||
const items = listResumableSessions(this._context).map((s) => ({
|
||
timestamp: s.timestamp,
|
||
userPrompt: s.userPrompt.slice(0, 200),
|
||
pipelineId: s.pipelineId,
|
||
pipelineName: s.pipelineId
|
||
? (readCompanyState(this._context).pipelines?.[s.pipelineId]?.name ?? s.pipelineId)
|
||
: null,
|
||
completedCount: s.agentOutputs.length,
|
||
totalCount: s.plan.tasks.length,
|
||
status: s.status,
|
||
abortReason: s.abortReason ?? '',
|
||
lastUpdatedAt: s.lastUpdatedAt,
|
||
startedAt: s.startedAt,
|
||
}));
|
||
this._view.webview.postMessage({
|
||
type: 'companyResumable',
|
||
value: { items },
|
||
});
|
||
} catch (e: any) {
|
||
logError('company._sendCompanyResumable failed.', { error: e?.message ?? String(e) });
|
||
}
|
||
}
|
||
|
||
/** Open the architecture doc in editor group 2. */
|
||
async _openArchitectureDoc(): Promise<void> {
|
||
const p = this._getActiveChronicleProject();
|
||
if (!p || !p.architectureDocPath) return;
|
||
try {
|
||
const doc = await vscode.workspace.openTextDocument(p.architectureDocPath);
|
||
await vscode.window.showTextDocument(doc, {
|
||
viewColumn: vscode.ViewColumn.Two,
|
||
preview: false,
|
||
});
|
||
} catch (e: any) {
|
||
vscode.window.showErrorMessage(`Architecture doc 열기 실패: ${e?.message ?? e}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Register a debounced watcher over the project root. Only structural
|
||
* changes regen the doc — the signature hash decides whether to write.
|
||
* Files inside node_modules / out / dist are filtered by the glob to keep
|
||
* the noise floor sane during normal development.
|
||
*/
|
||
private _registerArchitectureWatcher(profile: ProjectProfile): void {
|
||
if (!profile.projectRoot) return;
|
||
if (this._archWatchedProjectId === profile.projectId && this._archWatcher) return;
|
||
this._disposeArchitectureWatcher();
|
||
if (profile.architectureAutoUpdate === false) {
|
||
this._archWatchedProjectId = profile.projectId;
|
||
return;
|
||
}
|
||
const pattern = new vscode.RelativePattern(
|
||
profile.projectRoot,
|
||
'{package.json,tsconfig.json,README.md,src/**,media/**,docs/**,tests/**,core_py/**,lib/**,app/**}'
|
||
);
|
||
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
|
||
const onChange = () => this._scheduleArchitectureRefresh();
|
||
watcher.onDidCreate(onChange);
|
||
watcher.onDidDelete(onChange);
|
||
watcher.onDidChange(onChange);
|
||
this._archWatcher = watcher;
|
||
this._archWatchedProjectId = profile.projectId;
|
||
logInfo('architecture: watcher registered.', { projectId: profile.projectId, root: profile.projectRoot });
|
||
}
|
||
|
||
private _disposeArchitectureWatcher(): void {
|
||
try { this._archWatcher?.dispose(); } catch { /* noop */ }
|
||
this._archWatcher = undefined;
|
||
if (this._archWatchDebounce) { clearTimeout(this._archWatchDebounce); this._archWatchDebounce = undefined; }
|
||
this._archWatchedProjectId = undefined;
|
||
}
|
||
|
||
private _scheduleArchitectureRefresh(): void {
|
||
if (this._archWatchDebounce) clearTimeout(this._archWatchDebounce);
|
||
// 6 s debounce: long enough that a "save file" burst settles into one
|
||
// regen, short enough that the chip's "updated 2m ago" badge stays
|
||
// believable.
|
||
this._archWatchDebounce = setTimeout(async () => {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile || !profile.projectRoot || profile.architectureAutoUpdate === false) return;
|
||
try {
|
||
// Cheap signature check first — most file events don't change shape.
|
||
const scan = scanProject(profile.projectRoot, profile.projectName);
|
||
if (scan.signature === profile.architectureLastScanSignature) return;
|
||
await this._refreshArchitecture();
|
||
} catch (e: any) {
|
||
logError('architecture: auto-refresh failed.', { error: e?.message ?? String(e) });
|
||
}
|
||
}, 6000);
|
||
}
|
||
|
||
_getActiveChronicleProject(): ProjectProfile | null {
|
||
const projects = this._getChronicleProjects();
|
||
if (projects.length === 0) return null;
|
||
const activeId = this._context.globalState.get<string>(SidebarChatProvider.activeChronicleProjectStateKey, '');
|
||
return projects.find(project => project.projectId === activeId) || projects[0];
|
||
}
|
||
|
||
async _sendChronicleProjects() {
|
||
if (!this._view) return;
|
||
const projects = this._getChronicleProjects();
|
||
const active = this._getActiveChronicleProject();
|
||
this._view.webview.postMessage({
|
||
type: 'chronicleProjects',
|
||
value: {
|
||
activeProjectId: active?.projectId || '',
|
||
projects: projects.map(project => ({
|
||
id: project.projectId,
|
||
name: project.projectName,
|
||
root: project.projectRoot || '',
|
||
recordRoot: project.recordRoot,
|
||
description: project.description || ''
|
||
}))
|
||
}
|
||
});
|
||
}
|
||
|
||
async _createChronicleProject() {
|
||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||
const defaultName = workspaceRoot ? path.basename(workspaceRoot) : 'New Project';
|
||
|
||
const projectName = await vscode.window.showInputBox({
|
||
prompt: 'Project name for Chronicle records',
|
||
value: defaultName,
|
||
validateInput: (value) => value.trim() ? null : 'Project name is required.'
|
||
});
|
||
if (!projectName) return;
|
||
|
||
const description = await vscode.window.showInputBox({
|
||
prompt: 'One-line project description',
|
||
value: 'Project planning, decisions, development logs, and bug records.'
|
||
});
|
||
if (description === undefined) return;
|
||
|
||
const projectRoot = await vscode.window.showInputBox({
|
||
prompt: 'Project root path',
|
||
value: workspaceRoot,
|
||
validateInput: (value) => value.trim() ? null : 'Project root is required.'
|
||
});
|
||
if (!projectRoot) return;
|
||
|
||
const defaultRecordRoot = path.join(projectRoot.trim(), 'docs', 'records', projectName.trim());
|
||
const recordRoot = await vscode.window.showInputBox({
|
||
prompt: 'Markdown record folder path',
|
||
value: defaultRecordRoot,
|
||
validateInput: (value) => value.trim() ? null : 'Record folder path is required.'
|
||
});
|
||
if (!recordRoot) return;
|
||
|
||
const corePurpose = await vscode.window.showInputBox({
|
||
prompt: 'Core project purpose or guardrail',
|
||
value: 'Keep project knowledge traceable through Markdown records.'
|
||
});
|
||
if (corePurpose === undefined) return;
|
||
|
||
const detailChoice = await vscode.window.showQuickPick([
|
||
{ label: 'standard', description: 'Request, question intent, decisions, planning, development, and bugs' },
|
||
{ label: 'simple', description: 'Request summary, decisions, and implementation result' },
|
||
{ label: 'detailed', description: 'Standard records plus alternatives, lessons, and retrospectives' }
|
||
], {
|
||
placeHolder: 'Chronicle record detail level'
|
||
});
|
||
if (!detailChoice) return;
|
||
|
||
const now = new Date().toISOString();
|
||
const projects = this._getChronicleProjects();
|
||
const idBase = this._slugify(projectName.trim());
|
||
let projectId = idBase;
|
||
let suffix = 2;
|
||
while (projects.some(project => project.projectId === projectId)) {
|
||
projectId = `${idBase}-${suffix++}`;
|
||
}
|
||
|
||
const profile: ProjectProfile = {
|
||
projectId,
|
||
projectName: projectName.trim(),
|
||
projectRoot: projectRoot.trim(),
|
||
recordRoot: recordRoot.trim(),
|
||
description: description.trim(),
|
||
corePurpose: corePurpose.trim(),
|
||
targetUsers: ['Project developer'],
|
||
avoidDirections: ['Do not mix records across projects.', 'Do not tightly couple records to core agent execution.'],
|
||
detailLevel: detailChoice.label as ProjectProfile['detailLevel'],
|
||
createdAt: now,
|
||
updatedAt: now
|
||
};
|
||
|
||
this._chronicle.ensureProject(profile);
|
||
const nextProjects = [...projects.filter(project => project.projectId !== profile.projectId), profile];
|
||
await this._putChronicleProjects(nextProjects);
|
||
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, profile.projectId);
|
||
await this._sendChronicleProjects();
|
||
this.injectSystemMessage(`**[Designer Project Created]** ${profile.projectName}\n\`${profile.recordRoot}\``);
|
||
}
|
||
|
||
async _setActiveChronicleProject(projectId: string) {
|
||
if (!projectId || projectId === 'new') {
|
||
await this._createChronicleProject();
|
||
return;
|
||
}
|
||
|
||
const target = this._getChronicleProjects().find(project => project.projectId === projectId);
|
||
if (!target) return;
|
||
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, target.projectId);
|
||
await this._sendChronicleProjects();
|
||
await this._sendChronicleRecords();
|
||
this.injectSystemMessage(`**[Designer Project Selected]** ${target.projectName}\n\`${target.recordRoot}\``);
|
||
}
|
||
|
||
async _openChronicleFolder() {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile) {
|
||
vscode.window.showWarningMessage('No Chronicle project is selected.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this._chronicle.ensureProject(profile);
|
||
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(profile.recordRoot));
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to open Chronicle folder: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _sendChronicleRecords() {
|
||
if (!this._view) return;
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile) {
|
||
this._view.webview.postMessage({ type: 'chronicleRecords', value: [] });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const records = this._chronicle.listRecords(profile).map(record => ({
|
||
section: record.section,
|
||
fileName: record.fileName,
|
||
path: record.filePath,
|
||
relativePath: record.relativePath,
|
||
updatedAt: record.updatedAt
|
||
}));
|
||
this._view.webview.postMessage({ type: 'chronicleRecords', value: records });
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to list Chronicle records: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _openChronicleRecord(recordPath: string) {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile || !recordPath) {
|
||
vscode.window.showWarningMessage('Select a Chronicle record first.');
|
||
return;
|
||
}
|
||
|
||
const root = path.resolve(profile.recordRoot);
|
||
const target = path.resolve(recordPath);
|
||
if (!target.startsWith(`${root}${path.sep}`) || path.extname(target) !== '.md') {
|
||
vscode.window.showErrorMessage('Selected Chronicle record path is not valid.');
|
||
return;
|
||
}
|
||
|
||
if (!fs.existsSync(target)) {
|
||
vscode.window.showErrorMessage('Selected Chronicle record no longer exists.');
|
||
await this._sendChronicleRecords();
|
||
return;
|
||
}
|
||
|
||
await openInEditorGroup(target);
|
||
}
|
||
|
||
async _writeChroniclePlanningFromCurrentChat() {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile) {
|
||
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
||
return;
|
||
}
|
||
|
||
const history = this._agent.getHistory();
|
||
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
|
||
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
|
||
const featureName = await vscode.window.showInputBox({
|
||
prompt: 'Feature name for the planning document',
|
||
value: this._summarizeForTitle(latestUser || 'Project Chronicle Feature')
|
||
});
|
||
if (!featureName) return;
|
||
|
||
try {
|
||
const createdAt = new Date().toISOString();
|
||
const result = this._chronicle.writePlanning(profile, {
|
||
featureName: featureName.trim(),
|
||
purpose: 'Record the reason, scope, direction, and success criteria before implementation.',
|
||
background: this._summarizeTextForWiki(latestAssistant || latestUser),
|
||
userIntent: this._summarizeTextForWiki(latestUser),
|
||
sourceRequest: latestUser || 'No user request captured in the current chat.',
|
||
scope: [
|
||
'Create a project-specific planning record.',
|
||
'Capture user intent and implementation direction.',
|
||
'Keep the record independent from chat execution internals.'
|
||
],
|
||
outOfScope: [
|
||
'Full automatic transcript capture.',
|
||
'External database integration.',
|
||
'Git automation.'
|
||
],
|
||
developmentDirection: 'Use Project Chronicle as a low-dependency Markdown record layer.',
|
||
dependencyStrategy: 'Use local filesystem writes through the independent projectChronicle module.',
|
||
expectedValue: 'Future work can understand why this feature exists and what decisions shaped it.',
|
||
successCriteria: [
|
||
'The planning document is created under the selected project record folder.',
|
||
'The document includes user intent, scope, out-of-scope items, and success criteria.'
|
||
],
|
||
developerInstruction: 'Use this document as the implementation guardrail for the next development step.',
|
||
createdAt
|
||
});
|
||
this._chronicle.appendTimeline(profile, [`Planning record created: ${result.relativePath}`], createdAt);
|
||
vscode.window.showInformationMessage(`Chronicle planning saved: ${result.relativePath}`);
|
||
await this._sendChronicleRecords();
|
||
this.injectSystemMessage(`**[Chronicle Planning Saved]** \`${result.filePath}\``);
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to write planning record: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _writeChronicleDiscussionFromCurrentChat() {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile) {
|
||
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
||
return;
|
||
}
|
||
|
||
const history = this._agent.getHistory();
|
||
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
|
||
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
|
||
const title = await vscode.window.showInputBox({
|
||
prompt: 'Discussion title',
|
||
value: this._summarizeForTitle(latestUser || 'Project Discussion')
|
||
});
|
||
if (!title) return;
|
||
|
||
const question = await vscode.window.showInputBox({
|
||
prompt: 'AI question to record (optional)',
|
||
value: ''
|
||
});
|
||
if (question === undefined) return;
|
||
|
||
let questions: any[] = [];
|
||
if (question.trim()) {
|
||
const reason = await vscode.window.showInputBox({
|
||
prompt: 'Why was this question asked?',
|
||
value: 'To avoid writing records to the wrong project or making an unclear design decision.'
|
||
});
|
||
if (reason === undefined) return;
|
||
|
||
const impact = await vscode.window.showInputBox({
|
||
prompt: 'How does this question affect the decision?',
|
||
value: 'It determines the correct project context, scope, or implementation path.'
|
||
});
|
||
if (impact === undefined) return;
|
||
|
||
questions = [{
|
||
question: question.trim(),
|
||
reason: reason.trim(),
|
||
expectedInformation: 'Information needed to clarify project context, scope, or decision direction.',
|
||
impactOnDecision: impact.trim()
|
||
}];
|
||
}
|
||
|
||
try {
|
||
const createdAt = new Date().toISOString();
|
||
const result = this._chronicle.writeDiscussion(profile, {
|
||
title: title.trim(),
|
||
userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
|
||
interpretedIntent: 'Capture the discussion as a reusable project knowledge record instead of leaving it only in chat history.',
|
||
questions,
|
||
discussions: [
|
||
this._summarizeTextForWiki(latestAssistant || latestUser || 'No discussion detail captured yet.')
|
||
],
|
||
decisions: [],
|
||
createdAt
|
||
});
|
||
this._chronicle.appendTimeline(profile, [`Discussion record created: ${result.relativePath}`], createdAt);
|
||
vscode.window.showInformationMessage(`Chronicle discussion saved: ${result.relativePath}`);
|
||
await this._sendChronicleRecords();
|
||
this.injectSystemMessage(`**[Chronicle Discussion Saved]** \`${result.filePath}\``);
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to write discussion record: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _writeChronicleDecisionFromInput() {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile) {
|
||
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
||
return;
|
||
}
|
||
|
||
const title = await vscode.window.showInputBox({
|
||
prompt: 'Decision title',
|
||
value: 'Use independent Markdown record module'
|
||
});
|
||
if (!title) return;
|
||
|
||
const decision = await vscode.window.showInputBox({
|
||
prompt: 'Decision',
|
||
value: 'Implement this behavior as an independent Project Chronicle module.'
|
||
});
|
||
if (decision === undefined) return;
|
||
|
||
const reason = await vscode.window.showInputBox({
|
||
prompt: 'Decision reason',
|
||
value: 'To reduce coupling and keep project records portable.'
|
||
});
|
||
if (reason === undefined) return;
|
||
|
||
const alternatives = await vscode.window.showInputBox({
|
||
prompt: 'Rejected alternatives (comma-separated)',
|
||
value: 'Integrate with Second Brain, integrate directly into Agent execution'
|
||
});
|
||
if (alternatives === undefined) return;
|
||
|
||
try {
|
||
const createdAt = new Date().toISOString();
|
||
const adrNumber = this._chronicle.nextAdrNumber(profile);
|
||
const result = this._chronicle.writeDecision(profile, {
|
||
title: title.trim(),
|
||
status: 'accepted',
|
||
context: 'A project record needs to capture not only what changed, but why the direction was chosen.',
|
||
decision: decision.trim(),
|
||
reason: reason.trim(),
|
||
alternatives: alternatives.split(',').map(item => item.trim()).filter(Boolean),
|
||
consequences: [
|
||
'Records can evolve independently from chat and agent internals.',
|
||
'Future automation can emit chronicle events without owning the core execution path.'
|
||
],
|
||
createdAt
|
||
}, adrNumber);
|
||
this._chronicle.appendTimeline(profile, [`Decision record created: ${result.relativePath}`], createdAt);
|
||
vscode.window.showInformationMessage(`Chronicle decision saved: ${result.relativePath}`);
|
||
await this._sendChronicleRecords();
|
||
this.injectSystemMessage(`**[Chronicle Decision Saved]** \`${result.filePath}\``);
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to write decision record: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _writeChronicleDevelopmentFromCurrentChat() {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile) {
|
||
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
||
return;
|
||
}
|
||
|
||
const history = this._agent.getHistory();
|
||
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
|
||
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
|
||
const featureName = await vscode.window.showInputBox({
|
||
prompt: 'Feature name for the development log',
|
||
value: this._summarizeForTitle(latestUser || 'Implementation Log')
|
||
});
|
||
if (!featureName) return;
|
||
|
||
try {
|
||
const createdAt = new Date().toISOString();
|
||
const result = this._chronicle.writeDevelopmentLog(profile, {
|
||
featureName: featureName.trim(),
|
||
purpose: 'Record the actual implementation outcome for later maintenance.',
|
||
implementationSummary: this._summarizeTextForWiki(latestAssistant || 'Implementation summary was not captured from chat yet.'),
|
||
architecture: 'Project Chronicle records are written through an independent Markdown module.',
|
||
changedFiles: ['Capture exact changed files after verification.'],
|
||
dependencyNotes: 'Keep dependencies limited to TypeScript, Node fs/path, and VS Code extension APIs.',
|
||
bugs: [],
|
||
lessons: [
|
||
'Write implementation notes as soon as a stable development step finishes.',
|
||
'Keep generated records project-specific.'
|
||
],
|
||
createdAt
|
||
});
|
||
this._chronicle.appendTimeline(profile, [`Development log created: ${result.relativePath}`], createdAt);
|
||
vscode.window.showInformationMessage(`Chronicle development log saved: ${result.relativePath}`);
|
||
await this._sendChronicleRecords();
|
||
this.injectSystemMessage(`**[Chronicle Development Saved]** \`${result.filePath}\``);
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to write development record: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _writeChronicleBugFromInput() {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile) {
|
||
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
||
return;
|
||
}
|
||
|
||
const title = await vscode.window.showInputBox({
|
||
prompt: 'Bug title',
|
||
value: 'record-generation-issue'
|
||
});
|
||
if (!title) return;
|
||
|
||
const symptom = await vscode.window.showInputBox({
|
||
prompt: 'Bug symptom',
|
||
value: 'Describe what failed or looked wrong.'
|
||
});
|
||
if (symptom === undefined) return;
|
||
|
||
const cause = await vscode.window.showInputBox({
|
||
prompt: 'Bug cause',
|
||
value: 'Cause is not confirmed yet.'
|
||
});
|
||
if (cause === undefined) return;
|
||
|
||
const fix = await vscode.window.showInputBox({
|
||
prompt: 'Fix',
|
||
value: 'Describe the fix or mitigation.'
|
||
});
|
||
if (fix === undefined) return;
|
||
|
||
try {
|
||
const createdAt = new Date().toISOString();
|
||
const bugNumber = this._chronicle.nextBugNumber(profile);
|
||
const result = this._chronicle.writeBug(profile, {
|
||
title: title.trim(),
|
||
symptom: symptom.trim(),
|
||
cause: cause.trim(),
|
||
fix: fix.trim(),
|
||
prevention: 'Validate project selection, record path, and write permissions before generating files.',
|
||
createdAt
|
||
}, bugNumber);
|
||
this._chronicle.appendTimeline(profile, [`Bug record created: ${result.relativePath}`], createdAt);
|
||
vscode.window.showInformationMessage(`Chronicle bug record saved: ${result.relativePath}`);
|
||
await this._sendChronicleRecords();
|
||
this.injectSystemMessage(`**[Chronicle Bug Saved]** \`${result.filePath}\``);
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to write bug record: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _writeChronicleRetrospectiveFromInput() {
|
||
const profile = this._getActiveChronicleProject();
|
||
if (!profile) {
|
||
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
||
return;
|
||
}
|
||
|
||
const title = await vscode.window.showInputBox({
|
||
prompt: 'Retrospective title',
|
||
value: 'Project Chronicle Guard iteration'
|
||
});
|
||
if (!title) return;
|
||
|
||
const summary = await vscode.window.showInputBox({
|
||
prompt: 'Work summary',
|
||
value: 'Completed an incremental development step and recorded the outcome.'
|
||
});
|
||
if (summary === undefined) return;
|
||
|
||
const wentWell = await vscode.window.showInputBox({
|
||
prompt: 'What went well? (comma-separated)',
|
||
value: 'Kept the feature independent, Generated Markdown records, Preserved project context'
|
||
});
|
||
if (wentWell === undefined) return;
|
||
|
||
const toImprove = await vscode.window.showInputBox({
|
||
prompt: 'What should improve? (comma-separated)',
|
||
value: 'More automatic question intent capture, Richer record editing UI'
|
||
});
|
||
if (toImprove === undefined) return;
|
||
|
||
const nextActions = await vscode.window.showInputBox({
|
||
prompt: 'Next actions (comma-separated)',
|
||
value: 'Add tests, Improve Designer UI, Add event-based record capture'
|
||
});
|
||
if (nextActions === undefined) return;
|
||
|
||
try {
|
||
const createdAt = new Date().toISOString();
|
||
const result = this._chronicle.writeRetrospective(profile, {
|
||
title: title.trim(),
|
||
summary: summary.trim(),
|
||
wentWell: wentWell.split(',').map(item => item.trim()).filter(Boolean),
|
||
toImprove: toImprove.split(',').map(item => item.trim()).filter(Boolean),
|
||
nextActions: nextActions.split(',').map(item => item.trim()).filter(Boolean),
|
||
createdAt
|
||
});
|
||
this._chronicle.appendTimeline(profile, [`Retrospective created: ${result.relativePath}`], createdAt);
|
||
vscode.window.showInformationMessage(`Chronicle retrospective saved: ${result.relativePath}`);
|
||
await this._sendChronicleRecords();
|
||
this.injectSystemMessage(`**[Chronicle Retrospective Saved]** \`${result.filePath}\``);
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to write retrospective record: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _autoWriteChronicleAfterPrompt() {
|
||
const history = this._agent.getHistory();
|
||
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
|
||
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
|
||
const profile = this._getChronicleProjectForConversation(latestUser) || this._getActiveChronicleProject();
|
||
if (!profile) return;
|
||
|
||
const recordType = this._inferAutoChronicleRecordType(latestUser, latestAssistant);
|
||
if (!recordType) return;
|
||
|
||
const signature = [
|
||
profile.projectId,
|
||
recordType,
|
||
this._summarizeTextForWiki(latestUser).slice(0, 240),
|
||
this._summarizeTextForWiki(latestAssistant).slice(0, 240)
|
||
].join('|');
|
||
const lastSignature = this._context.globalState.get<string>(SidebarChatProvider.lastAutoChronicleSignatureStateKey, '');
|
||
if (signature === lastSignature) return;
|
||
|
||
try {
|
||
this._chronicle.ensureProject(profile);
|
||
const createdAt = new Date().toISOString();
|
||
const title = this._summarizeForTitle(latestUser || latestAssistant || 'Project Chronicle Auto Record');
|
||
const summary = this._summarizeTextForWiki(latestAssistant || latestUser);
|
||
let result;
|
||
|
||
if (recordType === 'bug') {
|
||
const bugNumber = this._chronicle.nextBugNumber(profile);
|
||
result = this._chronicle.writeBug(profile, {
|
||
title,
|
||
symptom: this._summarizeTextForWiki(latestUser || 'Issue was detected during the conversation.'),
|
||
cause: 'Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.',
|
||
fix: summary,
|
||
prevention: 'Keep automatic records tied to the active project and verify the relevant test or reproduction path.',
|
||
createdAt
|
||
}, bugNumber);
|
||
} else if (recordType === 'planning') {
|
||
result = this._chronicle.writePlanning(profile, {
|
||
featureName: title,
|
||
purpose: 'Capture the current planning or architecture direction before implementation continues.',
|
||
background: summary,
|
||
userIntent: this._summarizeTextForWiki(latestUser),
|
||
sourceRequest: latestUser || 'No user request captured in the current chat.',
|
||
scope: ['Continue from the active project conversation.', 'Use the selected project record folder automatically.'],
|
||
outOfScope: ['Manual record type selection.', 'Blocking the user with record-writing prompts.'],
|
||
developmentDirection: summary,
|
||
dependencyStrategy: 'Prefer existing project modules and local Markdown records.',
|
||
expectedValue: 'Future work can resume with the latest project intent and reasoning preserved.',
|
||
successCriteria: ['The record is saved automatically after a meaningful project turn.', 'The record stays under the active project.'],
|
||
developerInstruction: 'Use this record as lightweight context for the next development or review pass.',
|
||
createdAt
|
||
});
|
||
} else if (recordType === 'decision') {
|
||
const adrNumber = this._chronicle.nextAdrNumber(profile);
|
||
result = this._chronicle.writeDecision(profile, {
|
||
title,
|
||
status: 'accepted',
|
||
context: this._summarizeTextForWiki(latestUser),
|
||
decision: summary,
|
||
reason: 'Captured automatically because the conversation contained decision-oriented language.',
|
||
alternatives: [],
|
||
consequences: ['Future prompts should treat this as project context unless the user changes direction.'],
|
||
createdAt
|
||
}, adrNumber);
|
||
} else if (recordType === 'development') {
|
||
result = this._chronicle.writeDevelopmentLog(profile, {
|
||
featureName: title,
|
||
purpose: 'Record the implementation or verification outcome from the current conversation.',
|
||
implementationSummary: summary,
|
||
architecture: 'Captured automatically from the assistant response and active project context.',
|
||
changedFiles: this._extractChangedFilesFromText(latestAssistant),
|
||
dependencyNotes: 'No new dependency note was captured automatically.',
|
||
bugs: [],
|
||
lessons: ['Automatic project records should be generated in the background when the turn contains durable project knowledge.'],
|
||
createdAt
|
||
});
|
||
} else {
|
||
result = this._chronicle.writeDiscussion(profile, {
|
||
title,
|
||
userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
|
||
interpretedIntent: 'Capture a meaningful project discussion automatically instead of requiring manual record selection.',
|
||
questions: [],
|
||
discussions: [summary],
|
||
decisions: [],
|
||
createdAt
|
||
});
|
||
}
|
||
|
||
this._chronicle.appendTimeline(profile, [`Auto ${recordType} record created: ${result.relativePath}`], createdAt);
|
||
await this._context.globalState.update(SidebarChatProvider.lastAutoChronicleSignatureStateKey, signature);
|
||
await this._sendChronicleRecords();
|
||
vscode.window.setStatusBarMessage(`Astra: Chronicle auto-saved ${recordType}`, 3500);
|
||
} catch (err: any) {
|
||
logError('Automatic Chronicle record write failed.', { error: err?.message || String(err), recordType });
|
||
}
|
||
}
|
||
|
||
_getChronicleProjectForConversation(text: string): ProjectProfile | null {
|
||
const projectPath = this._extractLocalProjectPath(text);
|
||
if (!projectPath) return null;
|
||
|
||
const projects = this._getChronicleProjects();
|
||
const resolvedPath = path.resolve(projectPath);
|
||
const existing = projects.find(project => {
|
||
const root = project.projectRoot ? path.resolve(project.projectRoot) : '';
|
||
const recordRoot = path.resolve(project.recordRoot);
|
||
return root === resolvedPath || recordRoot.startsWith(`${resolvedPath}${path.sep}`);
|
||
});
|
||
if (existing) return existing;
|
||
|
||
const projectName = path.basename(resolvedPath) || 'Current Project';
|
||
const now = new Date().toISOString();
|
||
return {
|
||
projectId: this._slugify(projectName),
|
||
projectName,
|
||
projectRoot: resolvedPath,
|
||
recordRoot: path.join(resolvedPath, 'docs', 'records', projectName),
|
||
description: 'Auto-detected from the local project path in the conversation.',
|
||
corePurpose: 'Capture project direction, architecture discussion, decisions, and development notes as Markdown.',
|
||
targetUsers: ['Project developer'],
|
||
avoidDirections: ['Do not mix records across projects.'],
|
||
detailLevel: 'standard',
|
||
createdAt: now,
|
||
updatedAt: now
|
||
};
|
||
}
|
||
|
||
_extractLocalProjectPath(text: string): string | null {
|
||
const match = text.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i);
|
||
if (!match) return null;
|
||
const candidate = match[0].replace(/[.,;:)\]]+$/, '');
|
||
try {
|
||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
||
return candidate;
|
||
}
|
||
} catch {
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
_inferAutoChronicleRecordType(userText: string, assistantText: string): 'planning' | 'discussion' | 'decision' | 'development' | 'bug' | null {
|
||
const combined = `${userText}\n${assistantText}`;
|
||
if (!combined.trim()) return null;
|
||
if (/(기록하지마|저장하지마|no\s+record|do\s+not\s+record)/i.test(combined)) return null;
|
||
if (!/(프로젝트|코드|아키텍처|설계|개선|수정|구현|테스트|검증|이슈|문제|버그|오류|끊김|결정|방향|기록|지식|review|architecture|implement|fix|bug|issue|test|decision)/i.test(combined)) {
|
||
return null;
|
||
}
|
||
if (/(버그|오류|에러|이슈|문제|끊김|안\s*됨|실패|bug|error|issue|failed|failure)/i.test(userText)) {
|
||
return 'bug';
|
||
}
|
||
if (/(수정 완료|개선 완료|구현 완료|패치|테스트.*통과|검증.*완료|변경.*파일|compile|jest|tsc|passed|implemented|fixed)/i.test(assistantText)) {
|
||
return 'development';
|
||
}
|
||
if (/(결정|확정|채택|방향은|하기로|하지 않기로|decision|decide|accepted)/i.test(combined)) {
|
||
return 'decision';
|
||
}
|
||
if (/(계획|설계|아키텍처|조사|방향|로드맵|mvp|planning|architecture|roadmap|design)/i.test(userText)) {
|
||
return 'planning';
|
||
}
|
||
if (/(개선|수정|구현|테스트|검증|패킹|compile|jest|tsc|implement|fix|test|verify)/i.test(combined)) {
|
||
return 'development';
|
||
}
|
||
return 'discussion';
|
||
}
|
||
|
||
_extractChangedFilesFromText(text: string): string[] {
|
||
const files = new Set<string>();
|
||
for (const match of text.matchAll(/`([^`\n]+\.(?:ts|tsx|js|jsx|json|md|css|html|py|yml|yaml))`/gi)) {
|
||
files.add(match[1].trim());
|
||
}
|
||
return files.size > 0 ? Array.from(files).slice(0, 12) : ['No explicit changed file list was captured automatically.'];
|
||
}
|
||
|
||
async _writeChronicleRecord(recordType: string) {
|
||
switch (recordType) {
|
||
case 'planning':
|
||
await this._writeChroniclePlanningFromCurrentChat();
|
||
break;
|
||
case 'discussion':
|
||
await this._writeChronicleDiscussionFromCurrentChat();
|
||
break;
|
||
case 'decision':
|
||
await this._writeChronicleDecisionFromInput();
|
||
break;
|
||
case 'development':
|
||
await this._writeChronicleDevelopmentFromCurrentChat();
|
||
break;
|
||
case 'bug':
|
||
await this._writeChronicleBugFromInput();
|
||
break;
|
||
case 'retrospective':
|
||
await this._writeChronicleRetrospectiveFromInput();
|
||
break;
|
||
default:
|
||
vscode.window.showWarningMessage('Select a Chronicle record type first.');
|
||
}
|
||
}
|
||
|
||
_getAgentsDir(): string {
|
||
// 1) Explicit config override (works on any OS — useful on Windows or for skills outside the workspace).
|
||
const configured = (vscode.workspace.getConfiguration('g1nation').get<string>('agentSkillsPath', '') || '').trim();
|
||
const expanded = configured.startsWith('~/') || configured === '~'
|
||
? path.join(os.homedir(), configured.slice(1).replace(/^[\\/]/, ''))
|
||
: configured;
|
||
if (expanded && path.isAbsolute(expanded)) {
|
||
if (!fs.existsSync(expanded)) {
|
||
try { fs.mkdirSync(expanded, { recursive: true }); } catch { /* fall through to workspace */ }
|
||
}
|
||
if (fs.existsSync(expanded)) return expanded;
|
||
}
|
||
// 2) Default: <workspace>/.agent/skills
|
||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||
if (workspaceFolders) {
|
||
const localPath = path.join(workspaceFolders[0].uri.fsPath, '.agent', 'skills');
|
||
if (!fs.existsSync(localPath)) {
|
||
fs.mkdirSync(localPath, { recursive: true });
|
||
}
|
||
return localPath;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
async _sendAgentsList() {
|
||
if (!this._view) return;
|
||
const dir = this._getAgentsDir();
|
||
const agents = [];
|
||
if (dir && fs.existsSync(dir)) {
|
||
const files = fs.readdirSync(dir);
|
||
for (const f of files) {
|
||
if (f.endsWith('.md')) {
|
||
agents.push({ name: f.replace('.md', ''), path: path.join(dir, f) });
|
||
}
|
||
}
|
||
}
|
||
const lastPath = this._context.globalState.get<string>(SidebarChatProvider.lastAgentStateKey, 'none');
|
||
this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath });
|
||
void this._sendReadyStatus();
|
||
}
|
||
|
||
async _handleProactiveSuggestion(context: string) {
|
||
if (!this._view) return;
|
||
|
||
let suggestion = '';
|
||
switch (context) {
|
||
case 'settings_exploration':
|
||
suggestion = '💡 **Tip:** 모델 설정을 최적화하여 답변 속도를 2배 이상 높일 수 있습니다. 설정에서 `Max Context Size`를 조정해보세요!';
|
||
break;
|
||
case 'brain_sync_exploration':
|
||
suggestion = '🧠 **Knowledge Sync:** 최근에 수정한 파일이 지식 베이스에 반영되지 않았나요? 지금 동기화 버튼을 눌러 최신 정보를 업데이트하세요.';
|
||
break;
|
||
case 'agent_selection_exploration':
|
||
suggestion = '🤖 **Agent Skills:** 특정 언어나 프레임워크에 특화된 에이전트 스킬을 선택하면 더 정확한 코드를 생성할 수 있습니다.';
|
||
break;
|
||
default:
|
||
suggestion = '💡 새로운 기능을 발견하셨나요? 궁금한 점이 있다면 언제든 물어보세요!';
|
||
}
|
||
|
||
this._view.webview.postMessage({ type: 'streamStart' });
|
||
this._view.webview.postMessage({ type: 'streamChunk', value: `\n\n> [!TIP]\n> ${suggestion}\n` });
|
||
this._view.webview.postMessage({ type: 'streamEnd' });
|
||
}
|
||
|
||
async _createAgent() {
|
||
const name = await vscode.window.showInputBox({
|
||
prompt: 'Name of the new Agent (e.g., frontend_expert)',
|
||
placeHolder: 'Agent name...'
|
||
});
|
||
if (!name) return;
|
||
|
||
const safeName = name.trim().replace(/[^a-zA-Z0-9_\-\u3131-\uD79D가-힣]/g, '_');
|
||
if (!safeName) return;
|
||
|
||
const dir = this._getAgentsDir();
|
||
if (!dir) {
|
||
vscode.window.showErrorMessage('Agent directory could not be determined.');
|
||
return;
|
||
}
|
||
|
||
const filePath = path.join(dir, `${safeName}.md`);
|
||
if (!fs.existsSync(filePath)) {
|
||
fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\n\nAdd your instructions here...\n`, 'utf8');
|
||
}
|
||
|
||
await openInEditorGroup(filePath);
|
||
await this._sendAgentsList();
|
||
}
|
||
|
||
async _sendAgentContent(agentPath: string) {
|
||
if (!this._view || !agentPath || agentPath === 'none') return;
|
||
if (fs.existsSync(agentPath)) {
|
||
const content = fs.readFileSync(agentPath, 'utf8');
|
||
const negativePrompt = this._context.globalState.get<string>(`negativePrompt:${agentPath}`, '');
|
||
this._view.webview.postMessage({
|
||
type: 'agentContent',
|
||
value: content,
|
||
negativePrompt: negativePrompt
|
||
});
|
||
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, agentPath);
|
||
}
|
||
}
|
||
|
||
async _updateAgent(agentPath: string, content: string, negativePrompt?: string) {
|
||
if (!agentPath || agentPath === 'none') return;
|
||
try {
|
||
fs.writeFileSync(agentPath, content, 'utf8');
|
||
if (negativePrompt !== undefined) {
|
||
await this._context.globalState.update(`negativePrompt:${agentPath}`, negativePrompt);
|
||
}
|
||
vscode.window.showInformationMessage('Agent skill updated successfully.');
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to update agent: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _deleteAgent(agentPath: string) {
|
||
if (!agentPath || agentPath === 'none') return;
|
||
|
||
try {
|
||
const agentsDir = path.resolve(this._getAgentsDir());
|
||
const targetPath = path.resolve(agentPath);
|
||
if (!targetPath.startsWith(`${agentsDir}${path.sep}`) || path.extname(targetPath) !== '.md') {
|
||
vscode.window.showErrorMessage('Selected agent skill path is not valid.');
|
||
return;
|
||
}
|
||
|
||
if (!fs.existsSync(targetPath)) {
|
||
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none');
|
||
await this._sendAgentsList();
|
||
return;
|
||
}
|
||
|
||
const confirm = await vscode.window.showWarningMessage(
|
||
`Delete agent skill "${path.basename(targetPath, '.md')}"?`,
|
||
{ modal: true },
|
||
'Delete Skill'
|
||
);
|
||
if (confirm !== 'Delete Skill') return;
|
||
|
||
fs.unlinkSync(targetPath);
|
||
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none');
|
||
await this._sendAgentsList();
|
||
this._view?.webview.postMessage({ type: 'agentDeleted' });
|
||
vscode.window.showInformationMessage('Agent skill deleted successfully.');
|
||
} catch (err: any) {
|
||
vscode.window.showErrorMessage(`Failed to delete agent: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async _handlePrompt(data: any) {
|
||
if (!this._view) return;
|
||
|
||
const { value, model, internet, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data;
|
||
this._currentNegativePrompt = negativePrompt || '';
|
||
const selectedBrainId = typeof brainProfileId === 'string' && brainProfileId && brainProfileId !== 'new'
|
||
? brainProfileId
|
||
: getActiveBrainProfile().id;
|
||
this._currentSessionBrainId = selectedBrainId;
|
||
|
||
let agentSkillContext = undefined;
|
||
// Per-agent model override: if the active agent has a pinned model in the
|
||
// knowledge map, it wins over the model the webview just sent. Falls back
|
||
// to the incoming `model` (which is the global default the user picked).
|
||
let effectiveModel: string = typeof model === 'string' ? model : '';
|
||
if (agentFile && agentFile !== 'none' && fs.existsSync(agentFile)) {
|
||
const fileContent = fs.readFileSync(agentFile, 'utf8');
|
||
// Guard: a freshly-created agent still has only the placeholder template
|
||
// ("# Agent Persona: …\n\nAdd your instructions here…"). Treating that as a real
|
||
// agent prompt just confuses the model — fall back to normal mode and tell the user.
|
||
const body = fileContent.replace(/^?#\s*Agent\s*Persona\s*:.*$/im, '').trim();
|
||
const isPlaceholder = !body || /^add your instructions here/i.test(body);
|
||
if (isPlaceholder) {
|
||
logInfo('Selected agent has no real instructions — running without agent mode.', { agentFile });
|
||
this._view?.webview.postMessage({ type: 'lmStudioError', value: '선택한 에이전트에 내용이 없습니다 — 에이전트 프롬프트를 작성한 뒤 다시 시도하세요. (이번 응답은 에이전트 없이 처리합니다)' });
|
||
} else {
|
||
agentSkillContext = fileContent;
|
||
// Merge in any external skill .md files the user has mapped to this agent. We concatenate
|
||
// into the same agentSkillContext blob so the rest of the pipeline (agent.ts, agent-mode
|
||
// override) treats them identically to the agent's own .md — no further changes needed.
|
||
try {
|
||
const entry = getOrCreateAgentEntry(agentFile);
|
||
const bundle = loadExternalSkills(entry.skillFolders);
|
||
const block = formatSkillsAsPromptBlock(bundle);
|
||
if (block) {
|
||
agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`;
|
||
}
|
||
// Apply the per-agent model override, if any.
|
||
const pinned = entry.model?.trim();
|
||
if (pinned && pinned !== effectiveModel) {
|
||
logInfo('Per-agent model override applied.', {
|
||
agent: entry.name,
|
||
requested: effectiveModel,
|
||
pinned,
|
||
});
|
||
effectiveModel = pinned;
|
||
// Inform the webview so its UI can reflect the model that's actually in use.
|
||
this._view?.webview.postMessage({
|
||
type: 'agentModelOverride',
|
||
value: { agent: entry.name, model: pinned },
|
||
});
|
||
}
|
||
} catch (e: any) {
|
||
logError('External skill load failed.', { error: e?.message || String(e) });
|
||
}
|
||
}
|
||
}
|
||
|
||
const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;
|
||
|
||
// Project Architecture activation (Feature 2): if the user just said
|
||
// "나 X 프로젝트 진행할 거야" / mentioned an absolute path / etc., switch
|
||
// to that project's mode before assembling the prompt. Best-effort:
|
||
// failures here never block the actual answer.
|
||
if (typeof value === 'string' && value.trim().length > 0) {
|
||
try {
|
||
await this._tryActivateArchitectureFromText(value);
|
||
} catch (e: any) {
|
||
logError('architecture: intent detection failed.', { error: e?.message ?? String(e) });
|
||
}
|
||
}
|
||
// Re-resolve the active subproject from the currently-focused editor.
|
||
// Without this, switching between subprojects (e.g. ConnectAI →
|
||
// Datacollector) inside one VS Code window keeps loading the previous
|
||
// subproject's architecture into the prompt.
|
||
try {
|
||
await this._ensureActiveProjectForWorkspace();
|
||
} catch (e: any) {
|
||
logError('architecture: workspace resync failed.', { error: e?.message ?? String(e) });
|
||
}
|
||
const projectArchitectureContext = this._buildProjectArchitectureContext();
|
||
|
||
// [File Processing v2] 파일 타입별 분류 처리
|
||
let processedPrompt = value || '';
|
||
let imageFiles: any[] | undefined = undefined;
|
||
|
||
if (files && Array.isArray(files) && files.length > 0) {
|
||
const textContents: string[] = [];
|
||
const images: any[] = [];
|
||
|
||
for (const file of files) {
|
||
const name = file.name?.toLowerCase() || '';
|
||
const type = file.type || '';
|
||
|
||
if (name.endsWith('.pdf') || type === 'application/pdf') {
|
||
// PDF: 서버사이드 텍스트 추출 (pdf-parse v2 API) + Vision 폴백
|
||
let pdfTextOk = false;
|
||
try {
|
||
const { PDFParse } = require('pdf-parse');
|
||
const rawBuffer = Buffer.from(file.data, 'base64');
|
||
const uint8 = new Uint8Array(rawBuffer.buffer, rawBuffer.byteOffset, rawBuffer.byteLength);
|
||
const parser = new PDFParse(uint8);
|
||
await parser.load();
|
||
const textResult = await parser.getText();
|
||
const extracted = (typeof textResult === 'string' ? textResult : (textResult?.text || '')).trim();
|
||
const cleanText = extracted.replace(/\n*-- \d+ of \d+ --\n*/g, '\n').trim();
|
||
if (cleanText && cleanText.length > 30) {
|
||
textContents.push(`\n[PDF: ${file.name}]\n${cleanText}`);
|
||
logInfo(`PDF text extracted successfully.`, { fileName: file.name, chars: cleanText.length });
|
||
pdfTextOk = true;
|
||
}
|
||
|
||
// [Vision Fallback] 텍스트가 비어있으면 페이지 이미지 추출 -> Vision 모델에 전달
|
||
if (!pdfTextOk) {
|
||
logInfo(`PDF has no text layer. Extracting page screenshots for vision analysis.`, { fileName: file.name });
|
||
const screenshots = await parser.getScreenshot({ page: 1 });
|
||
if (screenshots?.pages && screenshots.pages.length > 0) {
|
||
const maxPages = Math.min(screenshots.pages.length, 8); // 메모리 보호: 최대 8페이지
|
||
for (let i = 0; i < maxPages; i++) {
|
||
const page = screenshots.pages[i];
|
||
if (page?.data) {
|
||
const pageBase64 = Buffer.from(page.data).toString('base64');
|
||
images.push({
|
||
name: `${file.name}_page${i + 1}.png`,
|
||
type: 'image/png',
|
||
data: pageBase64
|
||
});
|
||
}
|
||
}
|
||
textContents.push(`\n[PDF: ${file.name}]\n(이미지 기반 PDF ${screenshots.total}페이지 중 ${maxPages}페이지를 이미지로 추출하여 Vision 분석합니다. 각 페이지 이미지를 참조하여 문서의 내용을 상세히 분석하고 한국어로 정리하세요.)`);
|
||
logInfo(`PDF vision fallback: extracted ${maxPages} page screenshots.`, { fileName: file.name, totalPages: screenshots.total });
|
||
pdfTextOk = true; // Vision 분석으로 처리 완료
|
||
}
|
||
}
|
||
} catch (pdfError: any) {
|
||
logError(`PDF processing failed.`, { fileName: file.name, error: pdfError?.message || String(pdfError) });
|
||
}
|
||
|
||
// 최종 폴백: 텍스트도 없고 이미지 추출도 실패한 경우
|
||
if (!pdfTextOk) {
|
||
textContents.push(`\n[PDF: ${file.name}]\n(PDF 분석에 실패했습니다. 이 파일을 텍스트로 변환하여 다시 시도해주세요.)`);
|
||
}
|
||
} else if (
|
||
type.startsWith('text/') ||
|
||
type === 'application/json' ||
|
||
/\.(txt|md|csv|json|js|ts|jsx|tsx|html|css|py|java|rs|go|yaml|yml|xml|toml|sql|sh)$/i.test(name)
|
||
) {
|
||
// 텍스트 파일: base64 디코딩
|
||
try {
|
||
const decoded = Buffer.from(file.data, 'base64').toString('utf-8');
|
||
textContents.push(`\n[FILE: ${file.name}]\n\`\`\`\n${decoded}\n\`\`\``);
|
||
} catch (decodeError: any) {
|
||
logError(`Text file decode failed.`, { fileName: file.name, error: decodeError?.message });
|
||
textContents.push(`\n[FILE: ${file.name}]\n(디코딩 오류)`);
|
||
}
|
||
} else if (type.startsWith('image/')) {
|
||
// 이미지: 기존 vision 방식 유지
|
||
images.push(file);
|
||
} else {
|
||
// 미지원 타입: 파일명만 기록
|
||
textContents.push(`\n[ATTACHMENT: ${file.name}]\n(지원하지 않는 파일 형식: ${type || 'unknown'})`);
|
||
}
|
||
}
|
||
|
||
// 추출된 텍스트를 프롬프트에 주입
|
||
if (textContents.length > 0) {
|
||
processedPrompt = `${processedPrompt}\n\n--- 첨부 파일 내용 ---${textContents.join('\n')}`;
|
||
}
|
||
imageFiles = images.length > 0 ? images : undefined;
|
||
}
|
||
|
||
try {
|
||
await this._agent.handlePrompt(processedPrompt, effectiveModel || model, {
|
||
internetEnabled: internet,
|
||
visionContent: imageFiles,
|
||
agentSkillContext,
|
||
agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined,
|
||
negativePrompt,
|
||
designerContext,
|
||
projectArchitectureContext: projectArchitectureContext || undefined,
|
||
secondBrainTraceEnabled: secondBrainTrace !== false,
|
||
secondBrainTraceDebug: !!secondBrainTraceDebug,
|
||
brainProfileId: selectedBrainId
|
||
});
|
||
} catch (error: any) {
|
||
logError('Prompt handling failed in sidebar provider.', { error: error?.message || String(error), promptPreview: summarizeText(value || '', 200) });
|
||
this._view.webview.postMessage({ type: 'error', value: error.message });
|
||
} finally {
|
||
void this._sendReadyStatus();
|
||
}
|
||
}
|
||
|
||
_buildDesignerGuardContext(): string {
|
||
return buildProjectChronicleGuardContext(this._getActiveChronicleProject());
|
||
}
|
||
|
||
async _sendModels(force: boolean = false) {
|
||
if (!this._view) return;
|
||
if (this._modelDiscoveryInFlight) {
|
||
logInfo('Model discovery already in progress, skipping.');
|
||
return;
|
||
}
|
||
this._modelDiscoveryInFlight = true;
|
||
try {
|
||
const config = getConfig();
|
||
const url = config.ollamaUrl;
|
||
let defaultModel = config.defaultModel;
|
||
let models: string[] = [];
|
||
let online = false;
|
||
|
||
const cache = this._modelsCache;
|
||
const cacheFresh = !!cache
|
||
&& cache.url === url
|
||
&& (Date.now() - cache.fetchedAt) < SidebarChatProvider.MODELS_CACHE_TTL_MS;
|
||
|
||
if (!force && cacheFresh && cache) {
|
||
models = cache.models.slice();
|
||
online = cache.online;
|
||
this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } });
|
||
} else {
|
||
const engine = resolveEngine(url); // 단일 엔진만
|
||
const modelsUrl = buildApiUrl(url, engine, 'models');
|
||
try {
|
||
logInfo('Model discovery started.', { engine, modelsUrl, force });
|
||
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) });
|
||
const rawText = await res.text();
|
||
if (!res.ok) {
|
||
logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) });
|
||
} else {
|
||
const data = rawText ? JSON.parse(rawText) as any : {};
|
||
models = engine === 'lmstudio'
|
||
? (data.data || []).map((m: any) => m.id)
|
||
: (data.models || []).map((m: any) => m.name);
|
||
|
||
if (models.length > 0) {
|
||
logInfo('Model discovery succeeded.', { engine, count: models.length, modelsPreview: models.slice(0, 5) });
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) });
|
||
}
|
||
|
||
online = models.length > 0;
|
||
this._modelsCache = { url, models: models.slice(), online, fetchedAt: Date.now() };
|
||
this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } });
|
||
}
|
||
|
||
if (models.length === 0) {
|
||
models = defaultModel ? [defaultModel] : [];
|
||
}
|
||
|
||
const baseModel = defaultModel?.replace(/:\d+$/, '');
|
||
if (baseModel && baseModel !== defaultModel && models.includes(baseModel)) {
|
||
defaultModel = baseModel;
|
||
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
|
||
}
|
||
|
||
if (models.length > 0 && !defaultModel) {
|
||
// [State Persistence Fix v2] defaultModel이 완전히 비어있을 때만 첫 번째 모델로 설정
|
||
defaultModel = models[0];
|
||
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
|
||
} else if (models.length > 0 && defaultModel && !models.includes(defaultModel)) {
|
||
// [State Persistence Fix v2] 저장된 모델이 로컬 엔진 목록에 없는 경우:
|
||
// 강제 리셋하지 않고, 저장된 모델을 목록 선두에 추가하여 사용자 선택을 보존
|
||
logInfo('Saved model not in local engine list. Preserving user selection.', { saved: defaultModel, localModels: models.slice(0, 3) });
|
||
models.unshift(defaultModel);
|
||
}
|
||
|
||
const defaultIdx = models.indexOf(defaultModel);
|
||
if (defaultIdx > 0) {
|
||
models.splice(defaultIdx, 1);
|
||
models.unshift(defaultModel);
|
||
}
|
||
|
||
let loadedModels: string[] = [];
|
||
if (resolveEngine(url) === 'lmstudio' && this._lmStudio) {
|
||
try {
|
||
loadedModels = await this._lmStudio.loadedModels();
|
||
} catch (e) {
|
||
logInfo('LM Studio loadedModels probe failed (non-fatal).', { error: (e as any)?.message ?? String(e) });
|
||
}
|
||
}
|
||
|
||
this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel, loadedModels } });
|
||
} catch (err) {
|
||
logError('Model list update failed.', err);
|
||
} finally {
|
||
this._modelDiscoveryInFlight = false;
|
||
}
|
||
void this._sendReadyStatus();
|
||
}
|
||
|
||
static _htmlTemplateCache: string | undefined;
|
||
|
||
_getHtml(webview: vscode.Webview): string {
|
||
if (!SidebarChatProvider._htmlTemplateCache) {
|
||
const tplPath = path.join(this._extensionUri.fsPath, 'media', 'sidebar.html');
|
||
SidebarChatProvider._htmlTemplateCache = fs.readFileSync(tplPath, 'utf8');
|
||
}
|
||
const mediaRoot = vscode.Uri.joinPath(this._extensionUri, 'media');
|
||
const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.css')).toString();
|
||
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.js')).toString();
|
||
return SidebarChatProvider._htmlTemplateCache
|
||
.replace('__STYLES_URI__', stylesUri)
|
||
.replace('__SCRIPT_URI__', scriptUri);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Adapter that makes a {@link vscode.WebviewPanel} quack like a
|
||
* {@link vscode.WebviewView}, so providers written against the view API can
|
||
* mount inside an editor column without their internals knowing the difference.
|
||
*
|
||
* `onDidChangeVisibility` is synthesized from `onDidChangeViewState` — panels
|
||
* fire that event for both visibility *and* column moves, but the listener
|
||
* here only re-fires when the visible flag actually toggles.
|
||
*/
|
||
export function wrapPanelAsView(panel: vscode.WebviewPanel): vscode.WebviewView {
|
||
const visibilityEmitter = new vscode.EventEmitter<void>();
|
||
let _lastVisible = panel.visible;
|
||
panel.onDidChangeViewState(() => {
|
||
if (panel.visible !== _lastVisible) {
|
||
_lastVisible = panel.visible;
|
||
visibilityEmitter.fire();
|
||
}
|
||
});
|
||
panel.onDidDispose(() => visibilityEmitter.dispose());
|
||
const adapter: any = {
|
||
viewType: panel.viewType,
|
||
webview: panel.webview,
|
||
get visible() { return panel.visible; },
|
||
get title() { return panel.title; },
|
||
set title(v: string | undefined) { panel.title = v ?? ''; },
|
||
description: undefined as string | undefined,
|
||
badge: undefined as vscode.ViewBadge | undefined,
|
||
onDidChangeVisibility: visibilityEmitter.event,
|
||
onDidDispose: panel.onDidDispose,
|
||
show(preserveFocus?: boolean) {
|
||
panel.reveal(panel.viewColumn ?? vscode.ViewColumn.Three, preserveFocus);
|
||
},
|
||
};
|
||
return adapter as vscode.WebviewView;
|
||
}
|
||
|
||
/**
|
||
* Pixel Office "전체보기" webview panel용 HTML. 사이드바 mini 패널과 데이터
|
||
* 스키마는 같지만(`pixelOfficeUpdate` 메시지), 화면 구성은 사무실 그리드 +
|
||
* 직군별 캐릭터 배치로 완전히 다르다.
|
||
*
|
||
* 사무실 레이아웃:
|
||
* ┌────────────────────────────────────────────────┐
|
||
* │ Astra Office — 현재 작업 (스크롤 헤더) │
|
||
* │ CEO │
|
||
* │ [기획] [리서치] [디자인] │
|
||
* │ [개발] [QA] [감리] │
|
||
* │ Footer: progress bar + 닫기 │
|
||
* └────────────────────────────────────────────────┘
|
||
*
|
||
* 작업 중인 직군에 해당하는 캐릭터에 강조 테두리 + 머리 위 말풍선. 다른
|
||
* 캐릭터는 살짝 dim. 직군은 ROLE_CATEGORY_ORDER 순으로 배치 — Agent 코드
|
||
* 변경 없이 백엔드의 listActiveAgentsByCategory 결과를 그대로 활용 가능.
|
||
*/
|
||
function _pixelOfficePanelHtml(cspSource: string, assets: { derivedBase: string }): string {
|
||
return `<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${cspSource} data:; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
|
||
<style>
|
||
:root{--bg:#0E1019;--wall:#202536;--floor:#302634;--floor2:#281F2C;--text:#F1F4FB;--muted:#A8B0C7;--accent:#7C83FF;}
|
||
*{box-sizing:border-box} body{margin:0;height:100vh;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:flex;flex-direction:column;overflow:hidden}
|
||
header{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.22);border-bottom:1px solid rgba(255,255,255,.08)}
|
||
.h-title{font-weight:800}.h-sub{font-size:11px;color:var(--muted)}.status{font-size:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.18);border-radius:999px}
|
||
.strip{display:flex;gap:16px;padding:8px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid rgba(255,255,255,.06)}.strip b{color:var(--text)}
|
||
.office{position:relative;flex:1;overflow:hidden;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,#21283a 0 16%,transparent 16%),radial-gradient(ellipse at 50% 0%,rgba(124,131,255,.12),transparent 42%),linear-gradient(135deg,#322835,#271f2a)}
|
||
.office:before{content:'';position:absolute;left:0;right:0;top:16%;bottom:0;background-image:linear-gradient(rgba(255,255,255,.028) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.028) 1px,transparent 1px);background-size:48px 48px}
|
||
.office:after{content:'';position:absolute;left:0;right:0;top:15.5%;height:8px;background:linear-gradient(180deg,rgba(0,0,0,.36),transparent)}
|
||
.stage{position:relative;width:720px;height:585px;margin:0}
|
||
.wall-window{position:absolute;top:16px;width:86px;height:42px;border:3px solid rgba(206,223,255,.35);background:linear-gradient(180deg,rgba(160,208,255,.3),rgba(110,150,210,.1));box-shadow:inset 0 0 0 2px rgba(15,20,31,.55)}
|
||
.wall-window.w1{left:84px}.wall-window.w2{left:550px}
|
||
.obj,.desk,.char{position:absolute;image-rendering:pixelated}
|
||
.obj{filter:drop-shadow(3px 4px 0 rgba(0,0,0,.28));z-index:4}
|
||
.desk{width:112px;z-index:5;filter:drop-shadow(4px 5px 0 rgba(0,0,0,.32))}.desk.boss{width:136px}.label{position:absolute;left:50%;bottom:-10px;transform:translateX(-50%);font-size:10px;color:rgba(241,244,251,.78);white-space:nowrap;text-shadow:1px 1px #000}
|
||
.char{width:56px;height:72px;z-index:7;transition:left .9s cubic-bezier(.2,.7,.2,1),top .9s cubic-bezier(.2,.7,.2,1)}.char.walking{z-index:14}.char img{position:absolute;left:0;bottom:0;max-width:100%;max-height:100%;image-rendering:pixelated;filter:drop-shadow(2px 2px 0 rgba(0,0,0,.45));transform-origin:center bottom}
|
||
.char.active:before{content:'';position:absolute;left:24px;top:-10px;width:8px;height:8px;background:var(--role-color,var(--accent));box-shadow:0 0 12px var(--role-color,var(--accent));animation:po-pulse 1.6s ease-in-out infinite}
|
||
@keyframes po-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:.6}}
|
||
/* ── C. 직군별 페르소나 컬러 ── 책상 outline 가벼운 강조, 활성 캐릭터 위 점이 직군색.
|
||
data-role attribute로 자동 매핑. 사용자가 PNG sprite로 swap해도 컬러는 유지. */
|
||
.char[data-agent="ceo"],.desk[data-agent="ceo"] {--role-color:#A78BFA}
|
||
.char[data-agent="planner"],.desk[data-agent="planner"] {--role-color:#60A5FA}
|
||
.char[data-agent="researcher"],.desk[data-agent="researcher"] {--role-color:#10B981}
|
||
.char[data-agent="designer"],.desk[data-agent="designer"] {--role-color:#F472B6}
|
||
.char[data-agent="developer"],.desk[data-agent="developer"] {--role-color:#FBBF24}
|
||
.char[data-agent="qa"],.desk[data-agent="qa"] {--role-color:#22D3EE}
|
||
.char[data-agent="inspector"],.desk[data-agent="inspector"] {--role-color:#FB923C}
|
||
.char[data-agent="support"],.desk[data-agent="support"] {--role-color:#94A3B8}
|
||
.char.active::after{content:'';position:absolute;left:0;right:0;bottom:-4px;height:3px;background:var(--role-color,var(--accent));box-shadow:0 0 8px var(--role-color,var(--accent));border-radius:2px;animation:po-glow 1.6s ease-in-out infinite}
|
||
@keyframes po-glow{0%,100%{opacity:.7}50%{opacity:1}}
|
||
.desk{position:relative}
|
||
.desk::after{content:'';position:absolute;inset:-2px;border-radius:4px;border:2px solid transparent;pointer-events:none;transition:border-color .3s}
|
||
.stage:has(.char.active[data-agent="ceo"]) .desk[data-agent="ceo"]::after,
|
||
.stage:has(.char.active[data-agent="planner"]) .desk[data-agent="planner"]::after,
|
||
.stage:has(.char.active[data-agent="researcher"]) .desk[data-agent="researcher"]::after,
|
||
.stage:has(.char.active[data-agent="designer"]) .desk[data-agent="designer"]::after,
|
||
.stage:has(.char.active[data-agent="developer"]) .desk[data-agent="developer"]::after,
|
||
.stage:has(.char.active[data-agent="qa"]) .desk[data-agent="qa"]::after,
|
||
.stage:has(.char.active[data-agent="inspector"]) .desk[data-agent="inspector"]::after,
|
||
.stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after
|
||
{border-color:var(--role-color)}
|
||
.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%);background:#fff;color:#222;padding:5px 8px;border-radius:8px;font-size:11px;box-shadow:2px 2px 0 rgba(0,0,0,.35);white-space:nowrap}
|
||
.edit-btn{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.2);color:#F1F4FB;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px}.edit-btn:hover{background:rgba(99,102,241,.25);border-color:#6366F1}
|
||
/* ── B. 워크플로우 미니 맵 ── 헤더 아래 dot strip. 각 dot이 stage 하나. 완료=
|
||
채워진 점, 활성=링 펄스, 대기=빈 점. 호버 시 라벨 표시. */
|
||
.mini-map{display:flex;gap:5px;align-items:center;padding:7px 16px;background:rgba(0,0,0,.3);border-bottom:1px solid rgba(255,255,255,.06);overflow-x:auto;scrollbar-width:none}.mini-map::-webkit-scrollbar{display:none}
|
||
.mini-map .mm-dot{position:relative;width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.12);border:1.5px solid rgba(255,255,255,.18);flex-shrink:0;cursor:default;transition:all .25s}
|
||
.mini-map .mm-dot[data-status="done"]{background:#10B981;border-color:#10B981;box-shadow:0 0 4px rgba(16,185,129,.5)}
|
||
.mini-map .mm-dot[data-status="active"]{background:var(--accent);border-color:var(--accent);width:14px;height:14px;box-shadow:0 0 0 3px rgba(99,102,241,.3);animation:mm-pulse 1.4s ease-in-out infinite}
|
||
@keyframes mm-pulse{0%,100%{box-shadow:0 0 0 3px rgba(99,102,241,.3)}50%{box-shadow:0 0 0 6px rgba(99,102,241,.15)}}
|
||
.mini-map .mm-bar{flex:1;height:1px;background:linear-gradient(90deg,rgba(255,255,255,.08),rgba(255,255,255,.16))}
|
||
.mini-map .mm-label{position:absolute;left:50%;top:-22px;transform:translateX(-50%);font-size:10px;color:#F1F4FB;background:rgba(0,0,0,.85);padding:2px 6px;border-radius:3px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .15s;z-index:50}
|
||
.mini-map .mm-dot:hover .mm-label{opacity:1}
|
||
.mini-map .mm-counter{flex-shrink:0;font-size:10px;color:#94A3B8;margin-left:8px;white-space:nowrap}
|
||
/* ── E. Activity Ticker ── action-tag executor 결과를 하단 strip으로 흘림.
|
||
사용자가 에이전트의 *실제 행동*(파일 쓰기, 명령 실행)을 실시간으로 보며 신뢰. */
|
||
.ticker{position:relative;padding:5px 16px;background:rgba(99,102,241,.08);border-top:1px solid rgba(99,102,241,.18);overflow:hidden;font-size:11px;font-family:ui-monospace,monospace;height:24px}
|
||
.tk-track{display:flex;gap:18px;white-space:nowrap;animation:tk-roll 22s linear infinite;will-change:transform}
|
||
.ticker:hover .tk-track{animation-play-state:paused}
|
||
.tk-item{flex-shrink:0;color:#D7DBEA}
|
||
.tk-item.tk-ok{color:#10B981}
|
||
.tk-item.tk-warn{color:#F5C518}
|
||
.tk-item.tk-err{color:#EF4444}
|
||
.tk-item .tk-agent{color:#A78BFA;margin-right:5px;font-weight:600}
|
||
@keyframes tk-roll{from{transform:translateX(0)}to{transform:translateX(-50%)}}
|
||
/* ── D. 캐릭터 컨텍스트 메뉴 ── 편집 모드 X일 때 캐릭터 클릭하면 작은 메뉴 popup.
|
||
현재 turn 제어 + 최근 활동 보기. */
|
||
.ctx-menu{position:fixed;z-index:1000;background:#13162A;border:1px solid #2A2E3F;border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:4px;min-width:170px;font-size:12px;color:#F1F4FB}
|
||
.ctx-menu-head{padding:6px 10px 4px;font-size:10px;color:#94A3B8;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:4px}
|
||
.ctx-menu-head .cmh-role{color:var(--role-color,#A78BFA);font-weight:700;text-transform:uppercase}
|
||
.ctx-menu-item{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:pointer;border-radius:4px;transition:background .12s}
|
||
.ctx-menu-item:hover{background:rgba(99,102,241,.18)}
|
||
.ctx-menu-item.danger:hover{background:rgba(239,68,68,.18);color:#FCA5A5}
|
||
.ctx-menu-divider{height:1px;background:rgba(255,255,255,.08);margin:3px 4px}
|
||
body[data-edit-mode="true"] .ctx-menu{display:none!important}
|
||
body:not([data-edit-mode="true"]) .char{cursor:pointer}
|
||
.ctx-detail{position:fixed;z-index:1001;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 12px 36px rgba(0,0,0,.7);padding:16px 18px;color:#F1F4FB;min-width:320px;max-width:520px;max-height:60vh;overflow-y:auto;font-size:12px;line-height:1.5}
|
||
.ctx-detail h3{margin:0 0 8px;font-size:14px;color:var(--role-color,#A78BFA);text-transform:uppercase;letter-spacing:.04em}
|
||
.ctx-detail .cd-close{position:absolute;top:8px;right:10px;background:transparent;border:none;color:#94A3B8;font-size:16px;cursor:pointer}
|
||
.ctx-detail dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:4px 14px}
|
||
.ctx-detail dt{color:#94A3B8;font-weight:600;white-space:nowrap}
|
||
.ctx-detail dd{margin:0;color:#F1F4FB;overflow-wrap:anywhere}
|
||
.ctx-detail .cd-logs{margin-top:10px;padding:6px 8px;background:rgba(0,0,0,.3);border-radius:4px;font-family:ui-monospace,monospace;font-size:10.5px;max-height:120px;overflow-y:auto}
|
||
.edit-toolbar{display:flex;gap:8px;align-items:center;padding:6px 16px;background:rgba(99,102,241,.18);border-bottom:1px solid rgba(99,102,241,.4);font-size:11px;flex-wrap:wrap}.edit-toolbar .et-hint{flex:1;color:#D7DBEA;min-width:160px}.edit-toolbar button{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#F1F4FB;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px}.edit-toolbar button:hover{background:rgba(99,102,241,.35)}
|
||
.edit-toolbar button.add{background:rgba(16,185,129,.22);border-color:rgba(16,185,129,.55)}.edit-toolbar button.add:hover{background:rgba(16,185,129,.4)}
|
||
.edit-toolbar button.del{background:rgba(239,68,68,.22);border-color:rgba(239,68,68,.55)}.edit-toolbar button.del:hover{background:rgba(239,68,68,.4)}.edit-toolbar button[disabled]{opacity:.4;cursor:not-allowed}
|
||
/* 선택된 item 의 속성 편집 패널 — 우측 슬라이드 */
|
||
.prop-panel{position:absolute;right:12px;top:90px;width:240px;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:12px;font-size:11px;color:#F1F4FB;z-index:25;display:none}
|
||
.prop-panel.show{display:block}
|
||
.prop-panel h4{margin:0 0 8px;font-size:12px;color:#A78BFA;text-transform:uppercase;letter-spacing:.04em}
|
||
.prop-panel .pp-row{margin-bottom:8px}
|
||
.prop-panel label{display:block;font-size:10px;color:#94A3B8;margin-bottom:2px}
|
||
.prop-panel select,.prop-panel input{width:100%;background:#0c1020;color:#F1F4FB;border:1px solid #2A2E3F;border-radius:4px;padding:3px 6px;font-size:11px}
|
||
.prop-panel .pp-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:4px}
|
||
.prop-panel .pp-thumb{width:100%;aspect-ratio:1/1;background:#0c1020;border:1px solid #2A2E3F;border-radius:3px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:2px}
|
||
.prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated}
|
||
.prop-panel .pp-thumb.active{border-color:#A78BFA;box-shadow:0 0 0 2px rgba(167,139,250,.35)}
|
||
/* 프랍 추가 picker — 모달 grid */
|
||
.prop-picker{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1100;display:flex;align-items:center;justify-content:center}
|
||
.prop-picker-box{background:#13162A;border:1px solid #2A2E3F;border-radius:10px;padding:14px;max-width:520px;max-height:80vh;overflow-y:auto;color:#F1F4FB}
|
||
.prop-picker-box h3{margin:0 0 10px;font-size:13px;color:#A78BFA}
|
||
.prop-picker-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
|
||
.prop-pick{background:#0c1020;border:1px solid #2A2E3F;border-radius:4px;padding:6px;cursor:pointer;text-align:center}
|
||
.prop-pick:hover{border-color:#A78BFA}
|
||
.prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated}
|
||
.prop-pick .pp-name{font-size:10px;color:#94A3B8;margin-top:4px;word-break:break-all}
|
||
/* 편집 모드 — 드래그 가능 요소 강조 */
|
||
body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(99,102,241,.15) 1px,transparent 1px),linear-gradient(90deg,rgba(99,102,241,.15) 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(99,102,241,.5)}
|
||
body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid #6366F1;z-index:30}
|
||
body[data-edit-mode="true"] .dragging{cursor:grabbing!important;opacity:.7;outline:2px solid #FB923C!important;z-index:40}
|
||
body[data-edit-mode="true"] .selected{outline:2px solid #F472B6!important;box-shadow:0 0 0 4px rgba(244,114,182,.25);z-index:35}
|
||
body[data-edit-mode="true"] .char .shadow{display:none}
|
||
footer{padding:8px 16px 12px;border-top:1px solid rgba(255,255,255,.08);background:rgba(0,0,0,.25);font-size:11px;color:var(--muted)}.progress{height:5px;background:rgba(255,255,255,.08);margin-bottom:6px}.bar{height:100%;width:0;background:var(--accent);transition:width .25s}
|
||
</style></head>
|
||
<body>
|
||
<header><div><div class="h-title">🏢 ASTRA OFFICE</div><div class="h-sub" id="agent">Astra</div></div><div style="display:flex;gap:8px;align-items:center;"><button id="editBtn" class="edit-btn" title="배치 편집 모드 토글">✏️ 편집</button><div class="status" id="status">idle</div></div></header>
|
||
<div id="miniMap" class="mini-map" style="display:none;"></div>
|
||
<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="deleteSelBtn" class="del" title="선택 항목 삭제" disabled>🗑 삭제</button>
|
||
<button id="layerUpBtn" title="레이어 위로 (])">⬆</button>
|
||
<button id="layerDownBtn" title="레이어 아래로 ([)">⬇</button>
|
||
<button id="saveBtn">💾 저장</button>
|
||
<button id="resetBtn" title="기본 배치로 복귀">↻ 디폴트</button>
|
||
<button id="cancelBtn" title="저장 안 하고 종료">✕ 취소</button>
|
||
</div>
|
||
<div class="strip"><span><b>작업</b> <span id="task">—</span></span><span><b>단계</b> <span id="step">—</span></span></div>
|
||
<main class="office"><div class="stage" id="stage"><div class="wall-window w1"></div><div class="wall-window w2"></div></div><div id="propPanel" class="prop-panel"></div></main>
|
||
<div id="ticker" class="ticker" style="display:none;"><div class="tk-track" id="tickerTrack"></div></div>
|
||
<footer><div class="progress"><div class="bar" id="bar"></div></div><div id="log">—</div></footer>
|
||
<script>(function(){
|
||
const base='${assets.derivedBase}'; const stage=document.getElementById('stage');
|
||
// \u2500\u2500 \uB370\uC774\uD130 \uBAA8\uB378 \u2500\u2500
|
||
// stations: \uCC45\uC0C1 + \uCE90\uB9AD\uD130 \uC815\uC758 \uBC30\uC5F4 (let \u2014 \uCD94\uAC00/\uC81C\uAC70 \uAC00\uB2A5).
|
||
// key = \uC548\uC815\uC801 \uC2DD\uBCC4\uC790 (DOM dataset.role \uB85C\uB3C4 \uC0AC\uC6A9). \uC0AC\uC6A9\uC790\uAC00 \uC0C8\uB85C \uB9CC\uB4E0 \uCC45\uC0C1\uC740 \uC790\uB3D9 \uC0DD\uC131.
|
||
// agentKey = \uC774 \uCC45\uC0C1\uC5D0 \uB9E4\uD551\uB41C \uC5D0\uC774\uC804\uD2B8 ID (ceo/planner/researcher/...). \uC5C6\uC73C\uBA74 idle.
|
||
// charRow = \uC0AC\uC6A9\uD560 sprite row (0~7). \uCE90\uB9AD\uD130 \uC678\uBAA8/\uBC29\uD5A5.
|
||
// deskSprite= \uCC45\uC0C1 PNG \uC774\uB984 (desk-main / desk-boss / desk-dark-mirror \uB4F1).
|
||
// objs: \uD504\uB78D \uC815\uC758 \uBC30\uC5F4 (let). user-add/remove.
|
||
const DEFAULT_STATIONS=[
|
||
{key:'ceo',agentKey:'ceo',label:'CEO',charRow:0,deskSprite:'desk-boss',deskX:304,deskY:84,deskW:136,seatX:331,seatY:115,face:'R',dock:[362,164],roam:[[320,196],[396,196]],boss:true},
|
||
{key:'planner',agentKey:'planner',label:'\uAE30\uD68D',charRow:1,deskSprite:'desk-main',deskX:60,deskY:228,deskW:112,seatX:64,seatY:264,face:'R',dock:[96,322],roam:[[154,350],[200,330]]},
|
||
{key:'researcher',agentKey:'researcher',label:'\uB9AC\uC11C\uCE58',charRow:2,deskSprite:'desk-dark-mirror',deskX:236,deskY:214,deskW:112,seatX:284,seatY:248,face:'L',dock:[304,310],roam:[[322,340],[344,322]]},
|
||
{key:'designer',agentKey:'designer',label:'\uB514\uC790\uC778',charRow:3,deskSprite:'desk-main',deskX:402,deskY:240,deskW:112,seatX:406,seatY:276,face:'R',dock:[438,334],roam:[[492,350],[520,326]]},
|
||
{key:'developer',agentKey:'developer',label:'\uAC1C\uBC1C',charRow:4,deskSprite:'desk-dark-mirror',deskX:56,deskY:410,deskW:112,seatX:104,seatY:442,face:'L',dock:[124,500],roam:[[150,534],[188,518]]},
|
||
{key:'qa',agentKey:'qa',label:'QA',charRow:5,deskSprite:'desk-main',deskX:232,deskY:394,deskW:112,seatX:236,seatY:430,face:'R',dock:[268,486],roam:[[320,520],[352,500]]},
|
||
{key:'inspector',agentKey:'inspector',label:'\uAC10\uB9AC',charRow:6,deskSprite:'desk-dark-mirror',deskX:408,deskY:420,deskW:112,seatX:456,seatY:452,face:'L',dock:[476,506],roam:[[506,532],[540,502]]},
|
||
{key:'support',agentKey:'support',label:'\uC9C0\uC6D0',charRow:7,deskSprite:'desk-main',deskX:220,deskY:472,deskW:112,seatX:224,seatY:504,face:'R',dock:[256,548],roam:[[360,548],[484,540]]},
|
||
];
|
||
// \uC5D0\uC774\uC804\uD2B8 ID alias \u2014 \uAC19\uC740 \uD398\uB974\uC18C\uB098\uC758 \uB2E4\uC591\uD55C \uD638\uCE6D\uC744 \uAC19\uC740 book agentKey \uB85C. (writer\u2192planner, editor\u2192designer, secretary\u2192support, business\u2192inspector)
|
||
const AGENT_ALIASES={writer:'planner',editor:'designer',secretary:'support',business:'inspector'};
|
||
// \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'];
|
||
// \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 DEFAULT_PROPS=[
|
||
{name:'board',x:316,y:12,w:88},{name:'plant-tall',x:44,y:92,w:42},{name:'bookshelf',x:86,y:70,w:54},
|
||
{name:'plant-bushy',x:642,y:96,w:42},{name:'partition',x:520,y:208,w:72},{name:'cooler',x:640,y:248,w:38},
|
||
{name:'filing',x:620,y:330,w:42},{name:'couch',x:578,y:432,w:96},{name:'rug',x:560,y:510,w:126},
|
||
{name:'shelf',x:40,y:504,w:118},{name:'printer',x:520,y:520,w:58},{name:'monitor-blue',x:356,y:56,w:44},
|
||
];
|
||
|
||
let stations=[]; // mutable, \uC2DC\uC791 \uC2DC default \uB610\uB294 saved layout \uB85C \uCC44\uC6C0.
|
||
let __nextDeskN=100; // user-add \uCC45\uC0C1 id \uCE74\uC6B4\uD130 (default \uC640 \uCDA9\uB3CC \uD68C\uD53C \uC704\uD574 \uD070 \uC218\uC5D0\uC11C \uC2DC\uC791).
|
||
let __nextObjN=0;
|
||
const stationByKey={}; // \uBE60\uB978 lookup. stations \uBCC0\uACBD \uC2DC rebuild.
|
||
const __deskWrap={}; // role \u2192 desk DOM wrap.
|
||
const chars={}; // role \u2192 char DOM.
|
||
const anim={}; // role \u2192 animation state.
|
||
|
||
function png(name){return base+'/'+name+'.png'}
|
||
|
||
function _rebuildStationIndex(){
|
||
Object.keys(stationByKey).forEach(k=>delete stationByKey[k]);
|
||
stations.forEach(st=>{ stationByKey[st.key]=st; });
|
||
}
|
||
// agentKey \u2192 station.key \uB85C \uB77C\uC6B0\uD305. roleMap \uC758 \uB3D9\uC801 \uBC84\uC804.
|
||
function findStationByAgent(agentId){
|
||
if(!agentId) return null;
|
||
const a = AGENT_ALIASES[agentId] || agentId;
|
||
for(const st of stations){ if(st.agentKey === a) return st; }
|
||
return null;
|
||
}
|
||
// \uC61B \uCF54\uB4DC \uD638\uD658 \u2014 roleMap[x] \uD638\uCD9C \uD328\uD134\uC744 \uD568\uC218\uB85C.
|
||
const roleMap=new Proxy({},{get:(_,k)=>{ const s=findStationByAgent(k); return s?s.key:null; }});
|
||
|
||
function addImg(name,x,y,w){
|
||
const i=document.createElement('img');
|
||
i.src=png(name); i.className='obj';
|
||
i.dataset.objId='obj_'+(__nextObjN++); i.dataset.objName=name;
|
||
if(w!=null) i.dataset.objW=w;
|
||
i.style.left=x+'px'; i.style.top=y+'px';
|
||
if(w!=null) i.style.width=w+'px';
|
||
stage.appendChild(i); return i;
|
||
}
|
||
|
||
function addDesk(st){
|
||
const wrap=document.createElement('div');
|
||
wrap.className='desk '+(st.boss?'boss':'');
|
||
wrap.dataset.role=st.key;
|
||
if(st.agentKey) wrap.dataset.agent=st.agentKey;
|
||
wrap.style.left=st.deskX+'px'; wrap.style.top=st.deskY+'px'; wrap.style.width=st.deskW+'px';
|
||
const img=document.createElement('img'); img.src=png(st.deskSprite); img.style.width='100%';
|
||
wrap.appendChild(img);
|
||
const l=document.createElement('div'); l.className='label'; l.textContent=st.label;
|
||
wrap.appendChild(l);
|
||
stage.appendChild(wrap);
|
||
__deskWrap[st.key]=wrap;
|
||
return wrap;
|
||
}
|
||
|
||
function addChar(st){
|
||
const ch=document.createElement('div');
|
||
ch.className='char'; ch.dataset.role=st.key;
|
||
if(st.agentKey) ch.dataset.agent=st.agentKey;
|
||
ch.style.left=st.seatX+'px'; ch.style.top=st.seatY+'px';
|
||
ch.dataset.homeX=st.seatX; ch.dataset.homeY=st.seatY; ch.dataset.row=st.charRow;
|
||
ch.innerHTML='<img><div class="shadow"></div>';
|
||
stage.appendChild(ch);
|
||
chars[st.key]=ch;
|
||
anim[st.key]={row:st.charRow,frame:0,dir:0,mode:'sit',face:st.face,route:0};
|
||
const img=ch.querySelector('img');
|
||
if(st.face === 'U' || st.face === 'D'){
|
||
img.src=png('walk-r'+st.charRow+'-d'+_faceToWalkDir(st.face)+'-f0');
|
||
img.style.transform='none';
|
||
} else {
|
||
img.src=png('idle-r'+st.charRow+'-f0');
|
||
img.style.transform=st.face==='R'?'scaleX(-1)':'none';
|
||
}
|
||
return ch;
|
||
}
|
||
|
||
function buildStation(st){ addDesk(st); if(!st.noChar) addChar(st); }
|
||
|
||
function _renderDefaultStations(){
|
||
// default 8 stations + default props.
|
||
stations = DEFAULT_STATIONS.map(s=>Object.assign({},s));
|
||
_rebuildStationIndex();
|
||
stations.forEach(buildStation);
|
||
DEFAULT_PROPS.forEach(p=>addImg(p.name,p.x,p.y,p.w));
|
||
}
|
||
_renderDefaultStations();
|
||
// ── 4방향 dir 매핑 ──
|
||
// PNG 파일명 규약: walk-r<row>-d<DIR>-f<frame>.png
|
||
// 사용자의 sprite 컨벤션이 다르면 이 4 상수만 수정하면 됨.
|
||
// 기본값은 일반적인 RPG 4방향 시트 순서 (caliverse · RPGMaker 등).
|
||
const DIR_DOWN = 0; // 정면 (카메라/사용자 쪽)
|
||
const DIR_LEFT = 1;
|
||
const DIR_RIGHT = 2;
|
||
const DIR_UP = 3; // 뒤
|
||
// idle/work 의 정면(D) / 후면(U) sprite 는 별도로 없으므로 walk 의 같은 방향
|
||
// frame 0 (정지 포즈) 를 그대로 빌려쓴다.
|
||
function _faceToWalkDir(face){
|
||
if(face === 'D') return DIR_DOWN;
|
||
if(face === 'U') return DIR_UP;
|
||
if(face === 'L') return DIR_LEFT;
|
||
return DIR_RIGHT;
|
||
}
|
||
function setSprite(role,mode,frame=0,dir=0){
|
||
const ch=chars[role]; if(!ch) return;
|
||
const a=anim[role]; if(!a) return;
|
||
a.mode=mode;a.frame=frame;a.dir=dir;
|
||
ch.classList.toggle('walking',mode==='walk');
|
||
const img=ch.querySelector('img');
|
||
if(mode==='walk'){
|
||
// 4방향 walk sprite — 좌우 sprite를 따로 제공하므로 scaleX 반전은 *안* 한다.
|
||
img.src=png('walk-r'+a.row+'-d'+dir+'-f'+frame);
|
||
img.style.transform='none';
|
||
} else if(a.face === 'U' || a.face === 'D'){
|
||
// 위/아래 face 는 idle/work sprite 가 없으므로 walk 의 같은 방향 정지 포즈로.
|
||
img.src=png('walk-r'+a.row+'-d'+_faceToWalkDir(a.face)+'-f0');
|
||
img.style.transform='none';
|
||
} else if(mode==='work'){
|
||
img.src=png('work-r'+a.row+'-f'+frame);
|
||
img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
|
||
} else {
|
||
img.src=png('idle-r'+a.row+'-f'+frame);
|
||
img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
|
||
}
|
||
}
|
||
function move(role,x,y){
|
||
const ch=chars[role]; if(!ch) return; // 캐릭터 삭제된 자리면 무시.
|
||
const a=anim[role]; if(!a) return;
|
||
const cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top),
|
||
dx=x-cx,dy=y-cy;
|
||
// 주축 결정: 큰 쪽을 우선해 4방향 중 하나로 매핑. 동률이면 가로 우선.
|
||
let dir;
|
||
if(Math.abs(dx) >= Math.abs(dy)){
|
||
dir = dx >= 0 ? DIR_RIGHT : DIR_LEFT;
|
||
} else {
|
||
dir = dy >= 0 ? DIR_DOWN : DIR_UP;
|
||
}
|
||
// 도착 후 idle/sit 시 좌우 face도 마지막 가로 이동에 맞춰 갱신. UP/DOWN은
|
||
// 기존 face 유지 — 4방향 idle sprite가 없을 때 좌우만이라도 자연스럽게.
|
||
if(dir === DIR_LEFT) a.face = 'L';
|
||
if(dir === DIR_RIGHT) a.face = 'R';
|
||
setSprite(role,'walk',0,dir);
|
||
ch.style.left=x+'px';
|
||
ch.style.top=y+'px';
|
||
}
|
||
setInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='walk'){a.frame=(a.frame+1)%5;setSprite(k,'walk',a.frame,a.dir)}else if(a.mode==='work'){a.frame=(a.frame+1)%4;setSprite(k,'work',a.frame)} });},220)
|
||
setInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='sit'){a.frame=(a.frame+1)%2;setSprite(k,'sit',a.frame)} });},700)
|
||
// ── 책상 회피 path planner ──
|
||
// walkPath의 각 leg를 직선이 아닌 *책상을 우회하는* L자 또는 corridor 경로로
|
||
// 펴서 캐릭터가 책상을 가로지르지 않게. 책상이 회전됐을 때를 대비해 padding
|
||
// 충분히. 사용자 layout이 너무 빡빡해 모든 시도가 fail이면 직선 fallback.
|
||
function _deskRects(){
|
||
return stations.map(st=>{
|
||
const w=__deskWrap[st.key]; if(!w) return null;
|
||
const x=parseFloat(w.style.left), y=parseFloat(w.style.top);
|
||
const ww=parseFloat(w.style.width)||100;
|
||
const hh=w.offsetHeight||40;
|
||
const pad=20;
|
||
return {x:x-pad, y:y-pad, w:ww+pad*2, h:hh+pad*2};
|
||
}).filter(Boolean);
|
||
}
|
||
function _segIntersect(x1,y1,x2,y2,x3,y3,x4,y4){
|
||
const denom=(x1-x2)*(y3-y4)-(y1-y2)*(x3-x4);
|
||
if(Math.abs(denom)<0.0001) return false;
|
||
const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/denom;
|
||
const u=-((x1-x2)*(y1-y3)-(y1-y2)*(x1-x3))/denom;
|
||
return t>=0&&t<=1&&u>=0&&u<=1;
|
||
}
|
||
function _segHitsRect(x1,y1,x2,y2,r){
|
||
// 양 끝점이 rect 안이면 즉시 충돌
|
||
const inR=(x,y)=>x>=r.x&&x<=r.x+r.w&&y>=r.y&&y<=r.y+r.h;
|
||
if(inR(x1,y1)||inR(x2,y2)) return true;
|
||
// 4변과 교차 검사
|
||
return _segIntersect(x1,y1,x2,y2,r.x,r.y,r.x+r.w,r.y)
|
||
|| _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y,r.x+r.w,r.y+r.h)
|
||
|| _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y+r.h,r.x,r.y+r.h)
|
||
|| _segIntersect(x1,y1,x2,y2,r.x,r.y+r.h,r.x,r.y);
|
||
}
|
||
function _pathClear(pts,rects){
|
||
for(let i=0;i<pts.length-1;i++){
|
||
const [a,b]=[pts[i],pts[i+1]];
|
||
for(const r of rects){ if(_segHitsRect(a[0],a[1],b[0],b[1],r)) return false; }
|
||
}
|
||
return true;
|
||
}
|
||
function _planPath(fromX,fromY,toX,toY){
|
||
const rects=_deskRects();
|
||
if(rects.length===0) return [[toX,toY]];
|
||
// 1) 직선
|
||
if(_pathClear([[fromX,fromY],[toX,toY]],rects)) return [[toX,toY]];
|
||
// 2) 가로 먼저 L
|
||
if(_pathClear([[fromX,fromY],[toX,fromY],[toX,toY]],rects)) return [[toX,fromY],[toX,toY]];
|
||
// 3) 세로 먼저 L
|
||
if(_pathClear([[fromX,fromY],[fromX,toY],[toX,toY]],rects)) return [[fromX,toY],[toX,toY]];
|
||
// 4) 사무실 corridor를 통과 — y 통로 후보 (윗·중간·아랫줄 책상 사이)
|
||
// stage 높이에 따라 자동 후보 생성: 0, h/4, h/2, 3h/4, h
|
||
const sh=stage.offsetHeight||600;
|
||
const ycands=[20,Math.round(sh*0.32),Math.round(sh*0.6),Math.round(sh*0.82),sh-20];
|
||
for(const cy of ycands){
|
||
const trial=[[fromX,fromY],[fromX,cy],[toX,cy],[toX,toY]];
|
||
if(_pathClear(trial,rects)) return [[fromX,cy],[toX,cy],[toX,toY]];
|
||
}
|
||
// 5) x 통로 후보
|
||
const sw=stage.offsetWidth||700;
|
||
const xcands=[20,Math.round(sw*0.32),Math.round(sw*0.6),Math.round(sw*0.82),sw-20];
|
||
for(const cx of xcands){
|
||
const trial=[[fromX,fromY],[cx,fromY],[cx,toY],[toX,toY]];
|
||
if(_pathClear(trial,rects)) return [[cx,fromY],[cx,toY],[toX,toY]];
|
||
}
|
||
// 최후의 fallback — 직선 (책상을 가로지를 수 있지만 적어도 멈추진 않음)
|
||
return [[toX,toY]];
|
||
}
|
||
function walkPath(role,points,done,route){
|
||
const a=anim[role],token=route??++a.route;
|
||
if(token!==a.route) return;
|
||
if(!points.length){ if(done) done(); return; }
|
||
const ch=chars[role];
|
||
const cx=parseFloat(ch.style.left), cy=parseFloat(ch.style.top);
|
||
const [pt,...rest]=points;
|
||
// 현재 위치 → 다음 waypoint를 책상 회피 경로로 펴기.
|
||
const planned=_planPath(cx,cy,pt[0],pt[1]);
|
||
// 첫 step 이동하고, 나머지 planned 점들 + 원본 rest를 큐로.
|
||
const next=planned[0];
|
||
move(role,next[0],next[1]);
|
||
const tail=planned.slice(1).concat(rest);
|
||
setTimeout(()=>walkPath(role,tail,done,token),950);
|
||
}
|
||
function sendHome(role,mode='sit'){
|
||
const st=stationByKey[role],ch=chars[role];
|
||
if(!st || !ch) return; // 책상/캐릭터 부재 시 no-op.
|
||
const hx=st.seatX,hy=st.seatY,cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top);
|
||
anim[role].route++;
|
||
if(Math.abs(cx-hx)<1&&Math.abs(cy-hy)<1){setSprite(role,mode);return;}
|
||
walkPath(role,[st.dock,[hx,hy]],()=>setSprite(role,mode));
|
||
}
|
||
setInterval(()=>{
|
||
const free=Object.keys(chars).filter(k=>anim[k]?.mode==='sit'&&!chars[k].classList.contains('active'));
|
||
if(!free.length)return;
|
||
const k=free[Math.floor(Math.random()*free.length)],st=stationByKey[k];
|
||
if(!st || !Array.isArray(st.roam) || !st.roam.length || !Array.isArray(st.dock)) return;
|
||
const pt=st.roam[Math.floor(Math.random()*st.roam.length)];
|
||
walkPath(k,[st.dock,pt,st.dock,[st.seatX,st.seatY]],()=>setSprite(k,'sit'));
|
||
},5600);
|
||
function activate(role){Object.keys(chars).forEach(k=>chars[k].classList.toggle('active',k===role))}
|
||
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(),1600)}
|
||
// ── A. 상태 계층화 ──
|
||
// 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함.
|
||
// 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고,
|
||
// critical pivot(error/need_clarification/review_failed/approval/done 등)에서만
|
||
// 캐릭터 시뮬레이션 + 말풍선 강조. 사용자에게 정말 *알릴 만한 변화*에 시각
|
||
// 집중력 몰아주기.
|
||
const CRITICAL_STATUSES = new Set(['need_clarification','waiting_approval','error','done']);
|
||
const CRITICAL_BUBBLE_TYPES = new Set(['warning','error','success']);
|
||
const CRITICAL_TEXT_RE = /버그|오류|실패|승인|중단|완료|❌|🛑|✅|⚠️|보완|재작업|결재/;
|
||
function _isCriticalBubble(b){
|
||
if(!b) return false;
|
||
if(b.type && CRITICAL_BUBBLE_TYPES.has(b.type)) return true;
|
||
if(b.text && CRITICAL_TEXT_RE.test(b.text)) return true;
|
||
return false;
|
||
}
|
||
function routeBubble(b){
|
||
// 일반 status bubble은 무시 — 큐 적체 방지. 사용자에게 의미 있는 신호만 통과.
|
||
if(!_isCriticalBubble(b)) return;
|
||
const role = roleMap[b?.agentId] || 'ceo';
|
||
bubble(role, b?.text || '');
|
||
}
|
||
let _prevStatus = null;
|
||
function apply(s){
|
||
_lastState = s; // D. 컨텍스트 메뉴 / 세부보기에서 사용.
|
||
const st = s?.status || 'idle';
|
||
// 정적 갱신은 *항상* — 헤더/태스크/단계/로그/프로그레스.
|
||
document.getElementById('status').textContent = st;
|
||
document.getElementById('status').className = 'status s-' + st;
|
||
document.getElementById('agent').textContent = s?.agentName || 'Astra';
|
||
document.getElementById('task').textContent = s?.currentTask || '—';
|
||
document.getElementById('step').textContent = s?.currentStep || '—';
|
||
document.getElementById('log').textContent = (s?.recentLogs||[]).slice(-1)[0] || '—';
|
||
document.getElementById('bar').style.width = Math.round((s?.progress||0)*100) + '%';
|
||
// B. 미니 맵 렌더 — pipelineStages가 있을 때만 보임.
|
||
const mm = document.getElementById('miniMap');
|
||
const stages = s?.pipelineStages;
|
||
if(Array.isArray(stages) && stages.length > 0){
|
||
mm.style.display = 'flex';
|
||
mm.innerHTML = '';
|
||
const doneN = stages.filter(x=>x.status==='done').length;
|
||
stages.forEach((stg, i)=>{
|
||
const dot = document.createElement('div');
|
||
dot.className = 'mm-dot';
|
||
dot.dataset.status = stg.status || 'pending';
|
||
const label = document.createElement('span');
|
||
label.className = 'mm-label';
|
||
label.textContent = (i+1) + '. ' + (stg.label || '단계') + (stg.agent ? ' · ' + stg.agent : '');
|
||
dot.appendChild(label);
|
||
mm.appendChild(dot);
|
||
if(i < stages.length - 1){
|
||
const bar = document.createElement('div');
|
||
bar.className = 'mm-bar';
|
||
mm.appendChild(bar);
|
||
}
|
||
});
|
||
const counter = document.createElement('div');
|
||
counter.className = 'mm-counter';
|
||
counter.textContent = doneN + '/' + stages.length;
|
||
mm.appendChild(counter);
|
||
} else {
|
||
mm.style.display = 'none';
|
||
}
|
||
// 활성 캐릭터 결정. roleMap 은 agentKey → 실제 존재하는 station.key 로 lookup
|
||
// 하므로, 매핑된 책상이 없으면 null. 사용자가 default ceo 책상을 지워도 안전.
|
||
let role = null;
|
||
const m = (s?.message || '').match(/^([a-z0-9_-]+)/i);
|
||
if(m) role = roleMap[m[1]] || null;
|
||
if(['planning','analyzing','waiting_approval'].includes(st)) role = roleMap['ceo'] || role;
|
||
if(st === 'reviewing') role = roleMap['inspector'] || role;
|
||
// 활성 outline은 *항상* 즉시 반영 (시뮬레이션 없이도 누가 일하는지 보임).
|
||
activate(role);
|
||
// 시뮬레이션은 *status 전환 + critical 진입* 에서만.
|
||
const isPivot = CRITICAL_STATUSES.has(st);
|
||
const isTransition = st !== _prevStatus;
|
||
if(isTransition){
|
||
// 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
|
||
Object.keys(chars).forEach(k => {
|
||
if(k !== role && anim[k].mode === 'work') sendHome(k, 'sit');
|
||
});
|
||
// pivot 상태 진입은 walk + 말풍선 시뮬레이션 트리거.
|
||
if(isPivot && role){
|
||
sendHome(role, st === 'executing' || st === 'reviewing' ? 'work' : 'sit');
|
||
} else if(role){
|
||
// 일반 상태는 *정적 모드만* — 캐릭터를 그 자리에서 mode만 바꿈, 걷지 않음.
|
||
const ch = chars[role];
|
||
if(ch){
|
||
const cx = parseFloat(ch.style.left), cy = parseFloat(ch.style.top);
|
||
const st_ = stationByKey[role];
|
||
const home = st_ ? Math.abs(cx - st_.seatX) < 1 && Math.abs(cy - st_.seatY) < 1 : false;
|
||
if(home){
|
||
// 이미 자리에 있으면 mode만 work/sit으로 토글 (걷기 없음).
|
||
setSprite(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
|
||
} else {
|
||
// 자리를 벗어나 있으면 한 번만 복귀 — 그 이후 같은 status가 연속 와도 다시 보내지 않음.
|
||
sendHome(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
|
||
}
|
||
}
|
||
}
|
||
if(st === 'done'){
|
||
// 완료 — 모두 자리 복귀 후 단체 sit.
|
||
Object.keys(chars).forEach(k => sendHome(k, 'work'));
|
||
const ceoRole = roleMap['ceo']; if(ceoRole) bubble(ceoRole, '완료!');
|
||
setTimeout(() => Object.keys(chars).forEach(k => sendHome(k, 'sit')), 1800);
|
||
}
|
||
}
|
||
_prevStatus = st;
|
||
}
|
||
// D. 캐릭터 컨텍스트 메뉴 — 편집 모드 X일 때만. abort/세부보기/말풍선 정리.
|
||
let _lastState = null;
|
||
function _closeCtxMenu(){
|
||
document.querySelectorAll('.ctx-menu, .ctx-detail').forEach(el => el.remove());
|
||
}
|
||
document.addEventListener('click', e=>{
|
||
// 메뉴 외부 클릭 → 닫기
|
||
if(!e.target.closest('.ctx-menu') && !e.target.closest('.ctx-detail') && !e.target.closest('.char')){
|
||
_closeCtxMenu();
|
||
}
|
||
});
|
||
stage.addEventListener('click', e=>{
|
||
if(_editMode) return; // 편집 모드에선 드래그용 mousedown이 우선
|
||
const ch = e.target.closest('.char');
|
||
if(!ch) return;
|
||
const role = ch.dataset.role;
|
||
if(!role) return;
|
||
e.stopPropagation();
|
||
_closeCtxMenu();
|
||
const menu = document.createElement('div');
|
||
menu.className = 'ctx-menu';
|
||
menu.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
|
||
const head = document.createElement('div');
|
||
head.className = 'ctx-menu-head';
|
||
const st = stationByKey[role] || {label:role};
|
||
head.innerHTML = '<span class="cmh-role">'+ (st.label||role) +'</span><br><span style="color:#D7DBEA;font-size:10px;">' + role + '</span>';
|
||
menu.appendChild(head);
|
||
// 옵션
|
||
const addItem = (label, danger, onClick)=>{
|
||
const it = document.createElement('div');
|
||
it.className = 'ctx-menu-item' + (danger?' danger':'');
|
||
it.textContent = label;
|
||
it.onclick = (ev)=>{ ev.stopPropagation(); onClick(); _closeCtxMenu(); };
|
||
menu.appendChild(it);
|
||
};
|
||
addItem('📋 현재 작업 보기', false, ()=>{
|
||
_showDetail(role, _lastState);
|
||
});
|
||
addItem('💬 말풍선 보내기', false, ()=>{
|
||
const msg = window.prompt(role + '에게 보낼 메시지', '');
|
||
if(msg) bubble(role, msg);
|
||
});
|
||
const sep = document.createElement('div'); sep.className='ctx-menu-divider'; menu.appendChild(sep);
|
||
addItem('🛑 현재 작업 중단', true, ()=>{
|
||
try{ vscode.postMessage({type: 'pixelOfficeCommand', cmd: 'abort', role}); }catch{}
|
||
});
|
||
// 위치 — 클릭한 캐릭터 위
|
||
document.body.appendChild(menu);
|
||
const r = ch.getBoundingClientRect();
|
||
const mw = menu.offsetWidth, mh = menu.offsetHeight;
|
||
let mx = r.left + r.width/2 - mw/2, my = r.top - mh - 6;
|
||
if(my < 8) my = r.bottom + 6;
|
||
mx = Math.max(8, Math.min(window.innerWidth - mw - 8, mx));
|
||
menu.style.left = mx + 'px';
|
||
menu.style.top = my + 'px';
|
||
});
|
||
function _showDetail(role, state){
|
||
_closeCtxMenu();
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'ctx-detail';
|
||
const ch = chars[role];
|
||
if(ch) overlay.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
|
||
const st = stationByKey[role] || {label:role};
|
||
const close = document.createElement('button'); close.className='cd-close'; close.textContent='✕'; close.onclick=_closeCtxMenu;
|
||
overlay.appendChild(close);
|
||
const h = document.createElement('h3'); h.textContent = (st.label||role) + ' · ' + role;
|
||
overlay.appendChild(h);
|
||
const dl = document.createElement('dl');
|
||
const add = (k,v)=>{ if(v==null||v==='') return; const dt=document.createElement('dt');dt.textContent=k;const dd=document.createElement('dd');dd.textContent=v;dl.appendChild(dt);dl.appendChild(dd); };
|
||
add('상태', state?.status);
|
||
add('단계', state?.currentStep);
|
||
add('다음', state?.nextStep);
|
||
add('메모', state?.message);
|
||
if(state?.requirementContract){
|
||
const c = state.requirementContract;
|
||
add('목표', c.goal);
|
||
add('맥락', c.context);
|
||
add('형식', c.format);
|
||
if(Array.isArray(c.criteria) && c.criteria.length) add('기준', c.criteria.join(' · '));
|
||
if(Array.isArray(c.openQuestions) && c.openQuestions.length) add('미해결 질문', c.openQuestions.join(' / '));
|
||
}
|
||
overlay.appendChild(dl);
|
||
if(Array.isArray(state?.recentLogs) && state.recentLogs.length){
|
||
const logs = document.createElement('div'); logs.className='cd-logs';
|
||
logs.innerHTML = state.recentLogs.map(l=>'<div>'+ (l||'').replace(/[<>]/g,'') +'</div>').join('');
|
||
overlay.appendChild(logs);
|
||
}
|
||
document.body.appendChild(overlay);
|
||
const ow=overlay.offsetWidth, oh=overlay.offsetHeight;
|
||
overlay.style.left = Math.max(20, (window.innerWidth-ow)/2) + 'px';
|
||
overlay.style.top = Math.max(40, (window.innerHeight-oh)/2) + 'px';
|
||
}
|
||
|
||
// E. Activity ticker state — 최근 N개 행동만 ring buffer로. 너무 길어지면
|
||
// 자동으로 가장 오래된 것 drop.
|
||
const _tickerItems = [];
|
||
const TICKER_MAX = 12;
|
||
function _classifyTickerItem(text){
|
||
if(/❌|fail|error|⚠️/i.test(text)) return 'tk-err';
|
||
if(/✅|ok|created|edited|completed/i.test(text)) return 'tk-ok';
|
||
if(/⚠|warn|hollow/i.test(text)) return 'tk-warn';
|
||
return '';
|
||
}
|
||
function _renderTicker(){
|
||
const wrap = document.getElementById('ticker');
|
||
const track = document.getElementById('tickerTrack');
|
||
if(_tickerItems.length === 0){ wrap.style.display = 'none'; return; }
|
||
wrap.style.display = 'block';
|
||
// 무한 스크롤처럼 보이게 동일 리스트 2벌 연결.
|
||
const html = _tickerItems.map(it => {
|
||
const cls = _classifyTickerItem(it.text);
|
||
const ag = it.agentId ? '<span class="tk-agent">'+ it.agentId +'</span>' : '';
|
||
return '<span class="tk-item '+ cls +'">'+ ag + (it.text || '').replace(/[<>]/g,'') +'</span>';
|
||
}).join('');
|
||
track.innerHTML = html + html;
|
||
}
|
||
window.addEventListener('message',e=>{
|
||
const d = e.data;
|
||
if(!d) return;
|
||
if(d.type === 'pixelOfficeUpdate'){
|
||
const v = d.value;
|
||
if(!v) return;
|
||
if(v.state) apply(v.state);
|
||
if(Array.isArray(v.bubbles)) v.bubbles.forEach(routeBubble);
|
||
} else if(d.type === 'pixelOfficeActivity'){
|
||
const v = d.value;
|
||
if(!v || !Array.isArray(v.items)) return;
|
||
for(const it of v.items){
|
||
_tickerItems.push(it);
|
||
if(_tickerItems.length > TICKER_MAX) _tickerItems.shift();
|
||
}
|
||
_renderTicker();
|
||
}
|
||
});
|
||
try{vscode.postMessage({type:'getPixelOfficeState'})}catch{}
|
||
|
||
// ─────────────── Layout Editor ───────────────
|
||
// 사용자가 ✏️ 편집 버튼을 누르면 편집 모드 진입. 책상/캐릭터/소품을 드래그로
|
||
// 위치 조정 가능. 4px snap. 저장 시 백엔드 workspace state에 영구 저장. 다음
|
||
// 패널 오픈 때 자동 복원. 디폴트로 복귀 버튼도 제공.
|
||
let _editMode=false, _drag=null, _dragDX=0, _dragDY=0, _snapshotBeforeEdit=null, _selected=null;
|
||
|
||
function _snapshotLayout(){
|
||
// 새 schema v2: cells 가 단순 좌표 패치가 아니라 stations 전체 정의를 담는다.
|
||
// (책상 추가/제거가 가능하니 default 와의 delta 로는 표현 불가.) v1 호환을 위해
|
||
// _restoreLayout 이 양쪽 모두 처리.
|
||
return {
|
||
schema: 2,
|
||
cells: stations.map(st=>{
|
||
const ch = chars[st.key];
|
||
return {
|
||
roleKey: st.key,
|
||
agentKey: st.agentKey || '',
|
||
label: st.label || '',
|
||
charRow: st.charRow ?? 0,
|
||
deskSprite: st.deskSprite || 'desk-main',
|
||
face: st.face || 'R',
|
||
boss: !!st.boss,
|
||
noChar: !!st.noChar,
|
||
dock: st.dock,
|
||
roam: st.roam,
|
||
deskX: parseFloat(__deskWrap[st.key].style.left),
|
||
deskY: parseFloat(__deskWrap[st.key].style.top),
|
||
deskW: parseFloat(__deskWrap[st.key].style.width),
|
||
deskRot: parseFloat(__deskWrap[st.key].dataset.rot || '0'),
|
||
deskZ: parseFloat(__deskWrap[st.key].dataset.z || '0'),
|
||
seatX: ch ? parseFloat(ch.style.left) : (st.seatX ?? 0),
|
||
seatY: ch ? parseFloat(ch.style.top) : (st.seatY ?? 0),
|
||
charRot: ch ? parseFloat(ch.dataset.rot || '0') : 0,
|
||
charZ: ch ? parseFloat(ch.dataset.z || '0') : 0,
|
||
};
|
||
}),
|
||
objs: Array.from(stage.querySelectorAll('img.obj')).map(el=>({
|
||
id: el.dataset.objId,
|
||
name: el.dataset.objName,
|
||
x: parseFloat(el.style.left),
|
||
y: parseFloat(el.style.top),
|
||
w: el.dataset.objW ? parseFloat(el.dataset.objW) : undefined,
|
||
rot: parseFloat(el.dataset.rot || '0'),
|
||
z: parseFloat(el.dataset.z || '0'),
|
||
})),
|
||
};
|
||
}
|
||
|
||
// stage 에 그려진 모든 desk/char/obj DOM 제거 (벽 창문 제외). 레이아웃 리빌드 직전에 호출.
|
||
function _clearStage(){
|
||
Array.from(stage.querySelectorAll('.desk,.char,img.obj')).forEach(el=>el.remove());
|
||
Object.keys(__deskWrap).forEach(k=>delete __deskWrap[k]);
|
||
Object.keys(chars).forEach(k=>delete chars[k]);
|
||
Object.keys(anim).forEach(k=>delete anim[k]);
|
||
stations = [];
|
||
}
|
||
|
||
function _applyRot(el, rot){
|
||
if(!el) return;
|
||
const r = typeof rot==='number' ? rot : 0;
|
||
el.dataset.rot = String(r);
|
||
el.style.transform = r === 0 ? '' : ('rotate('+r+'deg)');
|
||
}
|
||
function _applyZ(el, z){
|
||
if(!el) return;
|
||
if(typeof z !== 'number') return;
|
||
el.dataset.z = String(z);
|
||
el.style.zIndex = z === 0 ? '' : String(z);
|
||
}
|
||
// v2 schema 또는 cell 에 desk 정의 필드가 있으면 stage 를 통째로 재구축.
|
||
// v1 (옛 포맷) 이면 좌표만 패치하는 in-place 갱신.
|
||
function _isV2Snap(snap){
|
||
if(!snap || !Array.isArray(snap.cells)) return false;
|
||
if(snap.schema === 2) return true;
|
||
return snap.cells.some(c => c && (typeof c.deskSprite === 'string' || typeof c.agentKey === 'string' || typeof c.charRow === 'number'));
|
||
}
|
||
function _restoreLayout(snap){
|
||
if(!snap) return;
|
||
if(_isV2Snap(snap)){
|
||
_clearStage();
|
||
(snap.cells||[]).forEach(c=>{
|
||
const st = {
|
||
key: c.roleKey,
|
||
agentKey: c.agentKey || '',
|
||
label: c.label || c.roleKey,
|
||
charRow: typeof c.charRow === 'number' ? c.charRow : 0,
|
||
deskSprite: c.deskSprite || 'desk-main',
|
||
face: c.face || 'R',
|
||
boss: !!c.boss,
|
||
noChar: !!c.noChar,
|
||
dock: Array.isArray(c.dock) ? c.dock : [c.deskX+32, c.deskY+80],
|
||
roam: Array.isArray(c.roam) ? c.roam : [[c.deskX, c.deskY+120],[c.deskX+60, c.deskY+100]],
|
||
deskX: c.deskX, deskY: c.deskY, deskW: c.deskW || 112,
|
||
seatX: c.seatX, seatY: c.seatY,
|
||
};
|
||
stations.push(st);
|
||
const m = String(st.key).match(/^desk_(\\d+)$/);
|
||
if(m){ const n = parseInt(m[1],10); if(n >= __nextDeskN) __nextDeskN = n + 1; }
|
||
});
|
||
_rebuildStationIndex();
|
||
stations.forEach(buildStation);
|
||
(snap.cells||[]).forEach(c=>{
|
||
const wrap=__deskWrap[c.roleKey], ch=chars[c.roleKey];
|
||
if(wrap){ _applyRot(wrap, c.deskRot); _applyZ(wrap, c.deskZ); }
|
||
if(ch){ _applyRot(ch, c.charRot); _applyZ(ch, c.charZ); }
|
||
});
|
||
(snap.objs||[]).forEach(o=>{
|
||
const el = addImg(o.name, o.x, o.y, o.w);
|
||
if(o.id){ el.dataset.objId = o.id; }
|
||
_applyRot(el, o.rot); _applyZ(el, o.z);
|
||
});
|
||
return;
|
||
}
|
||
// v1 — 옛 포맷, in-place patch.
|
||
(snap.cells||[]).forEach(c=>{
|
||
const wrap=__deskWrap[c.roleKey];
|
||
if(wrap){
|
||
if(typeof c.deskX==='number') wrap.style.left=c.deskX+'px';
|
||
if(typeof c.deskY==='number') wrap.style.top=c.deskY+'px';
|
||
if(typeof c.deskW==='number') wrap.style.width=c.deskW+'px';
|
||
_applyRot(wrap, c.deskRot);
|
||
_applyZ(wrap, c.deskZ);
|
||
}
|
||
const ch=chars[c.roleKey];
|
||
if(ch){
|
||
if(typeof c.seatX==='number'){ch.style.left=c.seatX+'px';ch.dataset.homeX=c.seatX;}
|
||
if(typeof c.seatY==='number'){ch.style.top=c.seatY+'px';ch.dataset.homeY=c.seatY;}
|
||
_applyRot(ch, c.charRot);
|
||
_applyZ(ch, c.charZ);
|
||
}
|
||
const st=stationByKey[c.roleKey];
|
||
if(st){
|
||
if(typeof c.deskX==='number') st.deskX=c.deskX;
|
||
if(typeof c.deskY==='number') st.deskY=c.deskY;
|
||
if(typeof c.seatX==='number') st.seatX=c.seatX;
|
||
if(typeof c.seatY==='number') st.seatY=c.seatY;
|
||
}
|
||
});
|
||
(snap.objs||[]).forEach(o=>{
|
||
let el=null;
|
||
if(o.id) el=stage.querySelector('img.obj[data-obj-id="'+o.id+'"]');
|
||
if(!el && o.name){
|
||
// id 없으면 name으로 첫번째 매칭 — legacy 데이터 호환.
|
||
el=stage.querySelector('img.obj[data-obj-name="'+o.name+'"]');
|
||
}
|
||
if(!el) return;
|
||
if(typeof o.x==='number') el.style.left=o.x+'px';
|
||
if(typeof o.y==='number') el.style.top=o.y+'px';
|
||
if(typeof o.w==='number'){ el.style.width=o.w+'px'; el.dataset.objW=String(o.w); }
|
||
_applyRot(el, o.rot);
|
||
_applyZ(el, o.z);
|
||
});
|
||
}
|
||
|
||
function _setEdit(on){
|
||
_editMode=!!on;
|
||
document.body.dataset.editMode = _editMode?'true':'false';
|
||
document.getElementById('editToolbar').style.display = _editMode?'flex':'none';
|
||
document.getElementById('editBtn').textContent = _editMode?'✓ 편집 종료':'✏️ 편집';
|
||
if(_editMode){
|
||
_snapshotBeforeEdit = _snapshotLayout();
|
||
} else {
|
||
_snapshotBeforeEdit = null;
|
||
// 편집 종료 시 selected 강조 해제.
|
||
if(_selected){ _selected.classList.remove('selected'); _selected = null; }
|
||
}
|
||
_onSelectionChanged();
|
||
}
|
||
|
||
// ── 선택 변화 시 호출 — 속성 패널 / 삭제 버튼 상태 sync ──
|
||
function _onSelectionChanged(){
|
||
const panel = document.getElementById('propPanel');
|
||
const delBtn = document.getElementById('deleteSelBtn');
|
||
if(!_editMode || !_selected){
|
||
panel.classList.remove('show'); panel.innerHTML='';
|
||
if(delBtn) delBtn.disabled = true;
|
||
return;
|
||
}
|
||
// 캐릭터를 선택해도 그 책상의 속성을 편집한다 (캐릭터와 책상은 한 쌍).
|
||
let targetForProps = _selected;
|
||
if(targetForProps.classList.contains('char')){
|
||
const role = Object.keys(chars).find(k=>chars[k]===targetForProps);
|
||
if(role && __deskWrap[role]) targetForProps = __deskWrap[role];
|
||
}
|
||
if(targetForProps.classList.contains('desk')){
|
||
if(delBtn) delBtn.disabled = false;
|
||
_renderDeskProps(targetForProps);
|
||
} else if(_selected.classList.contains('obj')){
|
||
if(delBtn) delBtn.disabled = false;
|
||
_renderObjProps(_selected);
|
||
} else {
|
||
panel.classList.remove('show'); panel.innerHTML='';
|
||
if(delBtn) delBtn.disabled = true;
|
||
}
|
||
}
|
||
|
||
function _renderDeskProps(deskEl){
|
||
const role = deskEl.dataset.role;
|
||
const st = stationByKey[role]; if(!st) return;
|
||
const panel = document.getElementById('propPanel');
|
||
panel.classList.add('show');
|
||
// 에이전트 매핑 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('');
|
||
// charRow 썸네일 picker (idle-r<n>-f0.png).
|
||
let thumbs='';
|
||
for(let r=0;r<8;r++){
|
||
thumbs += '<div class="pp-thumb'+(r===st.charRow?' active':'')+'" data-charrow="'+r+'" title="row '+r+'"><img src="'+png('idle-r'+r+'-f0')+'"></div>';
|
||
}
|
||
const hasChar = !!chars[role];
|
||
panel.innerHTML =
|
||
'<h4>책상 속성</h4>'+
|
||
'<div class="pp-row"><label>라벨</label><input id="ppLabel" value="'+(st.label||'').replace(/"/g,'"')+'"></div>'+
|
||
'<div class="pp-row"><label>에이전트 매핑</label><select id="ppAgent">'+agentOpts+'</select></div>'+
|
||
'<div class="pp-row"><label>책상 sprite</label><select id="ppDesk">'+deskOpts+'</select></div>'+
|
||
(hasChar ? '' : '<div class="pp-row"><button id="ppAddChar" style="width:100%;padding:6px;background:rgba(16,185,129,.22);border:1px solid rgba(16,185,129,.55);color:#F1F4FB;border-radius:4px;cursor:pointer;font-size:11px">+ 이 책상에 캐릭터 추가</button></div>')+
|
||
'<div class="pp-row"><label>착석 캐릭터 (row 0~7)</label><div class="pp-thumbs" id="ppThumbs">'+thumbs+'</div></div>'+
|
||
'<div class="pp-row"><label>방향 (앉은 face)</label><select id="ppFace">'+
|
||
'<option value="L"'+(st.face==='L'?' selected':'')+'>← Left</option>'+
|
||
'<option value="R"'+(st.face==='R'?' selected':'')+'>Right →</option>'+
|
||
'<option value="U"'+(st.face==='U'?' selected':'')+'>↑ Up (뒷모습)</option>'+
|
||
'<option value="D"'+(st.face==='D'?' selected':'')+'>↓ Down (정면)</option>'+
|
||
'</select></div>';
|
||
// 핸들러
|
||
panel.querySelector('#ppLabel').oninput = (ev)=>{
|
||
st.label = ev.target.value;
|
||
const lbl = deskEl.querySelector('.label'); if(lbl) lbl.textContent = st.label;
|
||
};
|
||
panel.querySelector('#ppAgent').onchange = (ev)=>{
|
||
st.agentKey = ev.target.value || '';
|
||
// CSS data-role 색깔은 agentKey 기준 — 매핑 변경 시 swap.
|
||
if(st.agentKey){ deskEl.dataset.agent = st.agentKey; if(chars[role]) chars[role].dataset.agent = st.agentKey; }
|
||
else { delete deskEl.dataset.agent; if(chars[role]) delete chars[role].dataset.agent; }
|
||
};
|
||
panel.querySelector('#ppDesk').onchange = (ev)=>{
|
||
st.deskSprite = ev.target.value;
|
||
const img = deskEl.querySelector('img'); if(img) img.src = png(st.deskSprite);
|
||
};
|
||
panel.querySelectorAll('.pp-thumb').forEach(t=>{
|
||
t.onclick = ()=>{
|
||
const r = parseInt(t.dataset.charrow,10);
|
||
st.charRow = r;
|
||
if(anim[role]){ anim[role].row = r; }
|
||
const ch = chars[role]; if(ch){ ch.dataset.row = r; const img=ch.querySelector('img'); if(img) img.src = png('idle-r'+r+'-f0'); }
|
||
panel.querySelectorAll('.pp-thumb').forEach(x=>x.classList.toggle('active', x===t));
|
||
};
|
||
});
|
||
panel.querySelector('#ppFace').onchange = (ev)=>{
|
||
st.face = ev.target.value;
|
||
if(anim[role]) anim[role].face = st.face;
|
||
// 현재 mode 기준 sprite 즉시 다시 그리기 — U/D 도 한 번에 반영.
|
||
const a = anim[role]; if(a) setSprite(role, a.mode || 'sit', 0, 0);
|
||
};
|
||
const addCharBtn = panel.querySelector('#ppAddChar');
|
||
if(addCharBtn){
|
||
addCharBtn.onclick = ()=>{
|
||
st.noChar = false;
|
||
addChar(st);
|
||
_renderDeskProps(deskEl); // 패널 재렌더 — "캐릭터 추가" 버튼 제거.
|
||
};
|
||
}
|
||
}
|
||
|
||
function _renderObjProps(el){
|
||
const panel = document.getElementById('propPanel');
|
||
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('');
|
||
panel.innerHTML =
|
||
'<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)=>{
|
||
el.dataset.objName = ev.target.value;
|
||
el.src = png(ev.target.value);
|
||
};
|
||
panel.querySelector('#ppObjW').oninput = (ev)=>{
|
||
const v = ev.target.value.trim();
|
||
if(v === ''){ el.style.removeProperty('width'); delete el.dataset.objW; }
|
||
else { el.style.width = parseFloat(v)+'px'; el.dataset.objW = String(parseFloat(v)); }
|
||
};
|
||
}
|
||
|
||
// ── 신규 책상 추가 ──
|
||
function _addNewDesk(){
|
||
const id = 'desk_'+(__nextDeskN++);
|
||
// 기본 위치: stage 중앙 근처, 다른 책상과 안 겹치게 살짝 오프셋.
|
||
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',
|
||
boss: false,
|
||
deskX: baseX, deskY: baseY, deskW: 112,
|
||
seatX: baseX+4, seatY: baseY+36,
|
||
dock: [baseX+32, baseY+80],
|
||
roam: [[baseX-20, baseY+120],[baseX+60, baseY+100]],
|
||
};
|
||
stations.push(st); _rebuildStationIndex();
|
||
buildStation(st);
|
||
// 새 책상을 자동 선택.
|
||
if(_selected) _selected.classList.remove('selected');
|
||
_selected = __deskWrap[id]; _selected.classList.add('selected');
|
||
_onSelectionChanged();
|
||
}
|
||
|
||
// ── 신규 프랍 추가 — sprite picker 모달 ──
|
||
function _openPropPicker(){
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'prop-picker';
|
||
const box = document.createElement('div');
|
||
box.className = 'prop-picker-box';
|
||
box.innerHTML = '<h3>프랍 추가 — sprite 선택</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('')+
|
||
'</div>';
|
||
overlay.appendChild(box);
|
||
overlay.onclick = (e)=>{ if(e.target === overlay) overlay.remove(); };
|
||
box.querySelectorAll('.prop-pick').forEach(p=>{
|
||
p.onclick = ()=>{
|
||
const name = p.dataset.name;
|
||
// stage 중앙 근처에 배치.
|
||
const x = 300 + ((__nextObjN%4)*12);
|
||
const y = 280 + ((__nextObjN%4)*12);
|
||
const el = addImg(name, x, y);
|
||
overlay.remove();
|
||
// 자동 선택.
|
||
if(_selected) _selected.classList.remove('selected');
|
||
_selected = el; _selected.classList.add('selected');
|
||
_onSelectionChanged();
|
||
};
|
||
});
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
// ── 선택 항목 삭제 ──
|
||
// 캐릭터 / 책상 / 프랍 각각 분리해서 처리:
|
||
// · 캐릭터 선택 → 캐릭터만 삭제 (책상은 유지). 속성 패널에서 "+ 캐릭터" 로 재추가 가능.
|
||
// · 책상 선택 → 책상 + 캐릭터 모두 삭제 (station 자체 제거).
|
||
// · 프랍 선택 → 프랍 삭제.
|
||
function _deleteSelected(){
|
||
if(!_editMode || !_selected) return;
|
||
if(_selected.classList.contains('char')){
|
||
const role = Object.keys(chars).find(k=>chars[k]===_selected);
|
||
if(!role) return;
|
||
const st = stationByKey[role];
|
||
if(!confirm('"'+(st?.label||role)+'" 책상의 캐릭터를 삭제할까요? 책상은 그대로 남습니다.')) return;
|
||
_selected.remove();
|
||
delete chars[role]; delete anim[role];
|
||
if(st) st.noChar = true;
|
||
_selected = null;
|
||
_onSelectionChanged();
|
||
return;
|
||
}
|
||
if(_selected.classList.contains('desk')){
|
||
const role = _selected.dataset.role;
|
||
if(!confirm('"'+(stationByKey[role]?.label||role)+'" 책상을 삭제할까요? 캐릭터도 함께 사라집니다.')) return;
|
||
_selected.remove();
|
||
if(chars[role]) chars[role].remove();
|
||
delete __deskWrap[role]; delete chars[role]; delete anim[role];
|
||
const idx = stations.findIndex(s=>s.key===role);
|
||
if(idx>=0) stations.splice(idx,1);
|
||
_rebuildStationIndex();
|
||
_selected = null;
|
||
_onSelectionChanged();
|
||
return;
|
||
}
|
||
if(_selected.classList.contains('obj')){
|
||
_selected.remove();
|
||
_selected = null;
|
||
_onSelectionChanged();
|
||
return;
|
||
}
|
||
}
|
||
|
||
function _findDraggable(el){
|
||
while(el && el !== stage){
|
||
if(el.classList){
|
||
if(el.classList.contains('obj')) return el;
|
||
if(el.classList.contains('char')) return el;
|
||
if(el.classList.contains('desk')) return el;
|
||
}
|
||
el = el.parentElement;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
stage.addEventListener('mousedown', e=>{
|
||
if(!_editMode) return;
|
||
const target = _findDraggable(e.target);
|
||
// 빈 공간 클릭 → 선택 해제.
|
||
if(!target){
|
||
if(_selected){ _selected.classList.remove('selected'); _selected=null; _onSelectionChanged(); }
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
// 선택 표시 갱신 — 한 번에 하나만 selected. 회전 키가 이 객체에 적용됨.
|
||
if(_selected && _selected !== target) _selected.classList.remove('selected');
|
||
_selected = target;
|
||
target.classList.add('selected');
|
||
_onSelectionChanged();
|
||
_drag = target;
|
||
const rect = stage.getBoundingClientRect();
|
||
const tx = parseFloat(target.style.left)||0;
|
||
const ty = parseFloat(target.style.top)||0;
|
||
_dragDX = e.clientX - rect.left - tx;
|
||
_dragDY = e.clientY - rect.top - ty;
|
||
target.classList.add('dragging');
|
||
});
|
||
|
||
// 키보드 — 편집 모드에서 선택된 객체에 적용.
|
||
// R : 시계방향 90도 회전
|
||
// Shift+R : 반시계방향 90도 회전
|
||
// ] : 레이어 위로 (z-index +1)
|
||
// [ : 레이어 아래로 (z-index -1)
|
||
function _bringLayer(el, delta){
|
||
const cur = parseFloat(el.dataset.z || '0');
|
||
const next = Math.max(-99, Math.min(99, cur + delta));
|
||
el.dataset.z = String(next);
|
||
el.style.zIndex = String(next);
|
||
}
|
||
document.addEventListener('keydown', e=>{
|
||
if(!_editMode || !_selected) return;
|
||
const tag = (e.target && e.target.tagName) || '';
|
||
if(tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||
const key = e.key.toLowerCase();
|
||
if(key === 'r'){
|
||
e.preventDefault();
|
||
const delta = e.shiftKey ? -90 : 90;
|
||
const cur = parseFloat(_selected.dataset.rot || '0');
|
||
const next = ((cur + delta) % 360 + 360) % 360;
|
||
_selected.dataset.rot = String(next);
|
||
_selected.style.transform = next === 0 ? '' : ('rotate(' + next + 'deg)');
|
||
return;
|
||
}
|
||
if(key === ']'){ e.preventDefault(); _bringLayer(_selected, +1); return; }
|
||
if(key === '['){ e.preventDefault(); _bringLayer(_selected, -1); return; }
|
||
});
|
||
document.addEventListener('mousemove', e=>{
|
||
if(!_editMode || !_drag) return;
|
||
const rect = stage.getBoundingClientRect();
|
||
let x = e.clientX - rect.left - _dragDX;
|
||
let y = e.clientY - rect.top - _dragDY;
|
||
// 4px 격자 snap
|
||
x = Math.round(x/4)*4;
|
||
y = Math.round(y/4)*4;
|
||
// stage 경계 안에 가두기
|
||
const sw = stage.offsetWidth, sh = stage.offsetHeight;
|
||
const tw = _drag.offsetWidth || 0, th = _drag.offsetHeight || 0;
|
||
x = Math.max(0, Math.min(sw - tw, x));
|
||
y = Math.max(0, Math.min(sh - th, y));
|
||
_drag.style.left = x+'px';
|
||
_drag.style.top = y+'px';
|
||
});
|
||
document.addEventListener('mouseup', ()=>{
|
||
if(_drag){ _drag.classList.remove('dragging'); }
|
||
if(_editMode && _drag && _drag.classList){
|
||
if(_drag.classList.contains('desk')){
|
||
// 책상만 새 좌표 저장. 캐릭터는 *그대로 둔다* (분리).
|
||
const role = _drag.dataset.role;
|
||
const st = stationByKey[role];
|
||
if(st){
|
||
st.deskX = parseFloat(_drag.style.left);
|
||
st.deskY = parseFloat(_drag.style.top);
|
||
}
|
||
} else if(_drag.classList.contains('char')){
|
||
// 캐릭터를 옮긴 경우 — 그 자리를 새 home으로.
|
||
const role = Object.keys(chars).find(k=>chars[k]===_drag);
|
||
if(role){
|
||
const st = stationByKey[role];
|
||
const nx = parseFloat(_drag.style.left), ny = parseFloat(_drag.style.top);
|
||
if(st){ st.seatX = nx; st.seatY = ny; }
|
||
_drag.dataset.homeX = nx;
|
||
_drag.dataset.homeY = ny;
|
||
}
|
||
}
|
||
}
|
||
_drag = null;
|
||
});
|
||
|
||
document.getElementById('editBtn').addEventListener('click', ()=>{
|
||
_setEdit(!_editMode);
|
||
});
|
||
document.getElementById('layerUpBtn').addEventListener('click', ()=>{
|
||
if(_selected) _bringLayer(_selected, +1);
|
||
});
|
||
document.getElementById('layerDownBtn').addEventListener('click', ()=>{
|
||
if(_selected) _bringLayer(_selected, -1);
|
||
});
|
||
document.getElementById('saveBtn').addEventListener('click', ()=>{
|
||
const layout = _snapshotLayout();
|
||
try{ vscode.postMessage({type:'savePixelOfficeLayout', value: layout}); }catch{}
|
||
_setEdit(false);
|
||
});
|
||
document.getElementById('cancelBtn').addEventListener('click', ()=>{
|
||
// 편집 시작 시 찍어둔 스냅샷으로 되돌리기.
|
||
if(_snapshotBeforeEdit) _restoreLayout(_snapshotBeforeEdit);
|
||
_setEdit(false);
|
||
});
|
||
document.getElementById('resetBtn').addEventListener('click', ()=>{
|
||
if(!confirm('현재 배치를 버리고 기본 배치로 되돌릴까요?')) return;
|
||
try{ vscode.postMessage({type:'resetPixelOfficeLayout'}); }catch{}
|
||
});
|
||
document.getElementById('addDeskBtn').addEventListener('click', _addNewDesk);
|
||
document.getElementById('addPropBtn').addEventListener('click', _openPropPicker);
|
||
document.getElementById('deleteSelBtn').addEventListener('click', _deleteSelected);
|
||
// Delete / Backspace 키도 같은 동작 — 단 입력 필드 포커스 시 무시.
|
||
document.addEventListener('keydown', e=>{
|
||
if(!_editMode) return;
|
||
const tag = (e.target && e.target.tagName) || '';
|
||
if(tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||
if(e.key === 'Delete' || e.key === 'Backspace'){ e.preventDefault(); _deleteSelected(); }
|
||
});
|
||
|
||
// 저장된 layout 로드 요청 + 응답 처리.
|
||
window.addEventListener('message', e=>{
|
||
const d = e.data;
|
||
if(!d || typeof d !== 'object') return;
|
||
if(d.type === 'pixelOfficeLayoutLoaded'){
|
||
if(d.value) _restoreLayout(d.value);
|
||
}
|
||
if(d.type === 'pixelOfficeLayoutSaved'){
|
||
if(d.value && d.value.reset){
|
||
// 디폴트로 리셋된 경우 — 페이지를 재로딩해서 코드 기본값으로 복귀.
|
||
location.reload();
|
||
}
|
||
}
|
||
});
|
||
try{ vscode.postMessage({type:'getPixelOfficeLayout'}); }catch{}
|
||
|
||
})();</script></body></html>`;
|
||
}
|