bfb0d23a2f
[포맷 통일 — Datacollect 가 정본] /wikify 와 Datacollect research 가 각자 포맷 사본을 들고 어긋났던 문제. 더 최신인 Datacollect 포맷을 wiki_format.mjs 정본으로 추출(브리지 측)하고, /wikify 는 GET /api/wiki/template 로 받아 소비. 구버전 브리지면 내장 사본 fallback (정본 v3.1과 동일 내용). 포맷 수정은 이제 wiki_format.mjs 한 곳. /wikify 가 정본을 따르며 고쳐진 것: - category "10_Wiki/Topics"(물리 경로 버그) → 논리 도메인 규칙 - 고정 신뢰도 B/0.8 → 소스 평가 동적 부여 (충돌 신뢰도 권고의 입력 품질) - aliases 빈 배열 → 동의어 3-8개 강제 (어휘갭 검색 보완) - "## 🔗 관련 문서 링크" → "## 🔗 지식 그래프" + 고아 방지 up-link - 인라인 [S#] 출처 인용 + 📚 출처 섹션, 비교표·코드 패턴 조건 섹션 [채팅 URL 접근 — 강제 주입 패턴 4번째 적용] 일반 채팅에 URL 을 주면 "접근 불가"라고 답하던 공백: urlContext 가 URL 감지 시 브리지 /api/web-extract(기존 /wikify 인프라 재사용)로 본문 추출 → 컨텍스트 주입 (8K 캡, 잘림 시 /wikify 안내). 실패 시 정직 블록 (브리지 확인 안내 + 추측 금지). 슬래시 명령은 제외 (자체 처리). 주입 성공 로그 포함. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
7.9 KiB
TypeScript
179 lines
7.9 KiB
TypeScript
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<string>('datacollectBridgeUrl')?.trim()) || 'http://127.0.0.1:3002';
|
|
if (cfg.get<string>('datacollectBridgeTarget', 'local') === 'nas') {
|
|
const nasUrl = cfg.get<string>('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<string>('datacollectBridgeTarget', 'local') !== 'nas') return '';
|
|
return (cfg.get<string>('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',
|
|
template: '/api/wiki/template',
|
|
},
|
|
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<T = any>(
|
|
path: string,
|
|
init?: RequestInit,
|
|
opts: BridgeFetchOptions = {},
|
|
): Promise<T> {
|
|
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);
|
|
}
|
|
}
|