import * as vscode from 'vscode'; /** * Datacollect (Wiki/Datacollect 프로젝트)의 MCP Bridge HTTP 클라이언트. * * Bridge는 사용자가 별도로 띄우는 Node Express 서버(`npm run bridge`)이고 * 기본 포트는 3002. Web Benchmark(Playwright)/YouTube(yt-dlp+transcript)/Wikify * 같은 무거운 기능을 노출하므로, Astra는 이 endpoint를 thin client로 호출만 한다 * — Playwright/Chrome/Python 의존성을 Astra가 직접 들고 갈 필요 없음. * (NotebookLM Deep Research 는 ASTRA 에서 제거 — 로컬 Datacollect 앱 전용.) * * 타깃은 `g1nation.datacollectBridgeTarget`(`local`|`nas`)으로 전환한다. * - local(기본): `g1nation.datacollectBridgeUrl` (기본 `http://127.0.0.1:3002`) * - nas: `g1nation.datacollectBridgeNasUrl` (+ `datacollectBridgeNasToken` 헤더) * nas 인데 URL 이 비어 있으면 안전하게 local 로 폴백한다(절대 깨지지 않게). */ export function getBridgeBaseUrl(): string { const cfg = vscode.workspace.getConfiguration('g1nation'); const localUrl = (cfg.get('datacollectBridgeUrl')?.trim()) || 'http://127.0.0.1:3002'; if (cfg.get('datacollectBridgeTarget', 'local') === 'nas') { const nasUrl = cfg.get('datacollectBridgeNasUrl')?.trim(); if (nasUrl) return nasUrl.replace(/\/$/, ''); // nas 선택했으나 URL 미설정 → 로컬로 폴백 (구동 끊기지 않게). } return localUrl.replace(/\/$/, ''); } /** * nas 타깃일 때 NAS Bridge 의 `x-bridge-token` 값. local 이거나 미설정이면 ''. * bridgeFetch 가 이 값을 요청 헤더에 실어 보낸다(빈 문자열이면 헤더 미부착). */ export function getBridgeAuthToken(): string { const cfg = vscode.workspace.getConfiguration('g1nation'); if (cfg.get('datacollectBridgeTarget', 'local') !== 'nas') return ''; return (cfg.get('datacollectBridgeNasToken')?.trim()) || ''; } /** * Datacollect Bridge API endpoints — 한 곳에서 관리. * * 이전엔 슬래시 명령마다 endpoint 문자열이 hardcoded 였음 → bridge API 버전이 * 바뀌면 5+ 곳을 동시 수정. 이 객체로 모아 한 곳만 바꾸면 됨. * * 카테고리: * - youtube: yt-dlp + youtube-transcript-api 기반 영상 분석 * - web: Playwright 기반 웹 페이지 추출·벤치마크 * - wiki: 생성된 위키 문서 디스크 저장 * - lm: 사용자의 LM Studio / Ollama 로 LLM 호출 프록시 */ export const BRIDGE_API = { // research(NotebookLM)는 ASTRA 에서 제거됨(v2.2.205) — 로컬 Datacollect 앱 전용. youtube: { extract: '/api/youtube/extract', }, web: { benchmarkScan: '/api/web-benchmark/scan', extract: '/api/web-extract', }, wiki: { save: '/api/wiki/save', }, lm: { proxy: '/api/lm', }, } as const; export interface BridgeFetchOptions { timeoutMs?: number; signal?: AbortSignal; /** * 호출이 N ms 이상 지속되면 N ms 마다 한 번씩 호출되는 콜백. 긴 호출 * (synthesize / scan / import) 에서 사용자에게 "살아있다" 신호를 흘리려고 * 도입. 콜백은 fire-and-forget 으로 호출되며 예외는 silently swallow. * 기본은 호출되지 않음. */ onHeartbeat?: (elapsedMs: number) => void; /** heartbeat 간격 (ms). 미지정 시 30s. */ heartbeatMs?: number; } /** * Bridge endpoint를 호출하고 JSON 응답을 돌려준다. 5xx/4xx는 throw로 surface — * `/api/research/start` 같은 핸들러가 `{ success: false, stage, error }` 형식의 * 풍부한 진단 정보를 body에 담기 시작했으므로(이전 라운드 추가), throw 메시지에 * `[stage] error` 형태로 포함해 slashRouter 쪽에서 사용자에게 그대로 보여준다. */ export async function bridgeFetch( path: string, init?: RequestInit, opts: BridgeFetchOptions = {}, ): Promise { const base = getBridgeBaseUrl(); const url = `${base}${path.startsWith('/') ? path : `/${path}`}`; const controller = new AbortController(); const timeoutMs = opts.timeoutMs ?? 60_000; const timer = setTimeout(() => controller.abort(), timeoutMs); // 호출자 signal이 abort되면 그대로 forward. if (opts.signal) { if (opts.signal.aborted) controller.abort(); else opts.signal.addEventListener('abort', () => controller.abort(), { once: true }); } // Heartbeat — 긴 LLM synthesize / Playwright scan 도중에도 사용자에게 // "살아있다" 신호. 호출자가 onHeartbeat 안 줬으면 비활성. const heartbeatStartedAt = Date.now(); let heartbeatInterval: NodeJS.Timeout | undefined; if (opts.onHeartbeat) { const intervalMs = Math.max(5_000, opts.heartbeatMs ?? 30_000); heartbeatInterval = setInterval(() => { try { opts.onHeartbeat!(Date.now() - heartbeatStartedAt); } catch { /* noop */ } }, intervalMs); } try { const token = getBridgeAuthToken(); const res = await fetch(url, { ...init, signal: controller.signal, headers: { 'Content-Type': 'application/json', ...(token ? { 'x-bridge-token': token } : {}), ...(init?.headers || {}), }, }); const text = await res.text(); let body: any = text; try { body = JSON.parse(text); } catch { /* keep as text */ } if (!res.ok) { const stage = body?.stage ? `[${body.stage}] ` : ''; // Bridge 가 에러 body 를 객체로 보낼 때 (e.g. `{error: {message, code, details}}`) // 옛 포맷터는 `body.error` 가 객체면 `${}` 보간이 `[object Object]` 로 깨져 // 사용자가 실제 원인 메시지를 못 봄. 문자열 추출을 우선순위대로 시도: // 1) body.error.message (구조화된 에러) // 2) body.error (문자열일 때) // 3) body.message (외곽 message) // 4) body 가 통째로 문자열 // 5) JSON.stringify(body.error) (최후 — 구조 그대로 노출) // 6) HTTP status 만 const extractErr = (): string => { if (body?.error?.message && typeof body.error.message === 'string') return body.error.message; if (typeof body?.error === 'string') return body.error; if (typeof body?.message === 'string') return body.message; if (typeof body === 'string') return body; if (body?.error) { try { return JSON.stringify(body.error).slice(0, 400); } catch { /* fall through */ } } return `HTTP ${res.status}`; }; throw new Error(`Datacollect ${path} 실패: ${stage}${extractErr()}`); } return body as T; } catch (e: any) { if (e?.name === 'AbortError') { // 외부 signal 로 인한 abort 인지 timeout 인지 구분해서 안내. if (opts.signal?.aborted) { throw new Error(`Datacollect ${path} 취소됨 (사용자 abort).`); } throw new Error(`Datacollect ${path} 시간 초과 (${timeoutMs}ms). Bridge가 응답하지 않습니다 (${base}).`); } // ECONNREFUSED 등 connect 실패는 친절히 안내. const msg = String(e?.message || e); if (msg.includes('ECONNREFUSED') || msg.includes('fetch failed')) { throw new Error( `Datacollect Bridge(${base})에 연결할 수 없습니다. ` + `Wiki/Datacollect 프로젝트에서 \`npm run bridge\`를 실행하고 다시 시도하세요.`, ); } throw e; } finally { clearTimeout(timer); if (heartbeatInterval) clearInterval(heartbeatInterval); } }