Update from Assistant
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { bridgeFetch, getBridgeBaseUrl } from './bridgeClient';
|
||||
|
||||
/**
|
||||
* Datacollect "라디오" slash 명령 라우터.
|
||||
*
|
||||
* 사용자가 Astra 채팅에서 `/research <주제>` 같은 입력을 보내면 chatHandlers에서
|
||||
* 이 모듈로 위임. 새 UI 버튼 없이 채팅 단일 경로만으로 Datacollect bridge의
|
||||
* 무거운 기능(NotebookLM Deep Research, Playwright 웹 벤치마크, YouTube 4-렌즈
|
||||
* 분석, Blog Pipeline)을 호출한다.
|
||||
*
|
||||
* 진행 상황과 최종 결과는 모두 webview에 `streamChunk` 메시지로 흘려, 일반
|
||||
* LLM 응답과 같은 자리에 자연스럽게 표시된다.
|
||||
*
|
||||
* 명령이 처리되면 true 반환 → chatHandlers가 일반 LLM 흐름으로 안 내려가게.
|
||||
*/
|
||||
|
||||
const COMMANDS = ['/research', '/benchmark', '/youtube', '/blog'] as const;
|
||||
type SlashCommand = typeof COMMANDS[number];
|
||||
|
||||
export function isSlashCommand(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith('/')) return false;
|
||||
const head = trimmed.split(/\s+/, 1)[0].toLowerCase();
|
||||
return (COMMANDS as readonly string[]).includes(head);
|
||||
}
|
||||
|
||||
interface Webview {
|
||||
postMessage(msg: any): Thenable<boolean> | boolean;
|
||||
}
|
||||
|
||||
function chunk(view: Webview | undefined, value: string) {
|
||||
view?.postMessage({ type: 'streamChunk', value });
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬래시 명령을 라우팅. chatHandlers에서 `userPrompt.startsWith('/')` 체크 후 호출.
|
||||
* 처리 성공/실패 모두 true 반환 (사용자가 명령을 의도했음을 명확히 표현했으므로
|
||||
* LLM fallback로 흘리지 않음).
|
||||
*
|
||||
* **반드시 finally에서 `streamEnd`를 보낸다** — Astra webview의 채팅 input은
|
||||
* `streamEnd` 메시지를 받아야 잠금이 풀린다(sidebarProvider.ts 참조). 일반 LLM
|
||||
* 흐름은 streamer가 자동으로 보내지만, 우리는 LLM을 우회해 bridge를 직접 호출
|
||||
* 하므로 명시적으로 보내야 한다. 안 보내면 timeout/에러/성공 어떤 경우에도
|
||||
* input이 영원히 잠긴 채 사용자가 무한 로딩 상태로 보게 됨.
|
||||
*/
|
||||
export async function handleSlashCommand(
|
||||
input: string,
|
||||
view: Webview | undefined,
|
||||
): Promise<boolean> {
|
||||
const trimmed = input.trim();
|
||||
const spaceIdx = trimmed.indexOf(' ');
|
||||
const head = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).toLowerCase() as SlashCommand;
|
||||
const arg = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
|
||||
|
||||
chunk(view, `\n\n**📻 Datacollect Radio** · \`${head}\` · bridge=\`${getBridgeBaseUrl()}\`\n\n`);
|
||||
|
||||
try {
|
||||
switch (head) {
|
||||
case '/research': return await runResearch(arg, view);
|
||||
case '/benchmark': return await runBenchmark(arg, view);
|
||||
case '/youtube': return await runYoutube(arg, view);
|
||||
case '/blog': return await runBlog(arg, view);
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n\n> ❌ **에러**: ${e?.message || String(e)}\n`);
|
||||
return true;
|
||||
} finally {
|
||||
// input 잠금 해제 — slashRouter 진입했으면 어떤 경로든 반드시 통과.
|
||||
view?.postMessage({ type: 'streamEnd' });
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── /research ─────────────────────────────
|
||||
|
||||
async function runResearch(topic: string, view: Webview | undefined): Promise<boolean> {
|
||||
if (!topic) {
|
||||
chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`);
|
||||
const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>(
|
||||
'/api/research/start',
|
||||
{ method: 'POST', body: JSON.stringify({ topic }) },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`);
|
||||
|
||||
// Deep research는 보통 1~5분. 5초 polling, 최대 120회(10분).
|
||||
const deadline = Date.now() + 10 * 60_000;
|
||||
let lastStatus = '';
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 5_000));
|
||||
// status 한 번 호출이 30s를 넘는 사례(stale MCP 자식)가 보고돼 60s로 완화.
|
||||
const st = await bridgeFetch<{ success: boolean; result: any }>(
|
||||
`/api/research/status?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`,
|
||||
{ method: 'GET' },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
const status = String(st.result?.status || st.result || '').toLowerCase();
|
||||
if (status && status !== lastStatus) {
|
||||
chunk(view, ` · ${status}\n`);
|
||||
lastStatus = status;
|
||||
}
|
||||
if (status === 'completed' || status === 'done' || status === 'success' || status === 'finished') break;
|
||||
if (status === 'failed' || status === 'error') {
|
||||
chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, `\n📥 import…\n`);
|
||||
// import는 deep research 결과를 노트북 소스로 옮기는 단계. 큰 리포트는 2~5분
|
||||
// 걸리는 경우가 흔해 120s에서 TRANSIENT_TIMEOUT으로 떨어지는 사례 보고됨. 300s로 늘림.
|
||||
await bridgeFetch('/api/research/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }),
|
||||
}, { timeoutMs: 300_000 });
|
||||
|
||||
chunk(view, `🧪 synthesize…\n\n`);
|
||||
// synthesize는 LLM이 노트북 전체를 합성 — 큰 노트북은 5~10분. 600s로 cap.
|
||||
const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>(
|
||||
'/api/research/synthesize',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }),
|
||||
},
|
||||
{ timeoutMs: 600_000 },
|
||||
);
|
||||
const md = synth.markdown || synth.result || '(빈 응답)';
|
||||
chunk(view, `---\n\n${md}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /benchmark ─────────────────────────────
|
||||
|
||||
async function runBenchmark(url: string, view: Webview | undefined): Promise<boolean> {
|
||||
if (!url) {
|
||||
chunk(view, `사용법: \`/benchmark <url>\`\n예: \`/benchmark https://example.com\`\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(Playwright + 디자인 토큰/사이트맵 추출, 최대 8페이지)\n\n`);
|
||||
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 }),
|
||||
},
|
||||
{ timeoutMs: 6 * 60_000 },
|
||||
);
|
||||
|
||||
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`);
|
||||
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`);
|
||||
|
||||
chunk(view, `### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})\n`);
|
||||
chunk(view, `\`\`\`\n${s?.sitemap?.ascii || '(없음)'}\n\`\`\`\n\n`);
|
||||
|
||||
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, `> 💡 더 깊은 4-렌즈/Rebuild Blueprint 합성을 원하면 위 결과를 인용해 Astra에 추가 질문하세요.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /youtube ─────────────────────────────
|
||||
|
||||
async function runYoutube(url: string, view: Webview | undefined): Promise<boolean> {
|
||||
if (!url) {
|
||||
chunk(view, `사용법: \`/youtube <url>\`\n예: \`/youtube https://youtu.be/xxxx\`\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `🎬 **YouTube 추출**: ${url}\n(transcript + metadata)\n\n`);
|
||||
const data = await bridgeFetch<{ success: boolean; metadata?: any; segments?: any[]; plainTranscript?: string; outputDir?: string }>(
|
||||
'/api/youtube/extract',
|
||||
{ method: 'POST', body: JSON.stringify({ url }) },
|
||||
{ timeoutMs: 5 * 60_000 },
|
||||
);
|
||||
|
||||
const m = data.metadata || {};
|
||||
chunk(view, `### 메타데이터\n`);
|
||||
chunk(view, `- **title**: ${m.title || '(없음)'}\n`);
|
||||
chunk(view, `- **channel**: ${m.channel || m.uploader || '(없음)'}\n`);
|
||||
chunk(view, `- **duration**: ${m.duration || '(없음)'}초\n`);
|
||||
chunk(view, `- **views**: ${m.view_count ?? '(없음)'}\n`);
|
||||
chunk(view, `- **upload_date**: ${m.upload_date || '(없음)'}\n`);
|
||||
if (Array.isArray(m.tags) && m.tags.length) {
|
||||
chunk(view, `- **tags**: ${m.tags.slice(0, 10).join(', ')}\n`);
|
||||
}
|
||||
if (Array.isArray(m.chapters) && m.chapters.length) {
|
||||
chunk(view, `\n### 챕터 (${m.chapters.length})\n`);
|
||||
for (const c of m.chapters.slice(0, 12)) {
|
||||
chunk(view, `- ${c.start_time}s — ${c.title}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const transcript = data.plainTranscript || '';
|
||||
chunk(view, `\n### Transcript (${transcript.length.toLocaleString()}자, 처음 4000자만 미리보기)\n\n`);
|
||||
chunk(view, `\`\`\`\n${transcript.slice(0, 4000)}${transcript.length > 4000 ? '\n... (생략)' : ''}\n\`\`\`\n`);
|
||||
|
||||
chunk(view, `\n> 💡 Hook/Structure/Production/CTR 4-렌즈 분석을 원하면 위 transcript를 인용해 Astra에 추가 질문하세요.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /blog ─────────────────────────────
|
||||
|
||||
async function runBlog(keyword: string, view: Webview | undefined): Promise<boolean> {
|
||||
// Blog Pipeline은 Datacollect의 별도 흐름(blog/app.js + local_platform_server 8787)으로
|
||||
// 실행된다. Bridge 3002에는 대응 endpoint가 없어 Astra가 직접 호출할 경로가 없음.
|
||||
// MVP에서는 사용자가 그쪽 UI로 빠르게 갈 수 있도록 안내만.
|
||||
const target = 'http://127.0.0.1:8787/blog/';
|
||||
chunk(view, `🖋️ **Blog Pipeline**\n\n`);
|
||||
if (keyword) {
|
||||
chunk(view, `요청 키워드: \`${keyword}\`\n\n`);
|
||||
}
|
||||
chunk(view, [
|
||||
`현재 Blog Pipeline은 Datacollect의 별도 정적 페이지(`,
|
||||
`[${target}](${target})`,
|
||||
`)에서 단계별로 실행됩니다. Bridge(3002)에 대응 API가 없어 채팅에서 직접`,
|
||||
` 트리거할 수 없어, 다음 두 가지 중 하나를 선택해 주세요:\n\n`,
|
||||
].join(''));
|
||||
chunk(view, `1. 위 링크에서 키워드/참고 자료/경험담을 입력해 직접 파이프라인 실행\n`);
|
||||
chunk(view, `2. Bridge에 \`/api/blog/run\` 같은 endpoint를 노출하면 \`/blog\`도 end-to-end 실행 가능 — 원하시면 추가 작업으로 진행\n`);
|
||||
|
||||
try {
|
||||
await vscode.env.openExternal(vscode.Uri.parse(target));
|
||||
} catch { /* best-effort */ }
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user