AI 연동
AI 스트리밍 파이프라인과 diff 리뷰 UX 통합 가이드
reopt design업데이트
1. 개요
opt-editor는 LLM이 생성한 NDJSON 스트림을 실시간으로 문서에 반영하는 파이프라인을 제공합니다. 전체 흐름은 다음과 같습니다:
User prompt │ ▼ buildSystemPrompt() ← 카탈로그 스키마 + 스트리밍 규칙 자동 생성 │ ▼ LLM API (Claude/GPT) ← SSE 스트리밍 응답 │ ▼ extractSSEText() ← SSE 이벤트에서 텍스트 추출 (Anthropic/OpenAI 자동 감지) │ ▼ transformToNDJSON() ← 마크다운 코드블록 제거 + JSON 라인 추출 │ ▼ useEditorStream() ← StreamCompiler로 파싱 → store.applyPatch() → UI 업데이트
각 단계는 독립적인 함수로 제공되므로, 기존 백엔드 인프라에 원하는 부분만 선택적으로 통합할 수 있습니다.
2. 시스템 프롬프트 생성
buildSystemPrompt은 AIStreamOptions를 받아 LLM에 전달할 시스템 프롬프트를 생성합니다. 카탈로그에 등록된 모든 블록 스키마와 NDJSON 스트리밍 규칙이 자동으로 포함됩니다.
import { buildSystemPrompt, defaultCatalog } from "@reopt-ai/opt-editor";
const systemPrompt = buildSystemPrompt({
prompt: "블로그 포스트를 작성해주세요",
catalog: defaultCatalog,
});
// 생성되는 프롬프트에 포함되는 내용:
// - Document Schema: flat element map 구조 설명
// - Available Block Types: 15개 블록의 type, props, 설명
// - Streaming Protocol: NDJSON 패치 규칙 6가지
// - Patch Operations Reference: add/replace/remove/move
// - Complete Streaming Example: heading + paragraph + list 예제| 옵션 | 타입 | 설명 |
|---|---|---|
| prompt | string | 사용자 요청 (user message로 전달됨) |
| catalog | EditorCatalog | 블록 스키마 + 스트리밍 규칙 생성에 사용 |
| currentSpec? | string | 현재 문서 JSON (편집/수정 요청 시) |
| systemContext? | string | 추가 시스템 컨텍스트 (프롬프트 끝에 추가) |
기존 문서를 수정하는 경우, currentSpec에 현재 문서 JSON을 전달하면 프롬프트에 "Current Document" 섹션이 추가됩니다:
const systemPrompt = buildSystemPrompt({
prompt: "결론 섹션을 추가해주세요",
catalog: defaultCatalog,
currentSpec: JSON.stringify(store.getSpec(), null, 2),
systemContext: "문서의 어조는 친근하게 유지해주세요.",
});3. LLM API 호출
buildAIMessages로 Anthropic과 OpenAI 모두 호환되는 메시지 배열을 생성할 수 있습니다.
Anthropic (Claude API)
import { buildAIMessages, defaultCatalog } from "@reopt-ai/opt-editor";
const messages = buildAIMessages({
prompt: "React 튜토리얼 문서를 작성해주세요",
catalog: defaultCatalog,
});
// [
// { role: "system", content: "# Document Schema\n..." },
// { role: "user", content: "React 튜토리얼 문서를 작성해주세요" }
// ]
// Anthropic API 호출
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY!,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
stream: true,
system: messages[0].content,
messages: [{ role: "user", content: messages[1].content }],
}),
});OpenAI (ChatCompletion API)
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o",
stream: true,
messages: [
{ role: "system", content: messages[0].content },
{ role: "user", content: messages[1].content },
],
}),
});4. 스트림 처리
AI API의 SSE 응답에서 NDJSON을 추출하는 두 단계의 변환 파이프라인입니다:
extractSSEText(sseStream)— SSE 이벤트에서 텍스트 델타를 추출합니다. Anthropic의content_block_delta.delta.text와 OpenAI의choices[0].delta.content포맷을 자동 감지합니다.transformToNDJSON(textStream)— 텍스트 스트림에서 마크다운 코드블록 마커(```)를 제거하고, JSON 객체가 아닌 줄을 건너뛰어 순수 NDJSON만 추출합니다.
import { extractSSEText, transformToNDJSON } from "@reopt-ai/opt-editor";
// 1단계: SSE → 텍스트 스트림
const textStream = extractSSEText(response.body!);
// Anthropic: data: {"type":"content_block_delta","delta":{"text":"..."}} → 텍스트
// OpenAI: data: {"choices":[{"delta":{"content":"..."}}]} → 텍스트
// 2단계: 텍스트 → NDJSON 스트림
const ndjsonStream = transformToNDJSON(textStream);
// ```jsonl ← 건너뜀
// {"op":"add",...} ← 통과
// {"op":"add",...} ← 통과
// ``` ← 건너뜀참고: LLM이 마크다운 코드블록으로 NDJSON을 감싸는 경우가 자주 있습니다. transformToNDJSON은 이를 자동으로 처리하므로, 시스템 프롬프트에서 "코드블록 없이 출력하라"고 지시하더라도 안전하게 동작합니다.
5. useEditorStream 적용
useEditorStream은 React 훅으로, 스트림을 소비하여 EditorStore에 패치를 적용합니다. 내부적으로 StreamCompiler를 사용하여 부분 청크를 버퍼링하고 완전한 JSON만 파싱합니다.
import { useEditorStream, createEditorStore } from "@reopt-ai/opt-editor";
const store = createEditorStore();
const { start, resume, abort, status, error, appliedCount } = useEditorStream(store);
// 스트림 시작
await start(ndjsonStream);
// status: "idle" → "streaming" → "done"
// 네트워크 오류 시 이어쓰기
if (status === "error") {
// 같은 패치 시퀀스를 재생하는 새 스트림으로 resume
const newStream = await fetchRetryStream();
await resume(newStream);
// → appliedCount만큼 자동 스킵 후 나머지만 적용
}
// 진행률 표시
console.log(`적용된 패치: ${appliedCount}개`);
// 사용자가 중단
abort();| 반환값 | 타입 | 설명 |
|---|---|---|
| status | StreamStatus | "idle" | "streaming" | "done" | "error" |
| error | Error | null | 에러 발생 시 Error 객체 |
| appliedCount | number | 적용 완료된 패치 수 |
| start(source) | Promise<void> | 스트림 소비 시작 (초기화 후) |
| resume(source) | Promise<void> | 이미 적용된 패치 스킵 후 이어쓰기 |
| abort() | void | 현재 스트림 중단 |
6. 전체 통합 예제
Next.js App Router에서 Route Handler + 클라이언트 컴포넌트로 AI 에디터를 구현하는 전체 예제입니다.
API Route Handler (app/api/ai/generate/route.ts)
import { buildAIMessages, defaultCatalog } from "@reopt-ai/opt-editor";
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const { prompt, currentSpec } = await req.json();
const messages = buildAIMessages({
prompt,
catalog: defaultCatalog,
currentSpec,
});
// Claude API 호출 (SSE 스트리밍)
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY!,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
stream: true,
system: messages[0].content,
messages: [{ role: "user", content: messages[1].content }],
}),
});
// SSE 스트림을 그대로 클라이언트에 전달
return new Response(response.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}클라이언트 컴포넌트
"use client";
import { useMemo, useState } from "react";
import {
Editor,
EditorProvider,
createEditorStore,
defaultCatalog,
useEditorStream,
extractSSEText,
transformToNDJSON,
} from "@reopt-ai/opt-editor";
import "@reopt-ai/opt-editor/styles.css";
export default function AIEditor() {
const store = useMemo(() => createEditorStore(), []);
const { start, abort, status, appliedCount } = useEditorStream(store);
const [prompt, setPrompt] = useState("");
const handleGenerate = async () => {
const response = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (!response.body) return;
// SSE → 텍스트 → NDJSON → store에 실시간 적용
const textStream = extractSSEText(response.body);
const ndjsonStream = transformToNDJSON(textStream);
await start(ndjsonStream);
};
return (
<EditorProvider store={store} catalog={defaultCatalog}>
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="AI에게 문서 작성을 요청하세요..."
className="flex-1 rounded border px-3 py-2"
/>
<button
onClick={handleGenerate}
disabled={status === "streaming"}
className="rounded bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
>
{status === "streaming"
? `생성 중 (${appliedCount})`
: "생성"}
</button>
{status === "streaming" && (
<button onClick={abort} className="rounded border px-4 py-2">
중단
</button>
)}
</div>
<Editor
store={store}
catalog={defaultCatalog}
mode={status === "streaming" ? "stream" : "edit"}
/>
</div>
</EditorProvider>
);
}모드 전환 패턴: AI가 생성 중일 때는 mode="stream"으로 읽기 전용 표시, 생성 완료 후 mode="edit"로 전환하여 사용자가 수정할 수 있게 합니다.
7. Diff 리뷰 통합 (Playground parity)
AI 버튼이 안 보일 때: 인라인 툴바의 AI 버튼은 mode="edit"이고 onAIRequest 콜백이 전달된 경우에만 표시됩니다. 두 조건 중 하나라도 빠지면 버튼은 숨겨지는 것이 정상 동작입니다.
Playground에서 보이는 리뷰 UX는 대부분 @reopt-ai/opt-editor 배포 모듈만으로 구현할 수 있습니다. 핵심은 mode="diff" 와 suggestionDecorations 조합입니다.
| 기능 | 제공 위치 | 연결 포인트 |
|---|---|---|
| stream / edit / diff 모드 | 모듈 내장 | <Editor mode="diff" /> |
| 변경 블록 강조, 배지, hover 플로팅 diff | 모듈 내장 (opt-in) | suggestionDecorations |
| 블록 단위 승인/거절 | 모듈 내장 | approveBlock / rejectBlock |
| pending 중 토글 잠금, 생성 중 편집 잠금, 상태 패널 | 소비자 프로젝트 구현 | app state / product policy |
권장 체크리스트: (1) 리뷰 중 mode="diff" 고정, (2) suggestion pending 동안 모드 토글 잠금, (3) 생성 중 편집 액션 잠금, (4) 블록/전체 승인·거절 경로를 명시적으로 분리.
const reviewDecorations =
mode === "diff" && suggestion
? {
changedBlockIds: suggestion.changedBlockIds,
diffs: diffSpecsDetailed(store.getSpec(), suggestion.draftSpec),
onApproveBlock: approveBlock,
onRejectBlock: rejectBlock,
showBadge: true,
showHoverCard: true,
}
: undefined;
<Editor
store={store}
catalog={defaultCatalog}
mode={mode}
suggestionDecorations={reviewDecorations}
/>