NotebookLM CLI 로그인 버튼 — nlm 실행 파일 자동 탐색

- 사이드바 도구 메뉴에 'NotebookLM 로그인' 버튼 추가, 파이썬 Scripts 폴더에서
  nlm(.exe) 자동 탐색(버전 하드코딩 없음) 후 login 인자로 실행
- g1nation.notebookLmCliPath 설정으로 수동 경로 지정도 가능
This commit is contained in:
2026-08-06 18:51:22 +09:00
parent 769cf01a99
commit b61e06ef41
13 changed files with 173 additions and 21 deletions
@@ -1,5 +1,5 @@
{ {
"result": "직답 결과 — single-pass mock 응답입니다.", "result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1785981204343, "createdAt": 1786009859146,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "---\nid: wiki_on\ndate: 2026-08-06T01:53:24.345Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\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- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (34ms)\n", "result": "---\nid: wiki_on\ndate: 2026-08-06T09:50:59.150Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\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- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (23ms)\n",
"createdAt": 1785981204345, "createdAt": 1786009859151,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,7 +1,7 @@
{ {
"missionId": "wiki_on", "missionId": "wiki_on",
"status": "completed", "status": "completed",
"startTime": "2026-08-06T01:53:24.307Z", "startTime": "2026-08-06T09:50:59.117Z",
"totalElapsedMs": 40, "totalElapsedMs": 40,
"results": { "results": {
"direct": "직답 결과 — single-pass mock 응답입니다." "direct": "직답 결과 — single-pass mock 응답입니다."
@@ -12,16 +12,16 @@
{ {
"from": "idle", "from": "idle",
"to": "direct", "to": "direct",
"durationMs": 34, "durationMs": 23,
"message": "답변 작성 중... (단일 호출 fast-path)", "message": "답변 작성 중... (단일 호출 fast-path)",
"ts": "2026-08-06T01:53:24.341Z" "ts": "2026-08-06T09:50:59.140Z"
}, },
{ {
"from": "direct", "from": "direct",
"to": "completed", "to": "completed",
"durationMs": 6, "durationMs": 14,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-08-06T01:53:24.347Z" "ts": "2026-08-06T09:50:59.154Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1785981213594, "createdAt": 1786009865515,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1785981213593, "createdAt": 1786009865514,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1785981213590, "createdAt": 1786009865510,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1785981213592, "createdAt": 1786009865512,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,7 +1,7 @@
{ {
"missionId": "stress_conflict_1785981213570", "missionId": "stress_conflict_1786009865491",
"status": "completed", "status": "completed",
"startTime": "2026-08-06T01:53:33.570Z", "startTime": "2026-08-06T09:51:05.491Z",
"totalElapsedMs": 25, "totalElapsedMs": 25,
"results": { "results": {
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
@@ -14,30 +14,30 @@
{ {
"from": "idle", "from": "idle",
"to": "outline", "to": "outline",
"durationMs": 19, "durationMs": 18,
"message": "답변 구조 잡는 중...", "message": "답변 구조 잡는 중...",
"ts": "2026-08-06T01:53:33.589Z" "ts": "2026-08-06T09:51:05.509Z"
}, },
{ {
"from": "outline", "from": "outline",
"to": "section", "to": "section",
"durationMs": 2, "durationMs": 2,
"message": "본문 작성 중...", "message": "본문 작성 중...",
"ts": "2026-08-06T01:53:33.591Z" "ts": "2026-08-06T09:51:05.511Z"
}, },
{ {
"from": "section", "from": "section",
"to": "polish", "to": "polish",
"durationMs": 1, "durationMs": 2,
"message": "최종 다듬기 중...", "message": "최종 다듬기 중...",
"ts": "2026-08-06T01:53:33.592Z" "ts": "2026-08-06T09:51:05.513Z"
}, },
{ {
"from": "polish", "from": "polish",
"to": "completed", "to": "completed",
"durationMs": 3, "durationMs": 3,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-08-06T01:53:33.595Z" "ts": "2026-08-06T09:51:05.516Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
+1
View File
@@ -56,6 +56,7 @@
<button class="hdr-menu-item" id="brainBtn" data-tooltip="두뇌(지식) 폴더 변경사항을 git commit + push">두뇌 동기화</button> <button class="hdr-menu-item" id="brainBtn" data-tooltip="두뇌(지식) 폴더 변경사항을 git commit + push">두뇌 동기화</button>
<button class="hdr-menu-item" id="nlmBridgeStartBtn" data-tooltip="Datacollector Research(NotebookLM)용 로컬 백엔드를 실행합니다 — 터미널 조작 불필요">NotebookLM 백엔드 실행</button> <button class="hdr-menu-item" id="nlmBridgeStartBtn" data-tooltip="Datacollector Research(NotebookLM)용 로컬 백엔드를 실행합니다 — 터미널 조작 불필요">NotebookLM 백엔드 실행</button>
<button class="hdr-menu-item" id="nlmBridgeStopBtn" data-tooltip="로컬 백엔드 종료 (NAS 브리지·Astra 기능에는 영향 없음)">NotebookLM 백엔드 종료</button> <button class="hdr-menu-item" id="nlmBridgeStopBtn" data-tooltip="로컬 백엔드 종료 (NAS 브리지·Astra 기능에는 영향 없음)">NotebookLM 백엔드 종료</button>
<button class="hdr-menu-item" id="nlmLoginBtn" data-tooltip="구글 세션이 만료돼 리서치가 빈 응답을 주거나 로그인 창이 반복해서 뜰 때 — 브라우저에서 인증을 한 번 다시 합니다">NotebookLM 로그인</button>
</div> </div>
</div> </div>
<button class="icon-btn" id="historyBtn" data-tooltip="이전 대화 기록 보기">기록</button> <button class="icon-btn" id="historyBtn" data-tooltip="이전 대화 기록 보기">기록</button>
+2
View File
@@ -1946,6 +1946,8 @@
if (nlmBridgeStartBtn) nlmBridgeStartBtn.onclick = () => vscode.postMessage({ type: 'startNotebookLmBridge' }); if (nlmBridgeStartBtn) nlmBridgeStartBtn.onclick = () => vscode.postMessage({ type: 'startNotebookLmBridge' });
const nlmBridgeStopBtn = document.getElementById('nlmBridgeStopBtn'); const nlmBridgeStopBtn = document.getElementById('nlmBridgeStopBtn');
if (nlmBridgeStopBtn) nlmBridgeStopBtn.onclick = () => vscode.postMessage({ type: 'stopNotebookLmBridge' }); if (nlmBridgeStopBtn) nlmBridgeStopBtn.onclick = () => vscode.postMessage({ type: 'stopNotebookLmBridge' });
const nlmLoginBtn = document.getElementById('nlmLoginBtn');
if (nlmLoginBtn) nlmLoginBtn.onclick = () => vscode.postMessage({ type: 'loginNotebookLm' });
addBrainBtn.onclick = () => vscode.postMessage({ type: 'addBrain' }); addBrainBtn.onclick = () => vscode.postMessage({ type: 'addBrain' });
// v2.2.67 — 만약 dropdown 이 'new' 상태로 굳어있어도 수정/삭제는 직전 유효 선택(또는 첫 실제 옵션) // v2.2.67 — 만약 dropdown 이 'new' 상태로 굳어있어도 수정/삭제는 직전 유효 선택(또는 첫 실제 옵션)
// 을 기준으로 동작하도록 폴백. 이전엔 'new' 인 순간 그냥 early-return 해서 "수정 안 됨" 버그 발생. // 을 기준으로 동작하도록 폴백. 이전엔 'new' 인 순간 그냥 early-return 해서 "수정 안 됨" 버그 발생.
+5
View File
@@ -293,6 +293,11 @@
"default": "", "default": "",
"markdownDescription": "로컬 Datacollector 프로젝트 폴더 경로 (도구 메뉴의 'NotebookLM 백엔드 실행' 버튼이 여기서 `npm run bridge` 를 실행). 비워두면 워크스페이스 주변에서 자동 탐색하고, 못 찾으면 버튼 클릭 시 폴더 선택 창이 떠서 자동 저장됩니다." "markdownDescription": "로컬 Datacollector 프로젝트 폴더 경로 (도구 메뉴의 'NotebookLM 백엔드 실행' 버튼이 여기서 `npm run bridge` 를 실행). 비워두면 워크스페이스 주변에서 자동 탐색하고, 못 찾으면 버튼 클릭 시 폴더 선택 창이 떠서 자동 저장됩니다."
}, },
"g1nation.notebookLmCliPath": {
"type": "string",
"default": "",
"markdownDescription": "NotebookLM CLI(`nlm`) 실행 파일 경로 (도구 메뉴의 'NotebookLM 로그인' 버튼이 여기에 `login` 인자를 붙여 실행). 비워두면 파이썬 Scripts 폴더들에서 자동 탐색하고(버전이 올라가도 찾음), 못 찾으면 버튼 클릭 시 파일 선택 창이 떠서 자동 저장됩니다."
},
"g1nation.datacollectSavePath": { "g1nation.datacollectSavePath": {
"type": "string", "type": "string",
"default": "", "default": "",
+111
View File
@@ -23,6 +23,7 @@ import { execShell } from '../../lib/execUtil';
*/ */
const TERMINAL_NAME = 'NotebookLM Bridge'; const TERMINAL_NAME = 'NotebookLM Bridge';
const LOGIN_TERMINAL_NAME = 'NotebookLM Login';
const BRIDGE_PORT = 3002; const BRIDGE_PORT = 3002;
/** 자동 탐색 시 찾는 프로젝트 폴더 이름 후보 (맥/윈도우 클론 이름이 다를 수 있음). */ /** 자동 탐색 시 찾는 프로젝트 폴더 이름 후보 (맥/윈도우 클론 이름이 다를 수 있음). */
const PROJECT_DIR_NAMES = ['Datacollector_MAC', 'Datacollector', 'Datacollect']; const PROJECT_DIR_NAMES = ['Datacollector_MAC', 'Datacollector', 'Datacollect'];
@@ -55,6 +56,71 @@ export function buildPortKillCommand(platform: NodeJS.Platform, port: number): s
return `lsof -ti :${port} | xargs kill 2>/dev/null`; return `lsof -ti :${port} | xargs kill 2>/dev/null`;
} }
/**
* `nlm` 실행 파일 후보 경로를 우선순위대로 나열한다 (존재 검증 안 함 — 순수 함수).
*
* nlm 은 pip 로 설치되는 콘솔 스크립트라 PATH 에 없는 경우가 흔하다(실측: 이 PC 도 PATH 에
* 없고 %APPDATA%\Python\Python314\Scripts 에만 있었다). 파이썬 버전이 올라가면 경로가
* 바뀌므로 특정 버전을 하드코딩하지 않고 Scripts 디렉터리들을 훑는다.
* 마지막 후보로 맨 이름('nlm')을 두어 PATH 에 있는 환경에서는 그대로 동작하게 한다.
*/
export function candidateNlmPaths(
platform: NodeJS.Platform,
env: Record<string, string | undefined>,
pythonScriptDirs: string[] = [],
cfgPath = '',
): string[] {
const out: string[] = [];
const push = (p: string) => { if (p && !out.includes(p)) out.push(p); };
if (cfgPath && cfgPath.trim()) push(cfgPath.trim());
if (platform === 'win32') {
for (const d of pythonScriptDirs) push(path.join(d, 'nlm.exe'));
const local = env.LOCALAPPDATA;
if (local) push(path.join(local, 'Programs', 'Python', 'Scripts', 'nlm.exe'));
push('nlm.exe');
} else {
for (const d of pythonScriptDirs) push(path.join(d, 'nlm'));
const home = env.HOME;
if (home) {
push(path.join(home, '.local', 'bin', 'nlm'));
push(path.join(home, 'Library', 'Python', 'bin', 'nlm'));
}
push('/usr/local/bin/nlm');
}
push('nlm'); // PATH 폴백
return out;
}
/** 파이썬 사용자 설치 경로 아래의 Scripts(win) / bin(mac) 디렉터리들을 나열한다. */
function pythonScriptDirs(): string[] {
const dirs: string[] = [];
const roots = process.platform === 'win32'
? [process.env.APPDATA ? path.join(process.env.APPDATA, 'Python') : '']
: [process.env.HOME ? path.join(process.env.HOME, 'Library', 'Python') : ''];
for (const root of roots.filter(Boolean)) {
try {
for (const name of fs.readdirSync(root)) {
const s = path.join(root, name, 'Scripts');
const b = path.join(root, name, 'bin');
if (fs.existsSync(s)) dirs.push(s);
if (fs.existsSync(b)) dirs.push(b);
}
} catch { /* 접근 불가 — skip */ }
}
return dirs;
}
/** 실제 존재하는 nlm 실행 파일 경로. 못 찾으면 null (PATH 폴백은 호출측이 판단). */
function findNlmExecutable(): string | null {
const cfg = vscode.workspace.getConfiguration('g1nation').get<string>('notebookLmCliPath', '') || '';
for (const cand of candidateNlmPaths(process.platform, process.env, pythonScriptDirs(), cfg)) {
if (!path.isAbsolute(cand)) continue; // PATH 이름은 존재 검증 대상이 아님
try { if (fs.existsSync(cand)) return cand; } catch { /* skip */ }
}
return null;
}
function findBridgeProject(): string | null { function findBridgeProject(): string | null {
const cfgPath = vscode.workspace.getConfiguration('g1nation').get<string>('datacollectLocalProjectPath', '') || ''; const cfgPath = vscode.workspace.getConfiguration('g1nation').get<string>('datacollectLocalProjectPath', '') || '';
const wsDirs = (vscode.workspace.workspaceFolders || []) const wsDirs = (vscode.workspace.workspaceFolders || [])
@@ -141,6 +207,48 @@ export async function stopNotebookLmBridge(): Promise<void> {
} }
} }
/**
* NotebookLM 재로그인(구글 인증) 원클릭.
*
* 배경: NotebookLM 토큰은 ~90분에 만료되고, 전용 프로필의 구글 **세션 자체**가 끊기면
* 자동 갱신(Datacollect 의 스케줄 작업)이 매번 실패하면서 로그인 창만 반복해서 띄운다.
* 복구는 `nlm login` 을 한 번 사람이 실행해 브라우저에서 인증하는 것뿐인데, 그러려면
* 터미널과 실행 파일 경로를 알아야 했다 — 그 한 단계를 버튼으로 없앤다.
*
* 구현: 셸을 거치지 않고 실행 파일을 터미널 프로세스로 직접 띄운다(shellPath/shellArgs).
* 경로에 공백이 있어도 따옴표 처리가 필요 없고, PowerShell/cmd/zsh 차이도 사라진다.
* 인증 결과(성공/실패)는 터미널에 그대로 보이므로 별도 폴링을 하지 않는다.
*/
export async function loginNotebookLm(): Promise<void> {
let exe = findNlmExecutable();
if (!exe) {
const pick = await vscode.window.showErrorMessage(
'NotebookLM CLI(nlm) 실행 파일을 찾지 못했습니다. 파일을 직접 선택하면 설정에 저장되어 다음부터는 버튼만 누르면 됩니다.',
'파일 선택'
);
if (pick !== '파일 선택') return;
const chosen = await vscode.window.showOpenDialog({
canSelectFiles: true, canSelectFolders: false, canSelectMany: false,
openLabel: 'nlm 실행 파일 선택',
});
const f = chosen?.[0]?.fsPath;
if (!f) return;
await vscode.workspace.getConfiguration('g1nation')
.update('notebookLmCliPath', f, vscode.ConfigurationTarget.Global);
exe = f;
}
vscode.window.terminals.find(t => t.name === LOGIN_TERMINAL_NAME)?.dispose();
const terminal = vscode.window.createTerminal({
name: LOGIN_TERMINAL_NAME, shellPath: exe, shellArgs: ['login'],
});
terminal.show(true);
vscode.window.showInformationMessage(
'NotebookLM 로그인을 시작했습니다 — 브라우저에서 구글 인증을 완료하세요. ' +
'결과는 "NotebookLM Login" 터미널에 표시됩니다.'
);
}
/** 사이드바 웹뷰 메시지 핸들러 — sidebarProvider 의 핸들러 체인에 등록된다. */ /** 사이드바 웹뷰 메시지 핸들러 — sidebarProvider 의 핸들러 체인에 등록된다. */
export async function handleBridgeLauncherMessage(_provider: unknown, data: any): Promise<boolean> { export async function handleBridgeLauncherMessage(_provider: unknown, data: any): Promise<boolean> {
switch (data?.type) { switch (data?.type) {
@@ -150,6 +258,9 @@ export async function handleBridgeLauncherMessage(_provider: unknown, data: any)
case 'stopNotebookLmBridge': case 'stopNotebookLmBridge':
await stopNotebookLmBridge(); await stopNotebookLmBridge();
return true; return true;
case 'loginNotebookLm':
await loginNotebookLm();
return true;
default: default:
return false; return false;
} }
+34 -1
View File
@@ -3,7 +3,7 @@
* (터미널 생성·프로세스 kill 같은 부수효과는 VS Code/OS 의존이라 여기선 순수 함수만.) * (터미널 생성·프로세스 kill 같은 부수효과는 VS Code/OS 의존이라 여기선 순수 함수만.)
*/ */
import * as path from 'path'; import * as path from 'path';
import { candidateProjectDirs, buildPortKillCommand } from '../src/features/datacollect/bridgeLauncher'; import { candidateProjectDirs, buildPortKillCommand, candidateNlmPaths } from '../src/features/datacollect/bridgeLauncher';
jest.mock('vscode', () => ({ jest.mock('vscode', () => ({
workspace: { getConfiguration: jest.fn(), workspaceFolders: [] }, workspace: { getConfiguration: jest.fn(), workspaceFolders: [] },
@@ -36,6 +36,39 @@ describe('candidateProjectDirs — 프로젝트 자동 탐색 후보 순서', ()
}); });
}); });
describe('candidateNlmPaths — nlm 실행 파일 탐색 순서', () => {
it('설정값이 있으면 항상 1순위다', () => {
const p = candidateNlmPaths('win32', {}, ['C:/py/Scripts'], 'D:/custom/nlm.exe');
expect(p[0]).toBe('D:/custom/nlm.exe');
});
it('윈도우는 파이썬 Scripts 폴더의 nlm.exe 를 찾는다 (버전 하드코딩 없이)', () => {
const p = candidateNlmPaths('win32', {}, ['C:/AppData/Python/Python314/Scripts']);
expect(p).toContain(path.join('C:/AppData/Python/Python314/Scripts', 'nlm.exe'));
});
it('맥/리눅스는 확장자 없는 nlm 과 ~/.local/bin 을 본다', () => {
const p = candidateNlmPaths('darwin', { HOME: '/Users/me' }, ['/Users/me/Library/Python/3.11/bin']);
expect(p).toContain(path.join('/Users/me/Library/Python/3.11/bin', 'nlm'));
expect(p).toContain(path.join('/Users/me', '.local', 'bin', 'nlm'));
expect(p.some(x => x.endsWith('nlm.exe'))).toBe(false);
});
it('마지막 후보는 PATH 폴백용 맨 이름이다', () => {
expect(candidateNlmPaths('win32', {}, []).at(-1)).toBe('nlm');
expect(candidateNlmPaths('linux', {}, []).at(-1)).toBe('nlm');
});
it('중복 경로는 한 번만 나온다', () => {
const p = candidateNlmPaths('win32', {}, ['C:/s', 'C:/s']);
expect(p.filter(x => x === path.join('C:/s', 'nlm.exe')).length).toBe(1);
});
it('설정값 공백은 무시한다', () => {
expect(candidateNlmPaths('win32', {}, [], ' ')[0]).not.toBe(' ');
});
});
describe('buildPortKillCommand — OS 자동 감지 분기', () => { describe('buildPortKillCommand — OS 자동 감지 분기', () => {
it('윈도우는 PowerShell Stop-Process 를 쓴다', () => { it('윈도우는 PowerShell Stop-Process 를 쓴다', () => {
const cmd = buildPortKillCommand('win32', 3002); const cmd = buildPortKillCommand('win32', 3002);