Astra v2.2.41: /benchmark LLM 4-lens synthesis + Datacollect settings

- /benchmark now runs the full scan -> LLM 3-stage 4-lens synthesis ->
  markdown report pipeline, matching the Datacollect web app output
- Add settings: datacollectSynthesisTemperature (0.1), datacollectCrawlDepth,
  datacollectMaxPages, datacollectSavePath; new "Datacollect" Settings section
- Fix slash result not rendering (missing streamStart) and /benchmark URL
  parsing when natural language is appended
- Rename view container/view ids to g1nation-* to avoid conflict with the
  Antigravity built-in "Connect AI" extension
- Version bump 2.2.34 -> 2.2.41

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 13:22:26 +09:00
parent fce6938e1c
commit 9e7c7fe605
13 changed files with 666 additions and 59 deletions
+6 -4
View File
@@ -49,11 +49,13 @@ const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken';
* Astra Extension Entry Point
*/
export async function activate(context: vscode.ExtensionContext) {
// Activation 시점 popup — 사용자 환경(Antigravity 등 VS Code 변종)에서 우리 vsix가
// 실제로 활성화됐는지 결정적으로 가시화. 같은 이름의 빌트인이 우선해 우리 코드
// 활성화 안 되는 케이스를 즉시 발견 가능.
// Activation 시점 popup + DevTools console — 사용자 환경(Antigravity 등 VS Code 변종)
// 에서 우리 vsix가 실제로 활성화됐는지 결정적으로 가시화. console.error는 사용자
// F12 DevTools console에서 다른 모든 출력과 함께 그대로 보인다 (logInfo의 OutputChannel
// 과 별개 채널 — popup도 OutputChannel도 못 보는 경우의 마지막 안전망).
const ext = vscode.extensions.getExtension('g1nation.astra');
const version = ext?.packageJSON?.version || '(unknown)';
console.error(`[ASTRA-DEBUG] activate v${version} pid=${process.pid}`);
void vscode.window.showInformationMessage(`📡 Astra v${version} activated (PID=${process.pid})`);
logInfo(`Astra activating... version=${version} pid=${process.pid}`);
@@ -184,7 +186,7 @@ export async function activate(context: vscode.ExtensionContext) {
getChildren: () => [],
};
context.subscriptions.push(
vscode.window.registerTreeDataProvider('astra-launcher', astraLauncherProvider),
vscode.window.registerTreeDataProvider('g1nation-astra-launcher', astraLauncherProvider),
);
// 4. Initialize Bridge Server (Port 4825)
+417 -24
View File
@@ -54,12 +54,16 @@ export async function handleSlashCommand(
const head = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).toLowerCase() as SlashCommand;
const arg = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
console.error(`[ASTRA-DEBUG] slashRouter handleSlashCommand head=${head} arg=${arg.slice(0, 40)}`);
logInfo(`[SLASH] handleSlashCommand start head=${head} arg="${arg.slice(0, 60)}" bridge=${getBridgeBaseUrl()}`);
// 사용자가 OutputChannel을 안 봐도 진입 사실을 확인할 수 있도록 popup + statusbar.
// chunk(view, ...)가 webview 비활성 등으로 silently drop돼도 popup은 화면 우측 하단에
// 큼지막하게 표시된다. setStatusBarMessage는 보조.
void vscode.window.showInformationMessage(`📻 Datacollect Radio: ${head} 진입`);
void vscode.window.setStatusBarMessage(`📻 Datacollect Radio: ${head} 처리 중…`, 5000);
// streamEnd(finally)와 대칭 — webview는 streamStart를 받아야 assistant 메시지
// 버블(streamBody)을 만든다(sidebar.js 참조). 이게 없으면 이후 chunk가 보내는
// streamChunk가 streamBody 부재로 전부 silently drop돼 사용자는 아무 결과도
// 못 본다. 일반 LLM 경로는 streamer가 streamStart를 보내주지만, slash 경로는
// LLM을 우회하므로 streamEnd처럼 streamStart도 명시적으로 보내야 한다.
view?.postMessage({ type: 'streamStart' });
chunk(view, `\n\n**📻 Datacollect Radio** · \`${head}\` · bridge=\`${getBridgeBaseUrl()}\`\n\n`);
try {
@@ -145,43 +149,432 @@ async function runResearch(topic: string, view: Webview | undefined): Promise<bo
// ───────────────────────────── /benchmark ─────────────────────────────
async function runBenchmark(url: string, view: Webview | undefined): Promise<boolean> {
type SynthesisPart = 1 | 2 | 3;
/**
* scan JSON → 4-렌즈 분석 LLM 프롬프트. Datacollect 웹앱(WebBenchmarkPanel)의
* buildSynthesisPrompt를 그대로 이식 — /benchmark 결과가 웹앱과 동등하게 나오도록.
*/
function buildSynthesisPrompt(scan: any, userContent: string, part: SynthesisPart): string {
// 4-렌즈 분석에 필요한 핵심 데이터만 추려 LLM 입력을 가볍게.
const slim = {
url: scan?.url,
title: scan?.meta?.title,
description: scan?.meta?.description,
lang: scan?.meta?.lang,
// §1. 비주얼 아이덴티티 — 컬러 비율 + 다크모드 + 타이포 위계
colors: {
palette: scan?.design?.colors?.palette?.slice(0, 8),
composition: scan?.design?.colors?.composition,
background: scan?.design?.colors?.background,
primaryText: scan?.design?.colors?.primaryText,
linkColor: scan?.design?.colors?.linkColor,
buttonBackground: scan?.design?.colors?.buttonBackground,
buttonText: scan?.design?.colors?.buttonText,
darkModeHints: scan?.design?.colors?.darkModeHints,
},
typography: {
primaryFont: scan?.design?.typography?.primaryFont,
fontStack: scan?.design?.typography?.fontStack?.slice(0, 3),
topFontSizes: scan?.design?.typography?.topFontSizes?.slice(0, 6),
topFontWeights: scan?.design?.typography?.topFontWeights?.slice(0, 5),
body: scan?.design?.typography?.body,
h1: scan?.design?.typography?.h1,
h2: scan?.design?.typography?.h2,
h3: scan?.design?.typography?.h3,
button: scan?.design?.typography?.button,
},
// §2. 레이아웃 & 공간감 — 여백 / 그리드
layout: {
viewport: { w: scan?.design?.layout?.viewportWidth, h: scan?.design?.layout?.viewportHeight },
bodyMaxWidth: scan?.design?.layout?.bodyMaxWidth,
sectionSpacing: scan?.design?.layout?.sectionSpacing,
cardSpacing: scan?.design?.layout?.cardSpacing,
borderRadiusScale: scan?.design?.layout?.borderRadiusScale,
grids: scan?.design?.layout?.grids,
containerSystem: scan?.design?.layout?.containerSystem,
responsiveHints: scan?.design?.layout?.responsiveHints,
layering: scan?.design?.layout?.layering,
},
components: scan?.design?.components,
mediaTreatment: scan?.design?.mediaTreatment,
surfaceTreatment: scan?.design?.surfaceTreatment,
// §3. 마이크로 인터랙션 — Hover / Transition
interactions: {
hoverRules: scan?.interactions?.hoverRules?.slice(0, 6),
focusRules: scan?.interactions?.focusRules?.slice(0, 3),
transitionDistribution: scan?.interactions?.transitionDistribution,
cssVars: scan?.interactions?.cssVars,
},
// §4. 라이팅 톤앤매너 — 마이크로카피
microcopy: {
headline: scan?.microcopy?.headline,
subheadline: scan?.microcopy?.subheadline,
subheadlines: scan?.microcopy?.subheadlines?.slice(0, 6),
ctaSamples: scan?.microcopy?.ctaSamples?.slice(0, 10),
placeholders: scan?.microcopy?.placeholders,
stateMessages: scan?.microcopy?.stateMessages,
ariaLabels: scan?.microcopy?.ariaLabels?.slice(0, 6),
bodySample: scan?.microcopy?.bodySample,
voiceSignals: scan?.microcopy?.voiceSignals,
},
// 구조 요약 — 역기획서 매칭을 위한 보조 정보
structure: {
sections: scan?.structure?.sections?.slice(0, 10).map((s: any) => ({
role: s.role,
depth: s.depth,
text: s.textPreview?.slice(0, 100),
btns: s.buttonCount,
links: s.linkCount,
imgs: s.imgCount,
})),
h1: scan?.structure?.h1,
h2List: scan?.structure?.h2List?.slice(0, 6),
navigationLinks: scan?.structure?.navigationLinks?.slice(0, 10),
},
iconography: scan?.design?.iconography,
// §5. 사이트맵 — 준비할 리소스 추정의 근거 (페이지별 역할 + 자산 수).
sitemap: scan?.sitemap ? {
totalPages: scan.sitemap.totalPages,
crawlDepth: scan.sitemap.crawlDepth,
asciiTree: scan.sitemap.ascii,
pages: scan.sitemap.pages?.map((p: any) => ({
url: p.url,
role: p.role,
title: p.title?.slice(0, 80),
h1: p.h1?.slice(0, 80),
h2List: p.h2List?.slice(0, 5),
contentType: p.primaryContentType,
imageCount: p.imageCount,
videoCount: p.videoCount,
formFields: p.formFields?.slice(0, 6).map((f: any) => ({
name: f.name || f.label, type: f.type, required: f.required,
})),
ctas: p.ctaSamples?.slice(0, 4),
sections: p.sectionRoles?.slice(0, 8).map((s: any) => ({
tag: s.tag, hint: s.hint, preview: (s.preview || '').slice(0, 50),
})),
error: p.error,
})),
} : null,
};
const today = new Date().toISOString().slice(0, 10);
const title = slim.title || 'Reference Site';
const userBlock = userContent.trim()
? userContent.trim()
: '(미입력 — 원본 사이트 자체의 재현에만 집중)';
const sharedRules = `
[분석 원칙]
1. 이 보고서의 미션은 "원본 레퍼런스 사이트를 가능한 한 닮은 사이트를 처음부터 다시 만들기 위한 명세"를 작성하는 것이다.
2. 추측이나 일반론은 금지. 모든 진술은 제공된 JSON 스캔 데이터의 구체적 수치/문자열을 근거로 인용한다.
3. JSON에 없는 정보를 지어내지 말 것. 데이터에 없는 항목은 "스캔 데이터 부족"이라고 명시한다.
4. 한국어로 작성한다.
5. 모든 색상/폰트/여백/Radius는 정확한 값(rgb/px)을 그대로 인용한다.`;
const commonHeader = `
# ${title} 레퍼런스 사이트 재구축 명세
> **레퍼런스 URL**: ${slim.url}
> **분석 일자**: ${today}
> **분석 관점**: 4-렌즈 (Visual / Layout / Interaction / Voice) + IA 및 페이지 템플릿 + 재구축 명세
> **스캔된 페이지**: ${slim.sitemap?.totalPages ?? 1}개 (crawlDepth: ${slim.sitemap?.crawlDepth ?? 0})`;
const partTemplate = part === 1
? `
${commonHeader}
## 한 줄 요약 (One-line Impression)
## 1. 시각적 정체성 (Visual Identity)
### 1-1. 컬러 팔레트 (Color Palette)
### 1-2. 타이포그래피 (Typography)
## 2. 레이아웃 및 여백 (Layout & Whitespace)
### 2-1. 그리드 시스템 (Grid System)
### 2-2. 섹션 간 여백 (Section Spacing)
### 2-3. 카드/카드 그리드 (Card Spacing)
### 2-4. 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 및 보이스 신호`
: part === 2
? `
## 5. 정보 구조 / 사이트 맵 (Information Architecture)
### 5-1. 사이트 트리 다이어그램 (Page Tree)
- \`sitemap.asciiTree\`를 코드블록으로 그대로 옮겨 적을 것.
### 5-2. 페이지 목록 (Flat View)
### 5-3. 페이지별 구성 요약 (Page Composition)
### 5-4. IA 특징 정리
### 5-5. 재구축용 컴포넌트 명세 (Component Reconstruction Spec)
### 5-6. 미디어 처리 (Media Treatment)
## 6. 준비해야 할 리소스 (Resources You Need to Prepare)
### 6-1. 페이지별 이미지/비디오 수
### 6-2. 카피라이팅 분량
### 6-3. 폼/입력 필드 목록
## 7. 디자인 토큰 (Design Tokens)
- Color / Typography / Spacing / Radius / Border / Shadow / Motion 각각 표로 정리.
## 8. 페이지 템플릿 맵 (Page Template Map)
스캔된 페이지들의 \`primaryContentType\` + \`sectionRoles\` + \`h2List\`를 묶어 **반복되는 템플릿 유형**을 도출하고, 반드시 아래 표 형식으로 작성하라. **반드시 마크다운 표 문법을 쓸 것** (글머리표·산문 금지). 템플릿은 최소 3개 이상 도출하되, 페이지가 모두 다르면 그만큼만 작성.
| 템플릿 ID | 적용 URL | 공통 블록 순서 (위 → 아래) | 페이지별 차이점 | 재사용 컴포넌트 |
|---|---|---|---|---|
| T1: Gallery Landing | / | Header → Hero(작품 1장) → 작품 그리드(3열) → Footer | (없음 / 단독 페이지) | Header, ImageCard, Footer |
| T2: Category List | /shop, /paintings | Header → 카테고리 타이틀(h1) → 작품 그리드(2열) → Pagination → Footer | 카테고리명·작품 수 다름 | Header, ImageCard, Footer, Pagination |
| T3: Detail | /shop/oil-painting/limited-editions | Header → Breadcrumb → 작품 이미지(좌) + 메타·CTA(우) → 관련 작품 → Footer | 상품별 이미지·가격·CTA 문구 다름 | Header, BreadcrumbBar, BuyButton, RelatedGrid, Footer |
작성 규칙:
- **템플릿 ID**: \`T1: <역할>\` 형식. 역할은 한국어 또는 영어 모두 OK.
- **적용 URL**: 해당 템플릿을 쓰는 페이지의 URL을 콤마로 모두 나열. 1개면 1개만.
- **공통 블록 순서**: \`sectionRoles\`의 tag 순서를 기반으로 \`Header → Hero → ... → Footer\` 식으로 위→아래 흐름을 화살표(\`\`)로 표기.
- **페이지별 차이점**: 같은 템플릿을 쓰는 페이지들 사이의 변하는 부분(타이틀/이미지 수/CTA 문구 등). 단독 페이지면 \`(없음 / 단독 페이지)\`.
- **재사용 컴포넌트**: 5-5에서 정의한 컴포넌트 이름을 콤마로 나열.
표 아래에 각 템플릿을 ATag/CSS 명세 수준으로 풀어 쓰는 짧은 단락을 덧붙여도 좋다 (선택).`
: `
## 9. 원본 사이트 재구축 명세 (Rebuild Spec — Same Site, Built From Scratch)
> **⚠️ 이 단계의 미션 (절대 이탈 금지)**
> - 이 섹션은 **원본 레퍼런스 사이트와 가능한 한 같은 사이트를 처음부터 다시 만들기 위한 개발 명세**다.
> - 다른 서비스(대시보드, 분석 툴, SaaS 등)로 **재해석·확장·전환하지 말 것**. 사용자 컨텍스트가 원본과 다른 도메인이면 part 9에서는 무시한다.
> - "개선 / 재설계 / 모던화 / 데이터 시각화 추가" 같은 변형 제안 금지. **원본 그대로 복원**이 유일한 목적.
> - 모든 결정값(색상·폰트·여백·Radius·전환 속도)은 part 1~7에서 추출한 토큰을 그대로 인용한다.
### 9-1. 디자인 토큰 정의 (원본 값 그대로)
- part 7에서 도출한 토큰을 CSS 변수 또는 Tailwind config 형식으로 코드블록에 옮긴다. 값은 절대 임의로 바꾸지 말 것.
### 9-2. 컴포넌트 명세 (원본 사이트의 카드/버튼/네비 등)
- part 5-5의 컴포넌트별 props·치수·padding·radius·border·shadow를 코드블록 형태로 명세.
### 9-3. 페이지별 레이아웃 마크업 가이드
- part 8 페이지 템플릿 맵의 각 템플릿(T1, T2, ...)에 대해 HTML 골격(섹션 → 자식 컴포넌트)을 의사 JSX/HTML로 1개씩 제시.
### 9-4. 인터랙션 재현 명세
- part 3의 hover/focus/transition 값을 어느 컴포넌트에 어떻게 적용할지 명시 (예: \`.btn:hover { background: ...; transition: 0.2s ease; }\`).
### 9-5. 콘텐츠 및 자산 준비 목록
- part 6의 페이지별 이미지/비디오 수, 카피 분량, 폼 필드를 체크리스트로 정리. 사장님이 준비해야 할 자산 목록.
### 9-6. 개발 티켓 (원본 복원 기준)
- 위 9-1 ~ 9-5를 구현 가능한 단위로 쪼개 \`[FE] / [BE] / [Asset]\` 태그를 붙여 티켓 형태로 나열. 모든 티켓은 "원본 사이트와 같게 만들기" 범위 안에 있어야 한다. 신규 기능 제안 금지.
## 🔍 복원 시 추정이 필요한 영역 (Buildability Gaps)
- 스캔으로는 잡히지 않는 영역(다이나믹 데이터·CMS 구조·실제 폰트 라이선스·결제 연동 등)을 나열. 추측이 필요한 부분만 적고, 임의로 결정하지 말 것.
> **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`;
const partGoal = part === 1
? '1/3단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
: part === 2
? '2/3단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
: '3/3단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**';
return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다.
${sharedRules}
[이번 단계 목표]
${partGoal}
[레퍼런스 사이트 스캔 데이터 (JSON)]
\`\`\`json
${JSON.stringify(slim, null, 2)}
\`\`\`
[사용자 보조 컨텍스트 — part 1·2의 톤 추정에만 참고. part 3에서는 무시할 것.]
${userBlock}
[작성할 보고서 섹션 (이 구조를 그대로 따를 것)]
${partTemplate}`;
}
/**
* Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출.
* LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다.
*/
async function callLmSynthesis(prompt: string): Promise<string> {
const cfg = vscode.workspace.getConfiguration('g1nation');
const lmUrl = (cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, '');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
// temperature는 설정값(g1nation.datacollectSynthesisTemperature). 낮을수록 환각·
// 깨진 문자가 줄어든다. 0~2 범위로 클램프, 기본 0.1.
const temperature = Math.max(0, Math.min(2, cfg.get<number>('datacollectSynthesisTemperature', 0.1) ?? 0.1));
const res = await bridgeFetch<any>('/api/lm', {
method: 'POST',
body: JSON.stringify({
url: `${lmUrl}/v1/chat/completions`,
payload: {
model,
messages: [
{ role: 'system', content: '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.' },
{ role: 'user', content: prompt },
],
temperature,
},
}),
}, { timeoutMs: 120_000 });
const content = res?.choices?.[0]?.message?.content
?? res?.choices?.[0]?.text
?? res?.answer
?? res?.response
?? '';
return String(content).trim();
}
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
// 인자 파싱: 첫 비-옵션 토큰=URL, `depth=N`·`pages=N`=크롤 옵션, 나머지=보조 컨텍스트.
// 예) `/benchmark caliverse.io depth=2 pages=12 우리 랜딩 참고용`
// URL 토큰만 떼어내므로 "분석해줘" 같은 자연어가 섞여도 안전하다.
const tokens = arg.trim().split(/\s+/).filter(Boolean);
let url = '';
let depthArg: number | undefined;
let pagesArg: number | undefined;
const restParts: string[] = [];
for (const t of tokens) {
const m = /^(depth|pages)=(\d+)$/i.exec(t);
if (m) {
if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
else pagesArg = Number(m[2]);
} else if (!url) {
url = t;
} else {
restParts.push(t);
}
}
if (!url) {
chunk(view, `사용법: \`/benchmark <url>\`\n예: \`/benchmark https://example.com\`\n`);
chunk(view, `사용법: \`/benchmark <url> [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
return true;
}
const userContent = restParts.join(' ');
chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(Playwright + 디자인 토큰/사이트맵 추출, 최대 8페이지)\n\n`);
// 크롤 옵션 우선순위: 명령 인자 > Settings 설정값 > 기본값(depth 1 / 8페이지).
const cfg = vscode.workspace.getConfiguration('g1nation');
const crawlDepth = depthArg ?? (cfg.get<number>('datacollectCrawlDepth', 1) ?? 1);
const maxPages = pagesArg ?? (cfg.get<number>('datacollectMaxPages', 8) ?? 8);
chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(crawlDepth ${crawlDepth} · 최대 ${maxPages}페이지 · Playwright)\n\n⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`);
// 1) scan — 진행 중 멈춘 줄로 오해하지 않도록 4초마다 경과 시간을 누적 표시.
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
'/api/web-benchmark/scan',
{
method: 'POST',
body: JSON.stringify({ url, captureScreenshots: false, maxPages: 8, crawlDepth: 1 }),
body: JSON.stringify({ url, captureScreenshots: false, maxPages, crawlDepth }),
},
{ timeoutMs: 6 * 60_000 },
);
).finally(() => clearInterval(heartbeat));
const s = scan.scan;
chunk(view, `### 메타\n`);
chunk(view, `- **title**: ${s?.meta?.title || '(없음)'}\n`);
chunk(view, `- **description**: ${s?.meta?.description || '(없음)'}\n`);
chunk(view, `- **lang**: ${s?.meta?.lang || '(없음)'}\n\n`);
chunk(view, `\n✅ **스캔 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${s?.sitemap?.totalPages ?? 1}페이지)\n\n`);
chunk(view, `### 디자인 토큰 (상위)\n`);
// 스캔이 에러 없이 끝나도(잘못된 URL·봇 차단·JS 렌더링 등) 알맹이가 빌 수 있다.
const looksEmpty = !s?.meta?.title
&& !(s?.design?.colors?.palette?.length)
&& !s?.microcopy?.headline;
if (looksEmpty) {
chunk(view, `⚠️ **스캔 결과가 비어 있습니다.** URL이 정확한지, 사이트가 접근 가능한지(봇 차단·JS 전용 렌더링 포함) 확인하세요. 스캔한 URL: \`${url}\`\n\n`);
}
// raw 스캔 요약 — LLM 합성이 실패하거나 스캔이 비었을 때의 fallback 본문.
const palette = s?.design?.colors?.palette?.slice(0, 5) || [];
chunk(view, `- **컬러 팔레트 Top 5**: ${palette.map((p: any) => `\`${p.value}\` (×${p.count})`).join(', ') || '(없음)'}\n`);
chunk(view, `- **컬러 비율**: ${s?.design?.colors?.composition?.ratioLabel || '(없음)'}\n`);
chunk(view, `- **Primary Font**: \`${s?.design?.typography?.primaryFont || '(없음)'}\`\n`);
chunk(view, `- **그리드**: ${(s?.design?.layout?.grids || []).map((g: any) => g.columnsRaw).join(' | ') || '(없음)'}\n\n`);
const rawReport = [
`### 메타`,
`- **title**: ${s?.meta?.title || '(없음)'}`,
`- **description**: ${s?.meta?.description || '(없음)'}`,
`- **lang**: ${s?.meta?.lang || '(없음)'}`,
``,
`### 디자인 토큰 (상위)`,
`- **컬러 팔레트 Top 5**: ${palette.map((p: any) => `\`${p.value}\` (×${p.count})`).join(', ') || '(없음)'}`,
`- **컬러 비율**: ${s?.design?.colors?.composition?.ratioLabel || '(없음)'}`,
`- **Primary Font**: \`${s?.design?.typography?.primaryFont || '(없음)'}\``,
``,
`### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`,
'```',
s?.sitemap?.ascii || '(없음)',
'```',
].join('\n');
chunk(view, `### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})\n`);
chunk(view, `\`\`\`\n${s?.sitemap?.ascii || '(없음)'}\n\`\`\`\n\n`);
// 2) synthesize — LLM 4-렌즈 합성 3단계 (Datacollect 웹앱과 동일한 리포트).
// 스캔이 비었거나 합성이 실패하면 raw 요약으로 fallback.
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`);
try {
const parts: string[] = [];
for (const part of [1, 2, 3] as const) {
chunk(view, `\n · 합성 ${part}/3 진행 중…`);
const partT0 = Date.now();
const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
parts.push(out);
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
}
finalReport = parts.join('\n\n---\n\n');
chunk(view, `\n\n`);
} catch (e: any) {
chunk(view, `\n\n⚠️ LLM 합성 실패: ${e?.message || String(e)}\n원시 스캔 요약만 표시·저장합니다. (LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
finalReport = rawReport;
}
}
chunk(view, `### 마이크로카피\n`);
chunk(view, `- **헤드라인**: ${s?.microcopy?.headline || '(없음)'}\n`);
chunk(view, `- **CTA Top 5**: ${(s?.microcopy?.ctaSamples || []).slice(0, 5).map((c: string) => `\`${c}\``).join(', ') || '(없음)'}\n\n`);
chunk(view, finalReport + '\n\n');
// 3) save — 저장 위치 우선순위: g1nation.datacollectSavePath > Bridge WIKI_RAW_PATH.
// 어느 쪽이든 Astra 코드에는 절대경로가 하드코딩되지 않는다.
try {
const today = new Date().toISOString().slice(0, 10);
let host = url;
try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
const title = `웹벤치마크 ${host} ${today}`;
const fileMarkdown = [
`# ${title}`,
``,
`- **원본 URL**: ${url}`,
`- **스캔 시각**: ${new Date().toISOString()}`,
`- **크롤 옵션**: depth ${crawlDepth} · 최대 ${maxPages}페이지`,
`- **생성**: Astra /benchmark · Datacollect web-benchmark`,
``,
finalReport,
``,
].join('\n');
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { title, content: fileMarkdown };
if (savePath) body.saveDir = savePath;
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
'/api/wiki/save',
{ method: 'POST', body: JSON.stringify(body) },
{ timeoutMs: 30_000 },
);
chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
} catch (e: any) {
chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n`);
}
chunk(view, `> 💡 더 깊은 4-렌즈/Rebuild Blueprint 합성을 원하면 위 결과를 인용해 Astra에 추가 질문하세요.\n`);
return true;
}
@@ -83,6 +83,14 @@ interface SettingsState {
maxAutoSteps: number;
maxContextSize: number;
};
datacollect: {
bridgeUrl: string;
/** Empty → results saved to the Bridge's WIKI_RAW_PATH default. */
savePath: string;
crawlDepth: number;
maxPages: number;
synthesisTemperature: number;
};
google: {
clientId: string;
/** secret 자체는 client 에 echo 안 함 — *설정 여부* 만. true 면 input placeholder 가 "저장됨" 으로 바뀜. */
@@ -237,6 +245,9 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
case 'advanced.update':
await this._handleAdvancedUpdate(msg);
return;
case 'datacollect.update':
await this._handleDatacollectUpdate(msg);
return;
case 'google.update':
await this._handleGoogleUpdate(msg);
return;
@@ -572,6 +583,28 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
}
}
// ────────────── Datacollect (slash 명령) ──────────────
// /research·/benchmark·/youtube 가 호출하는 Bridge URL 과, 결과물 저장 위치.
// savePath 가 비어 있으면 Bridge 의 WIKI_RAW_PATH 환경변수가 저장 위치를 결정한다.
private async _handleDatacollectUpdate(msg: any): Promise<void> {
if (typeof msg.bridgeUrl === 'string') {
await this._safeConfigUpdate('datacollectBridgeUrl', msg.bridgeUrl.trim());
}
if (typeof msg.savePath === 'string') {
await this._safeConfigUpdate('datacollectSavePath', msg.savePath.trim());
}
if (typeof msg.crawlDepth === 'number' && Number.isFinite(msg.crawlDepth)) {
await this._safeConfigUpdate('datacollectCrawlDepth', Math.max(0, Math.min(3, Math.floor(msg.crawlDepth))));
}
if (typeof msg.maxPages === 'number' && Number.isFinite(msg.maxPages)) {
await this._safeConfigUpdate('datacollectMaxPages', Math.max(1, Math.min(20, Math.floor(msg.maxPages))));
}
if (typeof msg.synthesisTemperature === 'number' && Number.isFinite(msg.synthesisTemperature)) {
await this._safeConfigUpdate('datacollectSynthesisTemperature', Math.max(0, Math.min(2, msg.synthesisTemperature)));
}
}
private async _refreshState(): Promise<void> {
if (!this._view && !this._panel) return;
const cfg = vscode.workspace.getConfiguration('g1nation');
@@ -620,6 +653,13 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
maxAutoSteps: cfg.get<number>('maxAutoSteps', 50) ?? 50,
maxContextSize: cfg.get<number>('maxContextSize', 32000) ?? 32000,
},
datacollect: {
bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '',
savePath: cfg.get<string>('datacollectSavePath', '') || '',
crawlDepth: cfg.get<number>('datacollectCrawlDepth', 1) ?? 1,
maxPages: cfg.get<number>('datacollectMaxPages', 8) ?? 8,
synthesisTemperature: cfg.get<number>('datacollectSynthesisTemperature', 0.1) ?? 0.1,
},
google: this._buildGoogleState(),
providers: await this._buildProvidersState(),
devilAgent: { enabled: cfg.get<boolean>('devilAgent.enabled', false) },
+6 -10
View File
@@ -16,24 +16,19 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
switch (data.type) {
case 'prompt':
case 'promptWithFile':
console.error(`[ASTRA-DEBUG] prompt case entered type=${data?.type} value=${JSON.stringify(String(data?.value ?? '').slice(0, 80))}`);
provider._lmStudio?.activity.bump();
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
// ── 📻 Datacollect Radio (slash 명령) 우선 분기 ──
// 사용자가 채팅에서 `/research`, `/benchmark`, `/youtube`, `/blog` 같은
// 슬래시 명령을 보내면 Datacollect bridge(3002)로 위임. 회사 모드/일반
// chat 분기보다 먼저 잡아 LLM 토큰을 쓰지 않고 직접 처리한다.
//
// 진단 logging: "/benchmark 입력했는데 아무 답변도 안 옴" 같은 보고가
// 들어왔을 때, OutputChannel(Astra)에 단계별 trace가 남으면 어디서
// 막혔는지 (분기 진입 / view 부재 / bridge 호출 실패) 즉시 판별 가능.
// 주의: globalState.update보다 *먼저* 잡는다 — 글로벌 state가 ~1MB까지
// 누적된 환경에서 update가 느려 첫 prompt가 hang하는 사례 보고됨. slash
// 명령은 LLM을 우회하니 blank chat state 갱신도 필요 없음.
if (typeof data.value === 'string') {
const { isSlashCommand, handleSlashCommand } = await import('../features/datacollect/slashRouter');
const matched = isSlashCommand(data.value);
console.error(`[ASTRA-DEBUG] slash check matched=${matched} hasView=${!!provider._view}`);
logInfo(`[SLASH] prompt received: ${JSON.stringify(data.value).slice(0, 100)} matched=${matched} hasView=${!!provider._view}`);
if (matched) {
if (!provider._view?.webview) {
// webview가 비활성/닫힘 상태면 chunk가 silently drop되므로
// 사용자가 아무 응답도 못 본다. notification으로 즉시 surface.
const msg = '📻 Datacollect Radio: 채팅 webview가 활성 상태가 아닙니다. Astra 사이드바를 한 번 열고 다시 시도해 주세요.';
await vscode.window.showWarningMessage(msg);
logInfo(`[SLASH] webview not available — aborting`);
@@ -45,6 +40,7 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
return true;
}
}
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
// ── 1인 기업 모드 우선 분기 ──
// 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜
// (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅