v2.2.299~307: 아키텍처 수렴 + 채팅 정리 + 벤치마킹 강화 + Claude 구독 엔진
- v2.2.299 아키텍처 수렴: coreChat 통일(엔진 휴리스틱 3벌 제거), 기업 모드 검색 오케스트레이터 승격, lib/execUtil(실행 래퍼 6곳·Python 탐지 3벌 단일화), lib/kstSchedule(워처 4개 nowInKst 통합), estimateTokens 통합, 설정 접근 규칙 명문화 - v2.2.300 채팅 화면 정리: LiveReasoningFilter(스트리밍 중 <think>/Harmony 추론 토큰 단위 차단), 확신도·검토요청 footer 기본 숨김(계산·Reflection 은 유지) - v2.2.301 문맥·의도 이해: [답변 전 이해 원칙] 상시 주입, 워크플로우 의도 브리핑, Report QA 루프(규칙 레지스트리+실측치+회귀 게이트, 블로그_v3 개념 이식) - v2.2.302 /benchmark 비즈니스 렌즈(가격·수익·운영)+빌드 프롬프트 모드+QA 연계 - v2.2.303 handoff 모드(측정치 무손실 인수인계 문서)+/claude(Claude Code 터미널 위임) - v2.2.304 이식성: 지식 경로 두뇌-상대 규약(pickWikiDir 상대 해석), 이사 체크리스트 - v2.2.305 Claude 구독 엔진: claude: 프로바이더(CLI 위임, 모델 드롭다운 자동 노출, coreChat 지원 — 워크플로우·QA도 구독 모델 가능) - v2.2.306 Tone Guard: AI 상투어 금지 레지스트리(상담사 화법 실사례 8종+대조 예시) - v2.2.307 /benchmark 레이아웃 골격(sectionRoles 결정론 분석, 롤링 배너 즉답), 파트별 실패 격리, 합성 타임아웃 120→300초 검증: tsc 무오류 + jest 888 통과 + esbuild 정상 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
* 스케줄링은 stocksWatcher 와 동일한 단일 setTimeout 체인 패턴 (KST 고정).
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
|
||||
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
|
||||
@@ -22,25 +23,8 @@ let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
let _lastFiredYmd = ''; // 같은 날 중복 발송 방지 (타이머 드리프트 대비)
|
||||
|
||||
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
|
||||
const now = new Date();
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(now);
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
|
||||
// 'YYYY-MM-DD' → UTC midnight Date — getUTCDay 가 그 날짜의 요일 (0=일).
|
||||
const weekday = new Date(`${ymd}T00:00:00Z`).getUTCDay();
|
||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday };
|
||||
}
|
||||
|
||||
function briefingTime(): { hour: number; minute: number } {
|
||||
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30') || '09:30').trim();
|
||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!m) return { hour: 9, minute: 30 };
|
||||
return { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) };
|
||||
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30'), 9, 30);
|
||||
}
|
||||
|
||||
/** 다음 발송까지 ms — 평일 스킵 포함. */
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { registerSlashCommand, chunk, type Webview } from '../datacollect/slashRouter';
|
||||
|
||||
/**
|
||||
* /claude — 로컬에 설치된 Claude Code CLI 를 VS Code 통합 터미널에서 실행.
|
||||
*
|
||||
* 배경 (v2.2.303): 벤치마킹→개발 파이프라인의 마지막 구간. 로컬 소형 모델은
|
||||
* 분석·명세(핸드오프 문서)까지 담당하고, 실제 코드 생성은 클라우드 모델에
|
||||
* 위임한다. Anthropic 구독(Pro/Max) 인증은 API 키가 아니라 Claude Code 로그인
|
||||
* 방식이므로, Astra 가 API 를 직접 호출하는 대신 **사용자가 이미 로그인해 둔
|
||||
* `claude` CLI 를 실행**한다 — 추가 과금 없이 구독 한도 안에서 동작.
|
||||
*
|
||||
* 설계 (bridgeLauncher 의 터미널 패턴 재사용):
|
||||
* - 통합 터미널("Claude Code (Astra)")에서 실행 → 셸/OS 차이를 VS Code 가 흡수,
|
||||
* Claude Code 의 권한 승인·진행 상황을 사용자가 눈으로 보며 제어.
|
||||
* - cwd = 첫 워크스페이스 폴더 (Claude Code 가 그 프로젝트 컨텍스트로 동작).
|
||||
* - 지시문은 첫 인자로 전달 — 따옴표는 셸 호환을 위해 정규화.
|
||||
*/
|
||||
|
||||
const TERMINAL_NAME = 'Claude Code (Astra)';
|
||||
|
||||
/** 셸(zsh/bash/PowerShell) 공통으로 안전한 한 줄 인자로 정규화 (순수 — 테스트 대상). */
|
||||
export function buildClaudeCommand(instruction: string): string {
|
||||
const t = (instruction || '').trim();
|
||||
if (!t) return 'claude';
|
||||
// 큰따옴표로 감싸므로 내부의 "는 ', POSIX 확장 문자와 PowerShell 백틱은 공백으로 완화.
|
||||
// 지시문은 터미널에서 사용자가 이어서 수정할 수 있으니 완벽 이스케이프보다 안전 우선.
|
||||
const safe = t.replace(/"/g, "'").replace(/[`$\\]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return `claude "${safe}"`;
|
||||
}
|
||||
|
||||
async function runClaude(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
const instruction = (arg || '').trim();
|
||||
|
||||
const ws = vscode.workspace.workspaceFolders?.find(f => f.uri.scheme === 'file');
|
||||
const cwd = ws?.uri.fsPath;
|
||||
|
||||
// 기존 우리 터미널이 있으면 재사용하지 않고 정리 후 새로 연다 (죽은 셸 방지 — bridgeLauncher 동일).
|
||||
vscode.window.terminals.find(t => t.name === TERMINAL_NAME)?.dispose();
|
||||
const terminal = vscode.window.createTerminal({ name: TERMINAL_NAME, cwd });
|
||||
terminal.show(true);
|
||||
terminal.sendText(buildClaudeCommand(instruction));
|
||||
|
||||
chunk(view, [
|
||||
`🚀 **Claude Code 실행** — "${TERMINAL_NAME}" 터미널에서 진행됩니다.`,
|
||||
instruction ? `- 전달한 지시: ${instruction.slice(0, 200)}${instruction.length > 200 ? '…' : ''}` : '- 지시 없이 대화형으로 시작했습니다.',
|
||||
cwd ? `- 작업 폴더: \`${cwd}\`` : '- ⚠️ 열린 워크스페이스가 없어 홈 폴더에서 시작합니다.',
|
||||
'- 파일 수정·명령 실행 승인은 터미널에서 직접 하시면 됩니다 (구독 인증 그대로 사용).',
|
||||
'',
|
||||
'💡 `command not found: claude` 가 뜨면: 터미널에서 `npm install -g @anthropic-ai/claude-code` 설치 후 `claude` 를 실행해 로그인하세요.',
|
||||
'',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
registerSlashCommand({
|
||||
name: '/claude',
|
||||
description: 'Claude Code(클라우드·구독 인증)를 터미널로 실행 — 핸드오프 문서 기반 개발 위임. 예: /claude <파일경로> 읽고 명세대로 개발해줘',
|
||||
handler: runClaude,
|
||||
});
|
||||
@@ -161,6 +161,12 @@ export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;
|
||||
export interface DispatcherDeps {
|
||||
context: vscode.ExtensionContext;
|
||||
ai: IAIService;
|
||||
/**
|
||||
* [코어 수렴] 두뇌 컨텍스트 검색 thunk — 있으면 메인 RetrievalOrchestrator 의
|
||||
* 전체 검색 경로(임베딩 하이브리드·청크)를 사용한다. 없으면(텔레그램 등 경량
|
||||
* 환경) 기존 scopedBrainRetriever 로 폴백. 반환 포맷은 buildContextBlock 동일.
|
||||
*/
|
||||
retrieveBrainContext?: (query: string, scopeFolders: string[], limit: number) => Promise<string>;
|
||||
/** Default model to fall back to when an agent has no override. */
|
||||
defaultModel: string;
|
||||
/**
|
||||
@@ -667,10 +673,16 @@ async function _dispatchOne(
|
||||
// the whole brain so a missing mapping doesn't starve the
|
||||
// dispatcher.
|
||||
const scope = resolveScopeForAgent(agentId, brainRoot);
|
||||
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
|
||||
maxResults: brainFileLimit,
|
||||
});
|
||||
brainContext = buildContextBlock(retrieval);
|
||||
if (deps.retrieveBrainContext) {
|
||||
// 메인 오케스트레이터 경로 — 임베딩 하이브리드·청크·개선 사항을 그대로 수혜.
|
||||
brainContext = await deps.retrieveBrainContext(task, scope.folders, brainFileLimit);
|
||||
} else {
|
||||
// 경량 폴백 (텔레그램 등 오케스트레이터가 없는 소비자).
|
||||
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
|
||||
maxResults: brainFileLimit,
|
||||
});
|
||||
brainContext = buildContextBlock(retrieval);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { execShell } from '../../lib/execUtil';
|
||||
|
||||
/**
|
||||
* NotebookLM 로컬 백엔드(Datacollect Bridge) 원클릭 실행/종료.
|
||||
@@ -133,7 +130,7 @@ export async function stopNotebookLmBridge(): Promise<void> {
|
||||
await new Promise(r => setTimeout(r, 600)); // 터미널 dispose 가 프로세스를 정리할 시간
|
||||
if (await isBridgeAlive()) {
|
||||
try {
|
||||
await execAsync(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeout: 8000 });
|
||||
await execShell(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeoutMs: 8000 });
|
||||
} catch { /* 이미 죽었거나 권한 문제 — 아래 재확인으로 판정 */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,15 @@ import { promises as fsp } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { registerSlashCommand, chunk, type Webview } from './slashRouter';
|
||||
import { callLmSynthesis } from './llm';
|
||||
import { getConfig } from '../../config';
|
||||
import { logInfo } from '../../utils';
|
||||
import { runReportQaLoop } from '../../agents/reportQa';
|
||||
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
|
||||
import { collectSourceFiles } from './reviewFiles';
|
||||
import { buildReviewFilePrompt, buildReviewReducePrompt } from './prompts/reviewPrompt';
|
||||
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
|
||||
import { buildHandoffDoc } from './prompts/handoffDoc';
|
||||
import { buildLayoutSkeleton } from './prompts/layoutSkeleton';
|
||||
import {
|
||||
type YoutubeAnalysisMode,
|
||||
formatHms,
|
||||
@@ -49,28 +54,64 @@ import {
|
||||
|
||||
// ───────────────────────────── /benchmark ─────────────────────────────
|
||||
|
||||
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
/**
|
||||
* /benchmark 모드 → 실행할 합성 파트 목록.
|
||||
* - full(기본): 디자인 3파트 + 비즈니스 렌즈 (1~4)
|
||||
* - design: 기존 3파트만 (원본 복원 시방서)
|
||||
* - biz: 비즈니스 렌즈만 (빠름 — 포지셔닝·가격·운영)
|
||||
* - build: 풀코스 + 스텝별 빌드 프롬프트 (1~5)
|
||||
*/
|
||||
export type BenchmarkMode = 'full' | 'design' | 'biz' | 'build' | 'handoff';
|
||||
export const BENCHMARK_MODE_PARTS: Record<BenchmarkMode, ReadonlyArray<1 | 2 | 3 | 4 | 5>> = {
|
||||
full: [1, 2, 3, 4],
|
||||
design: [1, 2, 3],
|
||||
biz: [4],
|
||||
build: [1, 2, 3, 4, 5],
|
||||
handoff: [], // LLM 합성 없음 — 스캔 원시 데이터로 인수인계 문서를 결정론 생성
|
||||
};
|
||||
|
||||
/** /benchmark 인자 파싱 — 순수 함수 (테스트 대상). */
|
||||
export function parseBenchmarkArgs(arg: string): {
|
||||
url: string; depth?: number; pages?: number; mode: BenchmarkMode; userContent: string;
|
||||
} {
|
||||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||||
let url = '';
|
||||
let depthArg: number | undefined;
|
||||
let pagesArg: number | undefined;
|
||||
let depth: number | undefined;
|
||||
let pages: number | undefined;
|
||||
let mode: BenchmarkMode = 'full';
|
||||
const restParts: string[] = [];
|
||||
const BARE_MODES = new Set<BenchmarkMode>(['full', 'design', 'biz', 'build', 'handoff']);
|
||||
for (const t of tokens) {
|
||||
const m = /^(depth|pages)=(\d+)$/i.exec(t);
|
||||
const modeM = /^mode[:=](full|design|biz|build|handoff)$/i.exec(t);
|
||||
if (m) {
|
||||
if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
|
||||
else pagesArg = Number(m[2]);
|
||||
if (m[1].toLowerCase() === 'depth') depth = Number(m[2]);
|
||||
else pages = Number(m[2]);
|
||||
} else if (modeM) {
|
||||
mode = modeM[1].toLowerCase() as BenchmarkMode;
|
||||
} else if (BARE_MODES.has(t.toLowerCase() as BenchmarkMode)) {
|
||||
mode = t.toLowerCase() as BenchmarkMode;
|
||||
} else if (!url) {
|
||||
url = t;
|
||||
} else {
|
||||
restParts.push(t);
|
||||
}
|
||||
}
|
||||
return { url, depth, pages, mode, userContent: restParts.join(' ') };
|
||||
}
|
||||
|
||||
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
const { url, depth: depthArg, pages: pagesArg, mode, userContent } = parseBenchmarkArgs(arg);
|
||||
if (!url) {
|
||||
chunk(view, `사용법: \`/benchmark <url> [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
|
||||
chunk(view, [
|
||||
'사용법: `/benchmark <url> [mode] [depth=N] [pages=N] [보조 설명]`',
|
||||
'- mode: `full`(기본 — 디자인+비즈니스) · `design`(복원 시방서만) · `biz`(사업 분석만) · `build`(풀코스+빌드 프롬프트) · `handoff`(클라우드 개발용 인수인계 문서 — LLM 없이 즉시)',
|
||||
'- 가격·수익 분석이 목적이면 `depth=2` 로 pricing 페이지까지 크롤 권장',
|
||||
'예: `/benchmark https://interiorai.com build depth=2 pages=12 나는 부동산 쪽으로 변형하고 싶어`',
|
||||
'',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
const userContent = restParts.join(' ');
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const crawlDepth = depthArg ?? (cfg.get<number>('datacollectCrawlDepth', 1) ?? 1);
|
||||
@@ -126,23 +167,84 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
// ── handoff 모드: LLM 합성을 건너뛰고 스캔 원시 데이터로 인수인계 문서를 즉시 생성.
|
||||
// 소형 모델의 요약 병목을 통과시키지 않는다 (측정치 무손실 — handoffDoc.ts 참조).
|
||||
if (mode === 'handoff') {
|
||||
const handoffDoc = buildHandoffDoc(s, url, { crawlDepth, maxPages, userContent });
|
||||
chunk(view, `📦 **개발 인수인계 문서 생성** (LLM 미개입 — 측정치 원본)\n\n`);
|
||||
const hToday = new Date().toISOString().slice(0, 10);
|
||||
let hHost = url;
|
||||
try { hHost = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw */ }
|
||||
const hTitle = `웹벤치마크 핸드오프 ${hHost} ${hToday}`;
|
||||
const savedH = await persistWikiDoc(hTitle, handoffDoc, { docType: 'benchmark-handoff', sources: [url] });
|
||||
chunk(view, describeSave(savedH, '인수인계 문서'));
|
||||
if (savedH.ok && savedH.path) {
|
||||
chunk(view, [
|
||||
'',
|
||||
'**다음 단계 — 클라우드 모델로 개발:**',
|
||||
`1. 파일을 열어 §1 "내 요구사항" 을 채우세요 (무엇으로 변형할지).`,
|
||||
`2. Claude Code(구독 인증)로 바로 개발: \`/claude ${savedH.path} 파일을 읽고 명세대로 개발해줘\``,
|
||||
` (또는 Antigravity 에 파일 내용을 붙여넣어도 됩니다)`,
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let finalReport: string;
|
||||
if (looksEmpty) {
|
||||
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
|
||||
finalReport = rawReport;
|
||||
} else {
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
|
||||
const partList = BENCHMARK_MODE_PARTS[mode];
|
||||
const PART_LABELS: Record<number, string> = {
|
||||
1: '4-렌즈 디자인', 2: 'IA·토큰', 3: '재구축 명세', 4: '비즈니스 렌즈', 5: '빌드 프롬프트',
|
||||
};
|
||||
chunk(view, `🧪 **LLM 합성** (mode \`${mode}\` · ${partList.length}단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
|
||||
try {
|
||||
const parts: string[] = [];
|
||||
for (const part of [1, 2, 3] as const) {
|
||||
chunk(view, `\n · 합성 ${part}/3 진행 중…`);
|
||||
let okParts = 0;
|
||||
for (let i = 0; i < partList.length; i++) {
|
||||
const part = partList[i];
|
||||
chunk(view, `\n · ${i + 1}/${partList.length} ${PART_LABELS[part]} 합성 중…`);
|
||||
const partT0 = Date.now();
|
||||
const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
|
||||
if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
|
||||
// [v2.2.307] 파트별 실패 격리 — 한 파트가 죽어도 완성된 파트를 버리지 않는다
|
||||
// (실사례: 파트 2 타임아웃으로 105초 걸린 파트 1까지 폐기). 타임아웃도 300초로 —
|
||||
// 입력 JSON 이 큰 파트는 e4b 급에서 120초를 넘긴다.
|
||||
let out: string;
|
||||
try {
|
||||
out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part), undefined, { timeoutMs: 300_000 });
|
||||
} catch (partErr: any) {
|
||||
chunk(view, ` ✗ (${Math.round((Date.now() - partT0) / 1000)}s — ${partErr?.message || partErr})`);
|
||||
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: ${partErr?.message || String(partErr)} — 나머지 파트는 계속 진행됨.`);
|
||||
continue;
|
||||
}
|
||||
if (!out) {
|
||||
chunk(view, ` ✗ (빈 응답)`);
|
||||
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: 빈 응답 — 나머지 파트는 계속 진행됨.`);
|
||||
continue;
|
||||
}
|
||||
okParts++;
|
||||
// [Report QA] 비즈니스 렌즈는 날조 위험(임의 가격·매출)이 가장 큰 파트 —
|
||||
// 채점→피드백 재작성→회귀 게이트를 통과시킨다. prompt 로 합성 프롬프트
|
||||
// 자체(스캔 JSON 포함)를 넘겨 재작성이 원본 근거를 보게 한다.
|
||||
if (part === 4 && getConfig().reportQaEnabled) {
|
||||
chunk(view, ` (품질 검증 중…)`);
|
||||
const qa = await runReportQaLoop({
|
||||
draft: out,
|
||||
prompt: buildSynthesisPrompt(s, userContent, 4),
|
||||
intentBrief: null,
|
||||
model,
|
||||
onProgress: (msg: string) => chunk(view, ` · ${msg}`),
|
||||
});
|
||||
out = qa.finalDraft;
|
||||
logInfo('[Benchmark] 비즈니스 렌즈 QA 완료.', { score: qa.score, revisions: qa.revisions });
|
||||
}
|
||||
parts.push(out);
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
|
||||
}
|
||||
if (okParts === 0) throw new Error('모든 합성 파트가 실패했습니다.');
|
||||
finalReport = parts.join('\n\n---\n\n');
|
||||
chunk(view, `\n\n`);
|
||||
} catch (e: any) {
|
||||
@@ -151,6 +253,10 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
|
||||
}
|
||||
}
|
||||
|
||||
// [v2.2.307] 레이아웃 골격 (결정론) — 합성 성공/실패와 무관하게 항상 포함.
|
||||
// "배너/캐러셀이 있는가, 각 페이지가 어떤 블록 순서로 구성되는가"에 즉답.
|
||||
finalReport = `${buildLayoutSkeleton(s)}\n\n---\n\n${finalReport}`;
|
||||
|
||||
chunk(view, finalReport + '\n\n');
|
||||
|
||||
try {
|
||||
@@ -1129,7 +1235,7 @@ async function reduceReviewNotes(
|
||||
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
|
||||
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
|
||||
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
|
||||
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
|
||||
registerSlashCommand({ name: '/benchmark', description: '웹 벤치마크 — 디자인 4-렌즈 + 비즈니스 렌즈(가격·수익·운영) 분석, build 모드는 스텝별 빌드 프롬프트까지', handler: runBenchmark });
|
||||
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
|
||||
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
|
||||
registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
|
||||
|
||||
@@ -21,6 +21,10 @@ import * as vscode from 'vscode';
|
||||
* LM 서버(OpenAI 호환 `/v1/chat/completions`)를 확장에서 직접 호출. LM Studio/Ollama 는
|
||||
* 인증이 없으므로 토큰 불필요. 타임아웃 가드 포함, 비정상 응답이면 throw.
|
||||
*/
|
||||
// [코어 수렴 예외 — 의도적] 이 모듈은 core/services.coreChat 으로 수렴하지 않는다:
|
||||
// finish_reason 기반 이어쓰기(continuation), repeat_penalty/top_k 커스텀 샘플링,
|
||||
// degeneration 감지·재시도 등 '장문 생성 엔진' 요구가 코어 채팅 API 범위를 벗어난다.
|
||||
// 단순 단발 완성이 필요한 새 코드는 이 파일이 아니라 coreChat 을 사용할 것.
|
||||
async function lmChat(lmUrl: string, payload: unknown, timeoutMs = 120_000): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
@@ -70,6 +74,9 @@ interface LmOpts {
|
||||
/** 출력이 length(토큰 상한)로 잘렸을 때 끊긴 지점부터 이어쓰기 최대 횟수(기본 3). 0이면 이어쓰기 안 함.
|
||||
* 회의록 "누락" 의 실제 원인(출력 잘림)을 보완 — 잘린 부분을 이어붙여 완결성을 확보. */
|
||||
maxContinuations?: number;
|
||||
/** 호출당 타임아웃(ms). 기본 120초 — /benchmark 4파트 합성처럼 입력 JSON 이 큰
|
||||
* 호출은 e4b 급 모델에서 120초를 넘길 수 있어 (실사례: 파트 2 abort) 크게 지정. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** 단발 호출 결과 — 이어쓰기 판단을 위해 finish_reason 을 함께 반환. */
|
||||
@@ -82,6 +89,7 @@ async function callLmOnce(
|
||||
lmUrl: string, model: string, sys: string, prompt: string,
|
||||
sampling: { temperature: number; repeat_penalty: number; top_k: number },
|
||||
maxTokens?: number,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<LmResult> {
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -96,7 +104,7 @@ async function callLmOnce(
|
||||
};
|
||||
// max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해.
|
||||
if (maxTokens && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
const res = await lmChat(lmUrl, payload, 120_000);
|
||||
const res = await lmChat(lmUrl, payload, timeoutMs);
|
||||
const choice = res?.choices?.[0];
|
||||
const content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? '';
|
||||
const finish = String(choice?.finish_reason ?? choice?.native_finish_reason ?? '').toLowerCase();
|
||||
@@ -146,7 +154,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
|
||||
top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10
|
||||
};
|
||||
try {
|
||||
const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens);
|
||||
const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens, opts?.timeoutMs);
|
||||
let out = first.text;
|
||||
if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; }
|
||||
if (looksDegenerate(out)) {
|
||||
@@ -159,7 +167,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
|
||||
for (let c = 0; c < maxCont && finish === 'length'; c++) {
|
||||
let next: LmResult;
|
||||
try {
|
||||
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens);
|
||||
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens, opts?.timeoutMs);
|
||||
} catch { break; } // 이어쓰기 호출 실패 시 지금까지분으로 마감
|
||||
if (!next.text || looksDegenerate(next.text)) break; // 이어쓰기가 붕괴하면 중단
|
||||
out = `${out}\n${next.text}`.trim();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* /benchmark handoff — 클라우드 모델(Claude Code·Antigravity 등) 개발 인수인계 문서.
|
||||
*
|
||||
* 설계 원칙 (v2.2.303): 로컬 소형 모델의 *작문*을 통과시키지 않는다.
|
||||
* 4b 모델이 큰 모델에게 줄 지시문을 요약하게 하면 스캔이 정확히 측정한 값
|
||||
* (rgb·px·카피 원문)이 "적절한 색상" 같은 모호어로 퇴화한다. 클라우드 모델은
|
||||
* 원시 구조화 데이터를 그대로 소화하는 걸 가장 잘하므로, 이 문서는 스캔 JSON 을
|
||||
* 결정론적으로 추려 옮기는 **순수 함수**다 — LLM 호출 0회, 손실 0.
|
||||
*
|
||||
* 산출물 구성: ①클라우드 모델용 지시문 ②내 요구사항 슬롯(사용자 변형 지시)
|
||||
* ③디자인 토큰 원본 ④마이크로카피 원문 ⑤IA/페이지 인벤토리 ⑥원시 JSON 부록.
|
||||
*/
|
||||
|
||||
const j = (v: unknown): string => JSON.stringify(v, null, 2);
|
||||
|
||||
/** 값이 있으면 "- **라벨**: 값" 한 줄, 없으면 빈 문자열. */
|
||||
function line(label: string, v: unknown): string {
|
||||
if (v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)) return '';
|
||||
const s = Array.isArray(v) ? v.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(', ') : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
||||
return `- **${label}**: ${s}`;
|
||||
}
|
||||
|
||||
function section(title: string, lines: string[]): string {
|
||||
const body = lines.filter(Boolean).join('\n');
|
||||
return body ? `### ${title}\n${body}` : `### ${title}\n- (스캔 데이터 없음)`;
|
||||
}
|
||||
|
||||
export function buildHandoffDoc(scan: any, url: string, opts: { crawlDepth: number; maxPages: number; userContent: string }): string {
|
||||
const s = scan || {};
|
||||
const colors = s.design?.colors || {};
|
||||
const typo = s.design?.typography || {};
|
||||
const layout = s.design?.layout || {};
|
||||
const inter = s.interactions || {};
|
||||
const micro = s.microcopy || {};
|
||||
const pages: any[] = s.sitemap?.pages || [];
|
||||
|
||||
const pageRows = pages.map((p: any) => {
|
||||
const forms = (p.formFields || []).slice(0, 4).map((f: any) => f.name || f.label || f.type).filter(Boolean).join('·') || '-';
|
||||
const ctas = (p.ctaSamples || []).slice(0, 3).join(' / ') || '-';
|
||||
return `| ${p.url || '-'} | ${p.role || '-'} | ${(p.h1 || p.title || '-').slice(0, 40)} | ${p.primaryContentType || '-'} | ${p.imageCount ?? '-'} | ${forms} | ${ctas} |`;
|
||||
}).join('\n');
|
||||
|
||||
const requirements = opts.userContent.trim()
|
||||
? `사용자가 지정한 변형 방향:\n> ${opts.userContent.trim()}\n\n위 방향에 맞춰 아래 원본 명세를 변형하되, 디자인 토큰·레이아웃 골격은 유지하라.`
|
||||
: [
|
||||
'아직 지정되지 않음 — 개발 시작 전에 아래를 채워 넣을 것:',
|
||||
'- [ ] 서비스 이름 / 도메인:',
|
||||
'- [ ] 헤드라인을 무엇으로 바꿀지 (원본의 포지셔닝 공식은 유지):',
|
||||
'- [ ] 콘텐츠·이미지는 무엇으로 대체할지:',
|
||||
'- [ ] 뺄 기능 (결제·로그인 등 MVP 에서 제외할 것):',
|
||||
'- [ ] 핵심 생성/처리 엔진 (사용할 API 또는 로컬 모델):',
|
||||
].join('\n');
|
||||
|
||||
return `# 개발 인수인계 — ${s.meta?.title || url}
|
||||
|
||||
> 이 문서는 Playwright 스캔이 **측정한 원시 값**을 그대로 옮긴 개발 명세입니다.
|
||||
> 생성: Astra /benchmark handoff · 원본 ${url} · 스캔 ${s.sitemap?.totalPages ?? 1}페이지 (depth ${opts.crawlDepth})
|
||||
|
||||
## 0. 클라우드 모델용 지시문 (이 문서를 받은 AI 에게)
|
||||
|
||||
너는 아래 명세를 근거로 원본 사이트와 매우 유사한 사이트를 처음부터 구현한다.
|
||||
1. **모든 색상·폰트·여백·radius 값은 이 문서의 측정치를 그대로 사용하라** — "비슷한 색", "모던한 폰트" 같은 임의 대체 금지.
|
||||
2. §1 "내 요구사항"의 변경 지시가 원본 명세와 충돌하면 요구사항이 우선한다. 그 외에는 원본 재현이 기본.
|
||||
3. 페이지 구조는 §4 인벤토리의 역할(role)·섹션 순서를 따른다.
|
||||
4. 기술 스택은 특별한 지시가 없으면 정적 웹 + 최소 백엔드(Next.js 또는 Node/Express)로 단순하게.
|
||||
5. 명세에 없는 정보(백엔드 로직·CMS·결제 연동)는 추측하지 말고 구현 전에 질문하라.
|
||||
|
||||
## 1. 내 요구사항 (변형 지시)
|
||||
|
||||
${requirements}
|
||||
|
||||
## 2. 디자인 토큰 (측정 원본)
|
||||
|
||||
${section('컬러', [
|
||||
line('팔레트 (사용 빈도순)', (colors.palette || []).slice(0, 8).map((p: any) => `${p.value}(×${p.count})`)),
|
||||
line('배경', colors.background),
|
||||
line('본문 텍스트', colors.primaryText),
|
||||
line('링크', colors.linkColor),
|
||||
line('버튼 배경', colors.buttonBackground),
|
||||
line('버튼 텍스트', colors.buttonText),
|
||||
line('구성 비율', colors.composition?.ratioLabel),
|
||||
line('다크모드 신호', colors.darkModeHints),
|
||||
])}
|
||||
|
||||
${section('타이포그래피', [
|
||||
line('주 폰트', typo.primaryFont),
|
||||
line('폰트 스택', (typo.fontStack || []).slice(0, 3)),
|
||||
line('h1', typo.h1),
|
||||
line('h2', typo.h2),
|
||||
line('h3', typo.h3),
|
||||
line('본문', typo.body),
|
||||
line('버튼', typo.button),
|
||||
line('상위 크기', (typo.topFontSizes || []).slice(0, 6)),
|
||||
line('상위 굵기', (typo.topFontWeights || []).slice(0, 5)),
|
||||
])}
|
||||
|
||||
${section('레이아웃 & 여백', [
|
||||
line('본문 최대 폭', layout.bodyMaxWidth),
|
||||
line('섹션 간 여백', layout.sectionSpacing),
|
||||
line('카드 간격', layout.cardSpacing),
|
||||
line('radius 스케일', layout.borderRadiusScale),
|
||||
line('그리드', layout.grids),
|
||||
line('컨테이너', layout.containerSystem),
|
||||
line('반응형 신호', layout.responsiveHints),
|
||||
])}
|
||||
|
||||
${section('인터랙션', [
|
||||
line('hover 규칙', (inter.hoverRules || []).slice(0, 6)),
|
||||
line('focus 규칙', (inter.focusRules || []).slice(0, 3)),
|
||||
line('transition 분포', inter.transitionDistribution),
|
||||
line('CSS 변수', inter.cssVars),
|
||||
])}
|
||||
|
||||
## 3. 마이크로카피 원문 (톤앤매너의 근거 — 변형 시에도 이 공식·리듬 유지)
|
||||
|
||||
${[
|
||||
line('헤드라인', micro.headline),
|
||||
line('서브헤드라인', micro.subheadline || (micro.subheadlines || []).slice(0, 4)),
|
||||
line('CTA 샘플', (micro.ctaSamples || []).slice(0, 8)),
|
||||
line('placeholder', micro.placeholders),
|
||||
line('상태 메시지', micro.stateMessages),
|
||||
line('본문 샘플', typeof micro.bodySample === 'string' ? micro.bodySample.slice(0, 300) : micro.bodySample),
|
||||
].filter(Boolean).join('\n') || '- (스캔 데이터 없음)'}
|
||||
|
||||
## 4. 페이지 인벤토리 (IA)
|
||||
|
||||
\`\`\`
|
||||
${String(s.sitemap?.ascii || '(단일 페이지)').trimEnd()}
|
||||
\`\`\`
|
||||
|
||||
| URL | 역할 | 제목/H1 | 콘텐츠 유형 | 이미지 수 | 폼 필드 | CTA |
|
||||
|---|---|---|---|---|---|---|
|
||||
${pageRows || '| (단일 페이지 스캔) | - | - | - | - | - | - |'}
|
||||
|
||||
## 5. 원시 스캔 데이터 부록 (기계 판독용 — 위 요약에 없는 세부값은 여기서 인용)
|
||||
|
||||
\`\`\`json
|
||||
${j({
|
||||
meta: s.meta,
|
||||
design: s.design,
|
||||
interactions: s.interactions,
|
||||
microcopy: s.microcopy,
|
||||
structure: s.structure,
|
||||
}).slice(0, 60_000)}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 레이아웃 골격 리포트 — 스캔 데이터에서 **결정론으로** 페이지별 위→아래 구성을 그린다.
|
||||
*
|
||||
* 배경 (v2.2.307): /benchmark 결과가 "색·폰트" 수준에 머물고, LLM 합성이 죽으면
|
||||
* 레이아웃 정보가 아예 사라졌다 (실사례: koritips 스캔 — 파트 2 타임아웃 → 원시
|
||||
* 요약만 남음). 골격(어떤 섹션이 어떤 순서로, 배너/캐러셀/폼 위젯이 어디 있는지)은
|
||||
* 스캔의 sectionRoles(태그 + class/id 힌트 + 미리보기)에서 LLM 없이 뽑을 수 있다.
|
||||
* → 이 모듈이 항상 보고서에 포함되므로 합성이 실패해도 골격 분석은 남는다.
|
||||
*/
|
||||
|
||||
/** class/id 힌트 → 위젯 종류 추정 (단일 권위 — 새 신호는 여기만 추가). */
|
||||
export const WIDGET_SIGNALS: ReadonlyArray<{ re: RegExp; label: string }> = [
|
||||
{ re: /carousel|swiper|slick|slider|slideshow|glide|splide/i, label: '🎠 캐러셀/롤링 배너' },
|
||||
{ re: /hero|jumbotron|masthead|banner|billboard/i, label: '🖼️ 히어로/배너' },
|
||||
{ re: /nav|menu|gnb|header/i, label: '🧭 내비게이션/헤더' },
|
||||
{ re: /footer/i, label: '🦶 푸터' },
|
||||
{ re: /sidebar|aside|widget-area/i, label: '📌 사이드바' },
|
||||
{ re: /search/i, label: '🔍 검색' },
|
||||
{ re: /subscribe|newsletter|cta|signup/i, label: '📮 구독/CTA' },
|
||||
{ re: /grid|card|list|archive|loop|posts/i, label: '🗂️ 카드/리스트 그리드' },
|
||||
{ re: /comment/i, label: '💬 댓글' },
|
||||
{ re: /related|recommend/i, label: '🔗 관련 글' },
|
||||
{ re: /breadcrumb/i, label: '🧵 브레드크럼' },
|
||||
{ re: /pagination|paging|pager/i, label: '📄 페이지네이션' },
|
||||
];
|
||||
|
||||
export function classifyWidget(hint: string): string | null {
|
||||
const h = (hint || '').trim();
|
||||
if (!h) return null;
|
||||
for (const { re, label } of WIDGET_SIGNALS) if (re.test(h)) return label;
|
||||
return null;
|
||||
}
|
||||
|
||||
function sectionLine(sec: any, idx: number): string {
|
||||
const tag = sec.tag || sec.role || 'section';
|
||||
const hint = String(sec.hint || '').slice(0, 40);
|
||||
const widget = classifyWidget(hint);
|
||||
const preview = String(sec.preview || sec.text || sec.textPreview || '').replace(/\s+/g, ' ').slice(0, 60);
|
||||
const counts = [
|
||||
sec.btns != null || sec.buttonCount != null ? `버튼 ${sec.btns ?? sec.buttonCount}` : '',
|
||||
sec.imgs != null || sec.imgCount != null ? `이미지 ${sec.imgs ?? sec.imgCount}` : '',
|
||||
sec.links != null || sec.linkCount != null ? `링크 ${sec.links ?? sec.linkCount}` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
return [
|
||||
`${idx + 1}. \`<${tag}>\``,
|
||||
widget ? widget : '',
|
||||
hint ? `\`${hint}\`` : '',
|
||||
preview ? `— "${preview}"` : '',
|
||||
counts ? `(${counts})` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지별 위→아래 골격 마크다운. 스캔이 비어도 깨지지 않는다.
|
||||
* LLM 합성 성공/실패와 무관하게 보고서에 항상 포함할 것.
|
||||
*/
|
||||
export function buildLayoutSkeleton(scan: any): string {
|
||||
const s = scan || {};
|
||||
const out: string[] = ['## 레이아웃 골격 (스캔 실측 — LLM 미개입)', ''];
|
||||
|
||||
// 홈(루트) — structure.sections 가 가장 상세.
|
||||
const rootSections: any[] = s.structure?.sections || [];
|
||||
if (rootSections.length) {
|
||||
out.push(`### 홈 \`/\` — 위→아래 블록 구성`);
|
||||
rootSections.slice(0, 12).forEach((sec, i) => out.push(`- ${sectionLine(sec, i)}`));
|
||||
out.push('');
|
||||
}
|
||||
|
||||
// 하위 페이지 — sitemap.pages[].sectionRoles.
|
||||
const pages: any[] = (s.sitemap?.pages || []).filter((p: any) => !p?.error);
|
||||
for (const p of pages) {
|
||||
const roles: any[] = p.sectionRoles || [];
|
||||
const pathLabel = (() => { try { return new URL(p.url, 'https://x').pathname; } catch { return p.url || '?'; } })();
|
||||
if (pathLabel === '/' && rootSections.length) continue; // 홈은 위에서 상세 처리
|
||||
if (!roles.length) continue;
|
||||
out.push(`### \`${decodeURIComponent(pathLabel)}\` (${p.role || '?'} · ${p.primaryContentType || '?'})`);
|
||||
roles.slice(0, 10).forEach((sec, i) => out.push(`- ${sectionLine(sec, i)}`));
|
||||
out.push('');
|
||||
}
|
||||
|
||||
// 감지된 위젯 총괄 — "롤링 배너가 있다/없다"에 바로 답하는 요약.
|
||||
const allHints: string[] = [
|
||||
...rootSections.map((x: any) => String(x.hint || '')),
|
||||
...pages.flatMap((p: any) => (p.sectionRoles || []).map((x: any) => String(x.hint || ''))),
|
||||
];
|
||||
const found = new Set<string>();
|
||||
for (const h of allHints) { const w = classifyWidget(h); if (w) found.add(w); }
|
||||
const carousel = [...found].some(w => w.includes('캐러셀'));
|
||||
out.push('### 위젯 감지 요약');
|
||||
out.push(`- 감지됨: ${found.size ? [...found].join(', ') : '(없음)'}`);
|
||||
out.push(`- 롤링 배너/캐러셀: ${carousel ? '**있음** (class/id 신호 기준)' : '신호 없음 — 스캔 힌트에 캐러셀 계열 class 가 없었음 (JS 렌더 위젯일 수 있어 실사이트 확인 권장)'}`);
|
||||
|
||||
if (!rootSections.length && !pages.some((p: any) => (p.sectionRoles || []).length)) {
|
||||
return '## 레이아웃 골격 (스캔 실측)\n- 섹션 데이터가 스캔에 없습니다 (depth/pages 를 올려 재스캔 권장).';
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
/** /benchmark 보고서의 3 파트 분할 — 1: 4-렌즈 / 2: IA + 토큰 / 3: 재구축 명세. */
|
||||
export type SynthesisPart = 1 | 2 | 3;
|
||||
/**
|
||||
* /benchmark 보고서의 파트 분할.
|
||||
* 1: 4-렌즈 디자인 분석 / 2: IA + 디자인 토큰 / 3: 원본 재구축 명세 (재해석 금지)
|
||||
* 4: 비즈니스 벤치마킹 렌즈 (v2.2.302 — 포지셔닝·가격·수익구조·운영 방향성.
|
||||
* 피터 레벨스 류 1인 AI 기업 분석 프레임 이식. 여기서만 사용자 컨텍스트로 변형 제안 허용)
|
||||
* 5: 단계별 빌드 프롬프트 (v2.2.302 — 재구축 명세를 바이브코딩 IDE 에 바로
|
||||
* 붙여넣는 스텝별 프롬프트 교재로 변환)
|
||||
*/
|
||||
export type SynthesisPart = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
/**
|
||||
* scan JSON → 4-렌즈 분석 LLM 프롬프트. Datacollect 웹앱(WebBenchmarkPanel)의
|
||||
@@ -149,19 +156,24 @@ ${commonHeader}
|
||||
### 1-2. 타이포그래피 (Typography)
|
||||
|
||||
## 2. 레이아웃 및 여백 (Layout & Whitespace)
|
||||
### 2-1. 그리드 시스템 (Grid System)
|
||||
### 2-2. 섹션 간 여백 (Section Spacing)
|
||||
### 2-3. 카드/카드 그리드 (Card Spacing)
|
||||
### 2-4. Border Radius / 컨테이너
|
||||
### 2-1. 페이지 골격 서술 (Page Skeleton — 위→아래)
|
||||
- \`structure.sections\` 와 \`sitemap.pages[].sectionRoles\` 를 근거로, 홈과 대표 페이지의 블록 구성을 **위→아래 순서 문장**으로 서술하라 (예: "헤더/GNB → 히어로 배너 → 최신 글 카드 그리드(이미지 40장) → 구독 폼 → 푸터").
|
||||
- 각 섹션의 \`hint\`(class/id) 신호로 위젯을 판별하라: carousel/swiper/slick/slider = 롤링 배너, hero/banner = 정적 배너, grid/card/list = 카드 그리드 등. **배너가 롤링(캐러셀)인지 정적인지 반드시 판정**하고, 신호가 없으면 "캐러셀 class 신호 없음 — 정적으로 추정"이라고 명시하라 (지어내기 금지).
|
||||
### 2-2. 그리드 시스템 (Grid System)
|
||||
### 2-3. 섹션 간 여백 (Section Spacing)
|
||||
### 2-4. 카드/카드 그리드 (Card Spacing)
|
||||
### 2-5. Border Radius / 컨테이너
|
||||
|
||||
## 3. 마이크로 인터랙션 (Micro Interaction)
|
||||
### 3-1. Hover / Focus 효과
|
||||
### 3-2. Transition 패턴
|
||||
### 3-3. 레이어링 (z-index / position)
|
||||
|
||||
## 4. 라이팅 톤앤매너 (Microcopy & Voice)
|
||||
### 4-1. 헤드라인 / 서브헤드라인 / CTA 카피
|
||||
### 4-2. Placeholder 및 보이스 신호`
|
||||
## 4. 톤앤매너 (Tone & Manner)
|
||||
### 4-1. 시각적 톤앤매너 한 문단 (Visual Personality)
|
||||
- 컬러 비율·폰트·radius·여백 수치를 종합해 이 사이트의 시각적 성격을 한 문단으로 규정하라 (예: "무채색 80% + 하늘색 포인트, 넉넉한 여백, 산세리프 — 정보 전달 중심의 차분한 블로그 톤"). 수치를 인용하되 성격 규정은 문장으로.
|
||||
### 4-2. 헤드라인 / 서브헤드라인 / CTA 카피
|
||||
### 4-3. Placeholder 및 보이스 신호`
|
||||
: part === 2
|
||||
? `
|
||||
## 5. 정보 구조 / 사이트 맵 (Information Architecture)
|
||||
@@ -199,7 +211,7 @@ ${commonHeader}
|
||||
- **재사용 컴포넌트**: 5-5에서 정의한 컴포넌트 이름을 콤마로 나열.
|
||||
|
||||
표 아래에 각 템플릿을 ATag/CSS 명세 수준으로 풀어 쓰는 짧은 단락을 덧붙여도 좋다 (선택).`
|
||||
: `
|
||||
: part === 3 ? `
|
||||
## 9. 원본 사이트 재구축 명세 (Rebuild Spec — Same Site, Built From Scratch)
|
||||
|
||||
> **⚠️ 이 단계의 미션 (절대 이탈 금지)**
|
||||
@@ -230,13 +242,70 @@ ${commonHeader}
|
||||
|
||||
- 스캔으로는 잡히지 않는 영역(다이나믹 데이터·CMS 구조·실제 폰트 라이선스·결제 연동 등)을 나열. 추측이 필요한 부분만 적고, 임의로 결정하지 말 것.
|
||||
|
||||
> **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`;
|
||||
> **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`
|
||||
: part === 4 ? `
|
||||
## 10. 서비스 정의 & 포지셔닝 (What Are They Selling)
|
||||
### 10-1. 한 줄 포지셔닝 분석
|
||||
- 헤드라인(\`microcopy.headline\`)이 어떤 공식인지 분석하라 (예: "Fire your photographer" = <대체 대상> + <도발적 동사> 공식). 카피 원문을 인용할 것.
|
||||
### 10-2. 타깃 사용자와 핵심 가치 제안
|
||||
- CTA·서브헤드라인·폼 필드에서 역추적한 타깃 고객과, 이 서비스가 대체하는 기존 비용(사람/시간/돈).
|
||||
### 10-3. 서비스 유형 분류
|
||||
- 생성형 AI 도구 / SaaS / 콘텐츠 / 커머스 중 무엇이며, 핵심 엔진(이미지 생성·텍스트 생성 등)은 무엇으로 추정되는지. 근거 문구 인용.
|
||||
|
||||
## 11. 가격 & 수익 구조 (Pricing & Unit Economics)
|
||||
### 11-1. 가격 티어 추출
|
||||
- 스캔 데이터(h2List·ctaSamples·페이지 텍스트)에서 발견한 가격·크레딧·구독 문구를 **원문 그대로** 표로 정리. 가격 정보가 스캔에 없으면 "스캔 데이터 부족 — \`depth=2\` 로 pricing 페이지 포함 재스캔 권장"이라고 쓰고 이 섹션을 추정으로 채우지 마라.
|
||||
### 11-2. AI 원가 추정 (표)
|
||||
- 이 서비스의 핵심 생성 1회당 원가를 시나리오 표로: (a) 상용 API 사용 시 (일반적으로 알려진 단가 범위 — 반드시 "추정"으로 표기), (b) 오픈소스 모델 셀프호스팅 시 (GPU 서버 고정비 방식). 두 방식의 손익분기 조건을 "사용자 N명 × 월 생성 M회" 형태의 부등식으로 제시.
|
||||
### 11-3. 마진 시뮬레이션
|
||||
- 판매가 − AI 원가 − 결제 수수료(~3.3%) 산술을 표로. 스캔에서 가격을 못 찾았으면 가상의 판매가라고 명시하고 계산 구조만 보여라.
|
||||
|
||||
## 12. 기능 인벤토리 & 사용자 여정 (Feature Inventory)
|
||||
### 12-1. 사용자 여정 지도
|
||||
- 폼 필드·CTA·페이지 흐름에서 역추적: 방문 → (예: 업로드 → 스타일 선택 → 생성 → 결과 → 결제/구독) 단계별로. 각 단계의 근거 데이터를 인용.
|
||||
### 12-2. 백엔드 요구 기능
|
||||
- 인증 / 결제(어떤 PG 흔적이 있는지) / 생성 큐 / 스토리지 / 크레딧 차감 등 프론트 뒤에 있어야 할 기능 목록.
|
||||
### 12-3. MVP 컷 (2~3일 버전)
|
||||
- "빠르게 만들어 시장 반응을 본다" 원칙으로, 위 기능 중 첫 배포에 꼭 필요한 것만 남긴 최소 범위. 뺄 것을 명시하는 것이 핵심.
|
||||
|
||||
## 13. 운영 & 방향성 제안 (Operations & Direction)
|
||||
> 이 섹션에서만 [사용자 보조 컨텍스트]를 적극 결합해 변형·확장을 제안하라 (part 3의 원본 복원 원칙과 분리된 목적).
|
||||
### 13-1. 시리즈화 아이디어
|
||||
- 이 서비스의 공식(포지셔닝+엔진)을 유지한 채 겉만 재포장할 수 있는 인접 서비스 3개 (예: "Fire your X" 시리즈).
|
||||
### 13-2. 광고비 0원 마케팅 채널
|
||||
- 이 유형의 서비스가 초기 사용자를 모으는 현실적 채널 2~3개와 각 채널에 맞는 첫 게시물 아이디어.
|
||||
### 13-3. 출시 후 검증 지표
|
||||
- 시장 반응을 판단할 최소 지표 (방문→업로드 전환율, 생성→결제 전환율 등)와 "접을 기준"까지.
|
||||
### 13-4. 사용자 컨텍스트 결합 변형
|
||||
- [사용자 보조 컨텍스트]가 있으면 그 방향으로 이 모델을 변형한 구체안 1개. 없으면 "(컨텍스트 미입력)" 한 줄만.`
|
||||
: `
|
||||
## 14. 단계별 빌드 프롬프트 (Step-by-Step Build Prompts)
|
||||
|
||||
> **미션**: 아래 스텝 프롬프트들을 바이브코딩 IDE 에 순서대로 붙여넣기만 하면 원본과 매우 유사한 서비스가 완성되어야 한다.
|
||||
> - 각 스텝은 \`### STEP N: <제목>\` 헤딩 + 프롬프트 전문을 담은 코드블록 1개.
|
||||
> - 프롬프트 안에 스캔에서 추출한 **실제 디자인 토큰 값**(색상 rgb/hex·폰트·radius·spacing)과 헤드라인·CTA 원문을 그대로 박아 넣어라 — "적절한 색"·"모던한 폰트" 같은 모호어 금지.
|
||||
> - 각 프롬프트는 자기완결적: 이전 스텝의 산출물을 전제하되, 참조하는 값은 다시 명시한다.
|
||||
> - 기술 스택은 특별한 근거가 없으면 정적 웹 + 최소 백엔드(Node/Express 또는 Next.js) 로 단순하게.
|
||||
|
||||
### STEP 1: 랜딩 페이지 (헤드라인·히어로·가격 섹션 — 디자인 토큰 포함)
|
||||
### STEP 2: 핵심 도구 UI (업로드/입력 → 옵션 선택 → 결과 표시 골격, API 미연결 상태)
|
||||
### STEP 3: 생성 API 연동 (.env 키 보호, 로딩·에러 상태, 결과 다운로드)
|
||||
### STEP 4: 결제/크레딧 최소 구현 (또는 대기자 명단 폼 — MVP 컷 기준)
|
||||
|
||||
각 STEP 아래에 "확인 방법" 한 줄(브라우저에서 무엇이 보이면 성공인지)을 덧붙여라.
|
||||
|
||||
## 15. 빌드 순서 요약 (Build Order)
|
||||
- STEP 1→4 를 표로: | 스텝 | 산출물 | 소요 추정 | 확인 방법 |.`;
|
||||
|
||||
const partGoal = part === 1
|
||||
? '1/3단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
|
||||
? '이번 단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
|
||||
: part === 2
|
||||
? '2/3단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
|
||||
: '3/3단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**';
|
||||
? '이번 단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
|
||||
: part === 3
|
||||
? '이번 단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**'
|
||||
: part === 4
|
||||
? '이번 단계: 이 사이트를 하나의 *사업*으로 분석한다 — 무엇을 팔고, 어떻게 돈을 벌고, 내가 비슷한 서비스를 만든다면 어떻게 운영할지. 스캔 데이터에 있는 문구·가격·CTA 를 근거로 하되, 스캔에 없는 가격·매출은 지어내지 말고 "스캔 데이터 부족"으로 표기한다.'
|
||||
: '이번 단계: 앞선 분석을 바탕으로, 바이브코딩 IDE(Antigravity 등)에 순서대로 붙여넣기만 하면 이 사이트와 매우 유사한 서비스가 만들어지는 **스텝별 빌드 프롬프트**를 작성한다. 각 프롬프트는 자기완결적이어야 한다 (앞 스텝 결과를 전제하되 필요한 값은 다시 명시).';
|
||||
|
||||
return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다.
|
||||
${sharedRules}
|
||||
|
||||
Binary file not shown.
@@ -18,6 +18,7 @@
|
||||
* 수동 명령과 완전 호환.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../../config';
|
||||
@@ -47,23 +48,11 @@ let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
let _lastFiredYmd = '';
|
||||
|
||||
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(new Date());
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
|
||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday: new Date(`${ymd}T00:00:00Z`).getUTCDay() };
|
||||
}
|
||||
|
||||
function cycleSchedule(): { day: number; hour: number; minute: number } {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const day = Math.min(6, Math.max(0, cfg.get<number>('growthCycle.day', 0)));
|
||||
const raw = (cfg.get<string>('growthCycle.time', '20:00') || '20:00').trim();
|
||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||
return { day, hour: m ? Math.min(23, Number(m[1])) : 20, minute: m ? Math.min(59, Number(m[2])) : 0 };
|
||||
const t = parseHhMm(cfg.get<string>('growthCycle.time', '20:00'), 20, 0);
|
||||
return { day, hour: t.hour, minute: t.minute };
|
||||
}
|
||||
|
||||
function msUntilNextFire(): number {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* - 스케줄: 매일 KST 03:00 (기본) — stocksWatcher 와 동일한 setTimeout 체인 패턴.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../../config';
|
||||
@@ -213,19 +214,8 @@ export async function runSleepDigestOnce(): Promise<string> {
|
||||
|
||||
// ── 일일 스케줄러 (KST, stocksWatcher 패턴) ──────────────────────────────────
|
||||
|
||||
function nowInKst(): { hour: number; minute: number; ymd: string } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(new Date());
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd: `${get('year')}-${get('month')}-${get('day')}` };
|
||||
}
|
||||
|
||||
function digestTime(): { hour: number; minute: number } {
|
||||
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00') || '03:00').trim();
|
||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||
return m ? { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) } : { hour: 3, minute: 0 };
|
||||
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00'), 3, 0);
|
||||
}
|
||||
|
||||
function msUntilNextFire(): number {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Claude Code CLI adapter — **구독(Pro/Max) 인증으로 Claude 를 Astra 엔진으로**.
|
||||
*
|
||||
* 배경 (v2.2.305): Anthropic 구독은 API 키가 아니라 Claude Code 로그인 방식이라
|
||||
* `anthropic:` 프로바이더(API 키·종량제)로는 쓸 수 없다. 이 어댑터는 사용자가
|
||||
* 이미 로그인해 둔 로컬 `claude` CLI 를 headless(-p) 로 실행해 추론만 위임한다 —
|
||||
* 추가 과금 없이 구독 한도 안에서 동작.
|
||||
*
|
||||
* 통합 방식: 다른 클라우드 프로바이더와 동일하게 *OpenAI 호환 SSE* Response 를
|
||||
* 반환한다 → agent.ts 의 기존 SSE 파서가 그대로 소비 (모델 드롭다운에
|
||||
* "Claude 구독 · sonnet" 으로 노출, 선택만 하면 됨).
|
||||
*
|
||||
* 설계 결정:
|
||||
* - 프롬프트는 argv 가 아니라 **stdin** 으로 전달 — Astra 의 시스템 프롬프트+
|
||||
* 컨텍스트는 수십 KB 라 ARG_MAX(맥 256KB) 위험이 있다.
|
||||
* - `--output-format stream-json` 을 파싱하되, 버전 간 이벤트 차이를 피하려고
|
||||
* turn 단위(assistant 메시지)로 수집해 SSE 로 흘린다 (부분 델타 플래그는
|
||||
* 구버전 CLI 에 없어 사용하지 않음).
|
||||
* - 실패는 명확한 에러로 — CLI 미설치/미로그인 시 설치·로그인 안내를 에러
|
||||
* 본문에 담는다 (조용한 폴백 금지).
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as vscode from 'vscode';
|
||||
import { StreamParams, ChatMessage } from './types';
|
||||
import { execFileCapture } from '../../lib/execUtil';
|
||||
import { logInfo, logWarn } from '../../utils';
|
||||
|
||||
/** 드롭다운에 노출할 구독 모델 별칭 — CLI 의 --model 이 그대로 받는 값. */
|
||||
export function listClaudeCodeModels(): string[] {
|
||||
return ['sonnet', 'opus', 'haiku'];
|
||||
}
|
||||
|
||||
// ── CLI 경로 탐지 (프로세스 생존 동안 캐시) ─────────────────────────────────
|
||||
let _cliPath: string | null | undefined;
|
||||
|
||||
function candidatePaths(): string[] {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation').get<string>('claude.cliPath', '')?.trim();
|
||||
const home = os.homedir();
|
||||
const cands = [
|
||||
...(cfg ? [cfg] : []),
|
||||
'claude',
|
||||
'/usr/local/bin/claude',
|
||||
'/opt/homebrew/bin/claude',
|
||||
`${home}/.claude/local/claude`,
|
||||
`${home}/.npm-global/bin/claude`,
|
||||
...(process.platform === 'win32' ? ['claude.cmd', 'claude.exe'] : []),
|
||||
];
|
||||
return [...new Set(cands)];
|
||||
}
|
||||
|
||||
/** 로그인된 Claude Code CLI 실행 파일을 찾는다. null = 미설치 (설치 안내는 호출자). */
|
||||
export async function resolveClaudeCli(): Promise<string | null> {
|
||||
if (_cliPath !== undefined) return _cliPath;
|
||||
for (const cand of candidatePaths()) {
|
||||
// 절대 경로 후보는 존재 확인으로 빠르게 거르고, PATH 후보는 --version 실행으로 검증.
|
||||
if (cand.includes('/') || cand.includes('\\')) {
|
||||
try { if (!fs.existsSync(cand)) continue; } catch { continue; }
|
||||
}
|
||||
const r = await execFileCapture(cand, ['--version'], { timeoutMs: 8_000 });
|
||||
if (r.code === 0) {
|
||||
_cliPath = cand;
|
||||
logInfo('[ClaudeCode] CLI 확인.', { path: cand, version: r.stdout.trim().slice(0, 40) });
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
_cliPath = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 테스트/설정 변경 시 캐시 초기화. */
|
||||
export function _resetClaudeCliCache(): void { _cliPath = undefined; }
|
||||
|
||||
// ── 프롬프트 조립 (순수 — 테스트 대상) ──────────────────────────────────────
|
||||
/**
|
||||
* Astra 의 messages 배열을 claude -p 가 받는 단일 프롬프트로 평탄화.
|
||||
* 시스템 프롬프트는 명시적 블록으로, 대화 이력은 화자 라벨로 보존한다.
|
||||
*/
|
||||
export function flattenClaudePrompt(messages: ChatMessage[]): string {
|
||||
const sys: string[] = [];
|
||||
const turns: string[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role === 'system') sys.push(m.content);
|
||||
else turns.push(`${m.role === 'user' ? '[사용자]' : '[아스트라]'}\n${m.content}`);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (sys.length) {
|
||||
parts.push(`<system_instructions>\n${sys.join('\n\n')}\n</system_instructions>`);
|
||||
parts.push('위 system_instructions 를 너의 시스템 지침으로 따르라. 도구를 사용하지 말고, 마지막 [사용자] 메시지에 대한 답변 본문만 출력하라.');
|
||||
}
|
||||
if (turns.length) parts.push(turns.join('\n\n'));
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/** CLI 인자 (순수 — 테스트 대상). 프롬프트는 stdin 으로 가므로 여기 없음. */
|
||||
export function buildClaudeCliArgs(model: string): string[] {
|
||||
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||||
const m = (model || '').trim();
|
||||
if (m) args.push('--model', m);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** stream-json 라인들에서 답변 텍스트/에러 추출 (순수 — 테스트 대상). */
|
||||
export function extractFromStreamJson(lines: string[]): { text: string; error: string } {
|
||||
let text = '';
|
||||
let error = '';
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith('{')) continue;
|
||||
let j: any;
|
||||
try { j = JSON.parse(t); } catch { continue; }
|
||||
if (j.type === 'assistant' && Array.isArray(j.message?.content)) {
|
||||
for (const block of j.message.content) {
|
||||
if (block?.type === 'text' && typeof block.text === 'string') text += block.text;
|
||||
}
|
||||
} else if (j.type === 'result') {
|
||||
if (j.is_error || (j.subtype && j.subtype !== 'success')) {
|
||||
error = String(j.result || j.error || j.subtype || 'unknown error');
|
||||
} else if (!text && typeof j.result === 'string') {
|
||||
text = j.result; // 일부 버전은 assistant 이벤트 없이 result 에만 본문
|
||||
}
|
||||
}
|
||||
}
|
||||
return { text, error };
|
||||
}
|
||||
|
||||
// ── 실행기 ──────────────────────────────────────────────────────────────────
|
||||
const LOGIN_HINT = 'Claude Code CLI 확인이 필요합니다 — 터미널에서 `claude` 실행 후 구독 계정으로 로그인하세요. 미설치라면 `npm install -g @anthropic-ai/claude-code`. (경로가 특이하면 설정 g1nation.claude.cliPath 지정)';
|
||||
|
||||
export interface ClaudeCodeResult { text: string }
|
||||
|
||||
/** claude -p 1회 실행 — 성공 시 답변 텍스트, 실패 시 throw(안내 포함 메시지). */
|
||||
export async function runClaudeCode(params: {
|
||||
messages: ChatMessage[];
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
}): Promise<ClaudeCodeResult> {
|
||||
const cli = await resolveClaudeCli();
|
||||
if (!cli) throw new Error(LOGIN_HINT);
|
||||
|
||||
const prompt = flattenClaudePrompt(params.messages);
|
||||
const args = buildClaudeCliArgs(params.model);
|
||||
const cwd = vscode.workspace.workspaceFolders?.find(f => f.uri.scheme === 'file')?.uri.fsPath || os.homedir();
|
||||
const timeoutMs = params.timeoutMs ?? 300_000; // 클라우드 대형 모델 — 여유 있게
|
||||
|
||||
return new Promise<ClaudeCodeResult>((resolve, reject) => {
|
||||
const child = spawn(cli, args, { cwd, windowsHide: true });
|
||||
let out = '';
|
||||
let err = '';
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => { if (!settled) { settled = true; clearTimeout(timer); fn(); } };
|
||||
const timer = setTimeout(() => {
|
||||
try { child.kill(); } catch { /* already dead */ }
|
||||
finish(() => reject(new Error(`Claude 응답이 ${Math.round(timeoutMs / 1000)}초 안에 오지 않아 중단했습니다.`)));
|
||||
}, timeoutMs);
|
||||
if (params.signal) {
|
||||
const onAbort = () => { try { child.kill(); } catch { /* noop */ } finish(() => reject(new Error('AbortError'))); };
|
||||
if (params.signal.aborted) onAbort();
|
||||
else params.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
child.stdout.on('data', (d) => { out += String(d); });
|
||||
child.stderr.on('data', (d) => { err += String(d); });
|
||||
child.on('error', (e) => finish(() => reject(new Error(`Claude CLI 실행 실패(${e.message}). ${LOGIN_HINT}`))));
|
||||
child.on('close', (code) => finish(() => {
|
||||
const { text, error } = extractFromStreamJson(out.split('\n'));
|
||||
if (text.trim()) return resolve({ text: text.trim() });
|
||||
const detail = (error || err || `exit ${code}`).trim().split('\n').slice(-4).join(' ').slice(0, 400);
|
||||
logWarn('[ClaudeCode] 빈 응답/실패.', { code, detail });
|
||||
reject(new Error(`Claude 응답 실패: ${detail}\n${LOGIN_HINT}`));
|
||||
}));
|
||||
child.stdin.write(prompt);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 스트리밍 어댑터 (OpenAI 호환 SSE Response) ─────────────────────────────
|
||||
export async function streamClaudeCode(
|
||||
_context: vscode.ExtensionContext,
|
||||
params: StreamParams,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const { text } = await runClaudeCode({
|
||||
messages: params.messages,
|
||||
model: params.model,
|
||||
signal: params.signal,
|
||||
});
|
||||
const sse = [
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { content: text }, finish_reason: null }] })}`,
|
||||
'',
|
||||
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}`,
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'', '',
|
||||
].join('\n');
|
||||
return new Response(sse, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
|
||||
} catch (e: any) {
|
||||
if (e?.message === 'AbortError') throw e;
|
||||
return new Response(JSON.stringify({ error: { message: e?.message || String(e) } }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ProviderId, StreamParams, ChatMessage, parseModelPrefix, makeModelId, p
|
||||
import { streamOpenRouter, listOpenRouterModels } from './openrouter';
|
||||
import { streamAnthropic, listAnthropicModels } from './anthropic';
|
||||
import { streamGemini, listGeminiModels } from './gemini';
|
||||
import { streamClaudeCode, listClaudeCodeModels, resolveClaudeCli } from './claudeCode';
|
||||
import { readProviderConfig, writeProviderConfig, readProviderStatus, ProviderConfig } from './providerConfig';
|
||||
|
||||
export type { ProviderId, ChatMessage, StreamParams, ProviderConfig };
|
||||
@@ -31,6 +32,7 @@ export async function streamCloudCompletion(
|
||||
case 'openrouter': return streamOpenRouter(context, fullParams);
|
||||
case 'anthropic': return streamAnthropic(context, fullParams);
|
||||
case 'gemini': return streamGemini(context, fullParams);
|
||||
case 'claude-code': return streamClaudeCode(context, fullParams);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +66,13 @@ export async function listAllCloudModels(context: vscode.ExtensionContext): Prom
|
||||
for (const id of ids) out.push({ id: makeModelId('gemini', id), provider: 'gemini', label: `Gemini · ${id}` });
|
||||
}));
|
||||
}
|
||||
// Claude 구독 — API 키 불필요: 로그인된 Claude Code CLI 가 있으면 곧 활성.
|
||||
tasks.push(resolveClaudeCli().then((cli) => {
|
||||
if (!cli) return;
|
||||
for (const id of listClaudeCodeModels()) {
|
||||
out.push({ id: makeModelId('claude-code', id), provider: 'claude-code', label: `Claude 구독 · ${id}` });
|
||||
}
|
||||
}).catch(() => { /* 탐지 실패 = 미노출 */ }));
|
||||
await Promise.all(tasks);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* - 옛 사용자 model 설정 ('gemma4:e2b') 은 prefix 없으니 자동으로 local 경로.
|
||||
*/
|
||||
|
||||
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini';
|
||||
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini' | 'claude-code';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
@@ -32,6 +32,8 @@ const PROVIDER_PREFIXES: ReadonlyArray<{ prefix: string; id: ProviderId }> = [
|
||||
{ prefix: 'openrouter:', id: 'openrouter' },
|
||||
{ prefix: 'anthropic:', id: 'anthropic' },
|
||||
{ prefix: 'gemini:', id: 'gemini' },
|
||||
// Claude 구독(Pro/Max) — API 키가 아니라 로컬 Claude Code CLI 로그인에 위임 (v2.2.305).
|
||||
{ prefix: 'claude:', id: 'claude-code' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -48,14 +50,18 @@ export function parseModelPrefix(modelId: string): { provider: ProviderId; model
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 역변환 — provider + 실제 model → prefixed id. UI / config 저장용. */
|
||||
/** 역변환 — provider + 실제 model → prefixed id. UI / config 저장용.
|
||||
* 주의: prefix 는 PROVIDER_PREFIXES 가 단일 권위 — provider id 와 다를 수 있다
|
||||
* (claude-code 의 prefix 는 사용자 친화적으로 'claude:'). */
|
||||
export function makeModelId(provider: ProviderId, model: string): string {
|
||||
return `${provider}:${model}`;
|
||||
const hit = PROVIDER_PREFIXES.find(p => p.id === provider);
|
||||
return `${hit ? hit.prefix : provider + ':'}${model}`;
|
||||
}
|
||||
|
||||
/** UI 표시용 짧은 라벨. */
|
||||
export function providerLabel(p: ProviderId): string {
|
||||
switch (p) {
|
||||
case 'claude-code': return 'Claude 구독';
|
||||
case 'openrouter': return 'OpenRouter';
|
||||
case 'anthropic': return 'Anthropic';
|
||||
case 'gemini': return 'Gemini';
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
* - 워크스페이스 외부 경로 무시
|
||||
* - 사용자가 `executionVerification=false`면 통째로 skip — 호출자가 가드
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import { detectPythonSync, PYTHON_CANDIDATES } from '../../lib/execUtil';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
@@ -86,23 +87,10 @@ function _firstNonEmptyLine(s: string): string {
|
||||
* Returns the platform-preferred default when neither is found so the caller
|
||||
* still produces the usual "미설치" warning via the spawn-failure path.
|
||||
*/
|
||||
let _pythonCmdCache: string | undefined;
|
||||
// [코어 수렴] Python 탐지는 lib/execUtil 의 단일 구현·공유 캐시를 쓴다.
|
||||
// 미설치 시에도 플랫폼 기본 후보를 돌려줘 spawn-실패 경로가 기존처럼 "미설치" 경고를 만든다.
|
||||
function _resolvePythonCmd(): string {
|
||||
if (_pythonCmdCache !== undefined) return _pythonCmdCache;
|
||||
const candidates = process.platform === 'win32'
|
||||
? ['python', 'python3']
|
||||
: ['python3', 'python'];
|
||||
for (const cmd of candidates) {
|
||||
try {
|
||||
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
|
||||
if (!r.error && (r.status === 0 || r.status === null)) {
|
||||
_pythonCmdCache = cmd;
|
||||
return cmd;
|
||||
}
|
||||
} catch { /* try next candidate */ }
|
||||
}
|
||||
_pythonCmdCache = candidates[0];
|
||||
return _pythonCmdCache;
|
||||
return detectPythonSync() ?? PYTHON_CANDIDATES[0];
|
||||
}
|
||||
|
||||
/** 확장자별 검사 명령 결정. 지원 안 하는 확장자면 null 반환 (skip). */
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* - 시스템 Python 자체 설치는 OS 정책상 자동화 위험 → python.org 링크 안내만.
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import { PYTHON_CANDIDATES } from '../../lib/execUtil';
|
||||
import { spawn } from 'child_process';
|
||||
import { logInfo, logError } from '../../utils';
|
||||
|
||||
@@ -44,9 +45,9 @@ export async function probePythonEnv(): Promise<PythonProbe> {
|
||||
};
|
||||
|
||||
// Windows 는 `python` / `py` 가 보통 우선, 그 외엔 `python3` 가 안전.
|
||||
const candidates = process.platform === 'win32'
|
||||
? ['python', 'py', 'python3']
|
||||
: ['python3', 'python'];
|
||||
// [코어 수렴] 후보 순서는 lib/execUtil 과 공유 (이 probe 는 Python3 버전·패키지 검증까지
|
||||
// 수행하는 특수 경로라 탐지 로직 자체는 유지한다).
|
||||
const candidates = PYTHON_CANDIDATES;
|
||||
|
||||
for (const cmd of candidates) {
|
||||
const ver = await _capture(cmd, ['--version'], 5_000);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { nowInKst } from '../../lib/kstSchedule';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { readStocksStore, writeStocksStore } from './stocksStore';
|
||||
import { fetchAllPrices } from './yahooClient';
|
||||
@@ -32,23 +33,6 @@ function scheduleTimesKst(): { hour: number; minute: number }[] {
|
||||
let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
|
||||
/** Asia/Seoul 기준 *지금* 의 hour/minute. */
|
||||
function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } {
|
||||
const now = new Date();
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(now);
|
||||
const get = (t: string) => Number(parts.find(p => p.type === t)?.value || '0');
|
||||
return {
|
||||
date: now,
|
||||
hour: get('hour'),
|
||||
minute: get('minute'),
|
||||
ymd: `${get('year')}-${parts.find(p => p.type === 'month')?.value}-${parts.find(p => p.type === 'day')?.value}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금부터 다음 firing 까지 milliseconds 계산.
|
||||
* SCHEDULE_HOURS_KST 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각.
|
||||
|
||||
Reference in New Issue
Block a user