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
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1779186936004,
"createdAt": 1779250764072,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1779186936000,
"createdAt": 1779250764070,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"createdAt": 1779186935997,
"createdAt": 1779250764068,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "---\nid: stress_conflict_1779186935983\ndate: 2026-05-19T10:35:36.005Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (13ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (3ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (4ms)\n",
"createdAt": 1779186936005,
"result": "---\nid: stress_conflict_1779250764052\ndate: 2026-05-20T04:19:24.073Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (14ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (3ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (2ms)\n",
"createdAt": 1779250764073,
"modelVersion": "unknown"
}
@@ -1,8 +1,8 @@
{
"missionId": "stress_conflict_1779186935983",
"missionId": "stress_conflict_1779250764052",
"status": "completed",
"startTime": "2026-05-19T10:35:35.983Z",
"totalElapsedMs": 24,
"startTime": "2026-05-20T04:19:24.052Z",
"totalElapsedMs": 22,
"results": {
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
@@ -16,30 +16,30 @@
{
"from": "idle",
"to": "planner",
"durationMs": 13,
"durationMs": 14,
"message": "전략 수립 중...",
"ts": "2026-05-19T10:35:35.996Z"
"ts": "2026-05-20T04:19:24.066Z"
},
{
"from": "planner",
"to": "researcher",
"durationMs": 3,
"message": "핵심 정보 수집 및 분석 중...",
"ts": "2026-05-19T10:35:35.999Z"
"ts": "2026-05-20T04:19:24.069Z"
},
{
"from": "researcher",
"to": "writer",
"durationMs": 4,
"durationMs": 2,
"message": "최종 리포트 작성 및 편집 중...",
"ts": "2026-05-19T10:35:36.003Z"
"ts": "2026-05-20T04:19:24.071Z"
},
{
"from": "writer",
"to": "completed",
"durationMs": 3,
"message": "미션 완료",
"ts": "2026-05-19T10:35:36.006Z"
"ts": "2026-05-20T04:19:24.074Z"
}
],
"resilienceMetrics": {
+71
View File
@@ -1,5 +1,76 @@
# Astra Patch Notes
## v2.2.41 (2026-05-20)
### 🎛️ /benchmark 합성 Temperature 설정 추가
- **`g1nation.datacollectSynthesisTemperature` 신설** (기본 0.1). `/benchmark` LLM 4-렌즈 합성의 temperature를 Astra Settings 패널 'Datacollect' 섹션에서 조절 가능 — 그동안 코드에 `0.3`으로 하드코딩돼 있었다. 낮출수록(0.1) 한국어 생성 중 섞이는 깨진 문자·환각이 줄고 결과가 결정적이다. 0~2 범위로 클램프.
- **신규 패키징:** `astra-2.2.41.vsix`.
---
## v2.2.40 (2026-05-20)
### 📦 재패키징 (코드 변경 없음)
- v2.2.39와 코드·기능 완전 동일. 버전 번호만 갱신한 재패키징 빌드 — v2.2.39의 변경사항(/benchmark LLM 4-렌즈 합성, 크롤 깊이/페이지 설정)이 모두 그대로 포함된다.
- **신규 패키징:** `astra-2.2.40.vsix`.
---
## v2.2.39 (2026-05-20)
### ✨ /benchmark LLM 4-렌즈 합성 + 크롤 깊이/페이지 설정
- **LLM 4-렌즈 합성 추가 — 결과물이 Datacollect 웹앱과 동등해짐.** 그동안 `/benchmark`는 Playwright 스캔 데이터를 그대로 덤프만 했다(디자인 토큰 raw 목록). 이제 Datacollect 웹앱(WebBenchmarkPanel)과 동일하게 `scan → LLM 3단계 합성(Visual/Layout/Interaction/Voice 4-렌즈 + IA·페이지 템플릿 + 원본 재구축 명세) → 완성된 마크다운 리포트`를 생성한다. Bridge `/api/lm` 프록시 경유, Astra 기본 모델(`g1nation.defaultModel`/`ollamaUrl`)을 사용. 합성 실패 시 raw 스캔 요약으로 자동 fallback.
- **크롤 깊이·최대 페이지 설정 추가.** `g1nation.datacollectCrawlDepth`(기본 1)·`g1nation.datacollectMaxPages`(기본 8) 신설 — Astra Settings 패널 'Datacollect' 섹션에서 편집. 명령어에서 `/benchmark <url> depth=2 pages=12` 로 그때그때 덮어쓰기 가능 (우선순위: 명령어 > 설정 > 기본값).
- **명령어 보조 컨텍스트.** `/benchmark <url> [depth=N] [pages=N] <자연어 설명>` 형태로 URL 뒤에 붙인 자연어는 합성 part 1·2의 톤 추정 보조 컨텍스트로 전달된다.
- **신규 패키징:** `astra-2.2.39.vsix`.
---
## v2.2.38 (2026-05-20)
### 🐛 /benchmark URL 파싱 fix — 자연어 섞인 입력 + 빈 결과 경고
- **URL 토큰만 추출.** `/benchmark www.caliverse.io 분석해줘` 처럼 URL 뒤에 자연어를 붙이면 "분석해줘"까지 URL에 섞여 `https://www.caliverse.io 분석해줘.` 라는 무효 URL이 되고, Playwright가 페이지를 못 열어 meta·디자인·마이크로카피가 전부 `(없음)`으로 나오던 문제. 이제 arg의 첫 공백 전 토큰만 URL로 사용한다. (스캔은 Playwright 결정론적 추출이라 local LLM과 무관 — 빈 결과는 LLM 문제가 아니라 URL 오염이었다.)
- **빈 스캔 결과 경고.** 스캔이 에러 없이 끝나도 meta·design·microcopy가 모두 비면 ⚠️ 경고를 표시 — URL 오타·봇 차단·JS 전용 렌더링을 사용자가 즉시 인지.
- **신규 패키징:** `astra-2.2.38.vsix`.
---
## v2.2.37 (2026-05-20)
### ✅ Slash 결과물 미표시 근본 fix + /benchmark 진행 표시·결과물 저장
- **`streamStart` 누락 수정 — slash 명령 결과가 화면에 안 보이던 진짜 원인.** v2.2.30이 `streamEnd`(input 잠금 해제)만 보냈고 `streamStart`는 빠뜨렸다. webview는 `streamStart`를 받아야 assistant 메시지 버블(`streamBody`)을 만들고, 그게 없으면 이후 모든 `streamChunk`(=결과 텍스트, 에러 메시지 포함)를 silently drop한다. `handleSlashCommand` 시작부에서 `streamStart`를 명시적으로 보내도록 수정 — `/research`·`/benchmark`·`/youtube`·`/blog` 모두 결과가 정상 표시됨.
- **`/benchmark` 진행 표시.** scan은 단일 동기 호출이라 진행률 스트림이 없어 사용자가 멈춘 줄 오해하던 문제 → 4초마다 경과 시간(`·4s ·8s …`)을 한 줄에 누적 표시하고, 완료 시 `✅ 스캔 완료 (Ns 소요)` 출력.
- **`/benchmark` 결과물 자동 저장.** scan 결과를 markdown 리포트로 합성해 Bridge `/api/wiki/save`로 저장. 저장 위치는 신규 설정 `g1nation.datacollectSavePath`(비우면 Bridge의 `WIKI_RAW_PATH` 환경변수가 결정 — 코드에 절대경로 하드코딩 없음).
- **Settings 패널에 "Datacollect" 섹션 추가.** Bridge URL과 결과물 저장 폴더를 Astra Settings 패널에서 직접 편집 가능.
- **신규 패키징:** `astra-2.2.37.vsix`.
---
## v2.2.36 (2026-05-20)
### 🛠️ Antigravity 빌트인 "Connect AI"와의 view 충돌 해소
- **사용자 보고 console에 `Connect AI extension activated` + 우리 `[ASTRA-DEBUG]` 로그 0개** → 우리 vsix activate 함수가 호출조차 안 되고 있다는 결정적 증거. Antigravity가 빌트인으로 가진 "Connect AI" extension이 우리 view container id `astra-activity` / view id `astra-launcher` / title `Astra` 영역과 충돌해 우리 코드를 덮은 것으로 추정.
- **view container/view id를 unique하게 변경**: `astra-activity``g1nation-astra-activity`, `astra-launcher``g1nation-astra-launcher`, title `Astra``Astra (g1nation)`. 좌측 활성바에 빌트인과 별도의 우리 아이콘이 표시되며, 그 아이콘 클릭 시 우리 activate가 호출됨.
- **신규 패키징:** `astra-2.2.36.vsix`.
---
## v2.2.35 (2026-05-20)
### 🛠️ Slash 미동작 결정적 fix: globalState 이전 진입 + DevTools console 진단
- **globalState write보다 *먼저* slash 분기를 잡도록 변경.** `g1nation.astra` global state가 ~917KB로 누적된 환경에서 `globalState.update` 호출이 첫 prompt를 지연/hang시키는 사례 보고됨. slash 명령은 LLM/blank-chat state 갱신과 무관하므로 update를 건너뛴다.
- **DevTools console에 ASTRA-DEBUG trace.** OutputChannel(`logInfo`)·popup·statusbar에 더해 `console.error``[ASTRA-DEBUG] activate vX.Y.Z`, `[ASTRA-DEBUG] prompt case entered`, `[ASTRA-DEBUG] slash check matched=…`, `[ASTRA-DEBUG] slashRouter handleSlashCommand`을 출력. 사용자가 F12 DevTools console을 볼 때 우리 코드 실행 단계가 즉시 보임.
- **신규 패키징:** `astra-2.2.35.vsix`.
---
## v2.2.34 (2026-05-19)
### 🚨 결정적 진단: activate / slash 진입 popup notification
- **Activation 시점 popup:** VS Code/Antigravity가 시작될 때 화면 우측 하단에 `📡 Astra vX.Y.Z activated (PID=...)` 알림. 우리 vsix가 실제로 활성화됐는지 1초 안에 확인 가능. 같은 이름의 빌트인 extension이 우리 코드를 가리는 케이스 발견용.
+45
View File
@@ -45,6 +45,51 @@
</div>
</section>
<!-- Datacollect -->
<section class="section" data-section="datacollect">
<h2>Datacollect (slash 명령)</h2>
<p class="hint">채팅에서 <code>/research</code> · <code>/benchmark</code> · <code>/youtube</code> 를 입력하면 Datacollect Bridge로 위임됩니다. Bridge는 Datacollect 프로젝트에서 <code>npm run bridge</code> 로 실행해야 합니다.</p>
<div class="row">
<label for="dcBridgeUrl">Bridge URL</label>
<div class="input-group">
<input id="dcBridgeUrl" type="text" placeholder="http://127.0.0.1:3002" spellcheck="false" />
<button data-save="datacollect.bridgeUrl">저장</button>
</div>
</div>
<div class="row">
<label for="dcSavePath">결과물 저장 폴더</label>
<div class="input-group">
<input id="dcSavePath" type="text" placeholder="(비우면 Bridge 기본 위치)" spellcheck="false" />
<button data-save="datacollect.savePath">저장</button>
</div>
<small class="hint">/benchmark 등의 결과 markdown 저장 위치. 비워두면 Bridge의 <code>WIKI_RAW_PATH</code> 환경변수가 결정합니다 (코드에 절대경로 하드코딩 없음). 특정 폴더로 저장하려면 절대경로를 입력하세요.</small>
</div>
<div class="row">
<label for="dcCrawlDepth">크롤 깊이 기본값</label>
<div class="input-group narrow">
<input id="dcCrawlDepth" type="number" min="0" max="3" step="1" />
<button data-save="datacollect.crawlDepth">저장</button>
</div>
<small class="hint">/benchmark 사이트맵 크롤 깊이. 0=루트만, 1=직속 자식, 2=손자, 3=깊은 크롤. 명령어에서 <code>depth=N</code> 으로 그때그때 덮어쓸 수 있습니다.</small>
</div>
<div class="row">
<label for="dcMaxPages">최대 페이지 기본값</label>
<div class="input-group narrow">
<input id="dcMaxPages" type="number" min="1" max="20" step="1" />
<button data-save="datacollect.maxPages">저장</button>
</div>
<small class="hint">/benchmark 스캔 최대 페이지 수. 명령어에서 <code>pages=N</code> 으로 덮어쓸 수 있습니다 (Bridge 상한 20).</small>
</div>
<div class="row">
<label for="dcSynthTemp">합성 Temperature</label>
<div class="input-group narrow">
<input id="dcSynthTemp" type="number" min="0" max="2" step="0.05" />
<button data-save="datacollect.synthesisTemperature">저장</button>
</div>
<small class="hint">/benchmark LLM 4-렌즈 합성의 temperature. 낮을수록(0.1) 환각·깨진 문자가 줄고 결정적입니다. 기본 0.1 권장.</small>
</div>
</section>
<!-- Memory -->
<section class="section" data-section="memory">
<h2>메모리</h2>
+34
View File
@@ -24,6 +24,13 @@
const cnRefreshModels = $('cnRefreshModels');
const cnModelHint = $('cnModelHint');
// ---- Datacollect ----
const dcBridgeUrl = $('dcBridgeUrl');
const dcSavePath = $('dcSavePath');
const dcCrawlDepth = $('dcCrawlDepth');
const dcMaxPages = $('dcMaxPages');
const dcSynthTemp = $('dcSynthTemp');
// ---- Memory ----
const memEnabled = $('memEnabled');
const memShort = $('memShort');
@@ -113,6 +120,23 @@
vscode.postMessage({ type: 'connection.update', requestTimeout: Number(cnTimeout.value) })
);
// ---- Datacollect listeners ----
document.querySelector('[data-save="datacollect.bridgeUrl"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', bridgeUrl: dcBridgeUrl.value })
);
document.querySelector('[data-save="datacollect.savePath"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', savePath: dcSavePath.value })
);
document.querySelector('[data-save="datacollect.crawlDepth"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', crawlDepth: Number(dcCrawlDepth.value) })
);
document.querySelector('[data-save="datacollect.maxPages"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', maxPages: Number(dcMaxPages.value) })
);
document.querySelector('[data-save="datacollect.synthesisTemperature"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', synthesisTemperature: Number(dcSynthTemp.value) })
);
// ---- Memory listeners ----
memEnabled.addEventListener('change', (e) =>
vscode.postMessage({ type: 'memory.update', memoryEnabled: e.target.checked })
@@ -338,6 +362,16 @@
? '모델 목록 가져오는 중…'
: `사이드바에서 선택한 모델이 여기에도 동기화됩니다. (${list.length}개 발견)`;
// ---- Datacollect ----
const dc = state.datacollect;
if (dc) {
setIfNotFocused(dcBridgeUrl, dc.bridgeUrl);
setIfNotFocused(dcSavePath, dc.savePath);
setIfNotFocused(dcCrawlDepth, dc.crawlDepth);
setIfNotFocused(dcMaxPages, dc.maxPages);
setIfNotFocused(dcSynthTemp, dc.synthesisTemperature);
}
// ---- Memory ----
const mem = state.memory;
memEnabled.checked = !!mem.memoryEnabled;
+33 -7
View File
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.2.34",
"version": "2.2.41",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -171,23 +171,23 @@
"viewsContainers": {
"activitybar": [
{
"id": "astra-activity",
"title": "Astra",
"id": "g1nation-astra-activity",
"title": "Astra (g1nation)",
"icon": "assets/icon-activitybar.svg"
}
]
},
"views": {
"astra-activity": [
"g1nation-astra-activity": [
{
"id": "astra-launcher",
"name": "Astra Launcher"
"id": "g1nation-astra-launcher",
"name": "Astra (g1nation) Launcher"
}
]
},
"viewsWelcome": [
{
"view": "astra-launcher",
"view": "g1nation-astra-launcher",
"contents": "✦ **Astra** — 로컬 AI 인텔리전스 레이어\n\nChat 탭을 닫았을 때 여기서 다시 열 수 있습니다.\n\n[$(comment-discussion) Open Chat](command:g1nation.openChat)\n[$(add) New Chat](command:g1nation.newChat)\n[$(gear) Settings](command:g1nation.settings.focus)\n\n---\n\n**1인 기업 모드**\n\n[$(organization) Manage Agents](command:g1nation.company.manage)\n[$(folder-opened) Open Sessions Folder](command:g1nation.company.openSessions)\n\n---\n\n**Project Architecture**\n\n[$(file-text) Open Architecture Doc](command:g1nation.architecture.open)\n[$(refresh) Refresh Architecture](command:g1nation.architecture.refresh)\n\n---\n\n**Lessons / Knowledge**\n\n[$(lightbulb) Manage Lessons](command:g1nation.lesson.manage)\n[$(edit) Edit Agent ↔ Knowledge Map](command:g1nation.skills.editKnowledgeMap)"
}
],
@@ -204,6 +204,32 @@
"default": "http://127.0.0.1:3002",
"description": "Wiki/Datacollect MCP Bridge URL. /research, /benchmark, /youtube chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project)."
},
"g1nation.datacollectSavePath": {
"type": "string",
"default": "",
"markdownDescription": "`/benchmark` 등 Datacollect slash 명령 결과물(markdown)을 저장할 폴더. **비워두면** Bridge 기본 위치(Bridge의 `WIKI_RAW_PATH` 환경변수)에 저장됩니다 — 코드/설정 어디에도 절대경로가 박히지 않습니다. 특정 폴더로 저장하려면 절대경로를 입력하세요. Astra Settings 패널의 'Datacollect' 섹션에서도 편집 가능."
},
"g1nation.datacollectCrawlDepth": {
"type": "number",
"default": 1,
"minimum": 0,
"maximum": 3,
"markdownDescription": "`/benchmark` 사이트맵 크롤 깊이 기본값. 0=루트만, 1=직속 자식, 2=손자, 3=깊은 크롤. 명령어에서 `depth=N`으로 그때그때 덮어쓸 수 있습니다. (단일 페이지 SPA는 깊이를 올려도 1페이지)"
},
"g1nation.datacollectMaxPages": {
"type": "number",
"default": 8,
"minimum": 1,
"maximum": 20,
"markdownDescription": "`/benchmark` 스캔 최대 페이지 수 기본값. 명령어에서 `pages=N`으로 덮어쓸 수 있습니다. Bridge에서 20으로 상한이 적용됩니다."
},
"g1nation.datacollectSynthesisTemperature": {
"type": "number",
"default": 0.1,
"minimum": 0,
"maximum": 2,
"markdownDescription": "`/benchmark` LLM 4-렌즈 합성의 temperature. 낮을수록(0.1) 환각·깨진 문자가 줄고 결과가 결정적이며, 높을수록 표현이 다양해집니다. 기본 0.1 권장."
},
"g1nation.memoryEnabled": {
"type": "boolean",
"default": true,
+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) 후속 대화 → 일반 채팅