reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Start
Playground
Core Concepts
Core Concepts
Editor
Editor 개요
Block Types
Streaming
Markdown Conversion
Build & Operate
Skills
App Composition Guide
AI Integration
Authoring Playbook
Custom Blocks
Release Notes
Oopt-editor
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

Build & Operate
  1. Docs
  2. /
  3. Build & Operate
  4. /
  5. AI Integration

AI 연동

AI 스트리밍 파이프라인과 diff 리뷰 UX 통합 가이드

reopt design · Updated Jun 26, 2026

시작하기Playground핵심 개념개요블록 타입스트리밍Markdown 변환Skills앱 조합 가이드AI 연동Authoring 플레이북커스텀 블록릴리즈 노트

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 스트리밍 규칙이 자동으로 포함됩니다.

tsx
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 예제
옵션타입설명
promptstring사용자 요청 (user message로 전달됨)
catalogEditorCatalog블록 스키마 + 스트리밍 규칙 생성에 사용
currentSpec?string현재 문서 JSON (편집/수정 요청 시)
systemContext?string추가 시스템 컨텍스트 (프롬프트 끝에 추가)

기존 문서를 수정하는 경우, currentSpec에 현재 문서 JSON을 전달하면 프롬프트에 "Current Document" 섹션이 추가됩니다:

tsx
const systemPrompt = buildSystemPrompt({
  prompt: "결론 섹션을 추가해주세요",
  catalog: defaultCatalog,
  currentSpec: JSON.stringify(store.getSpec(), null, 2),
  systemContext: "문서의 어조는 친근하게 유지해주세요.",
});

3. LLM API 호출

buildAIMessages로 Anthropic과 OpenAI 모두 호환되는 메시지 배열을 생성할 수 있습니다.

Anthropic (Claude API)

tsx
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)

tsx
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만 추출합니다.
tsx
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만 파싱합니다.

tsx
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();
반환값타입설명
statusStreamStatus"idle" | "streaming" | "done" | "error"
errorError | null에러 발생 시 Error 객체
appliedCountnumber적용 완료된 패치 수
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)

tsx
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",
    },
  });
}

클라이언트 컴포넌트

tsx
"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) 블록/전체 승인·거절 경로를 명시적으로 분리.

tsx
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}
/>
PreviousApp Composition Guideopt-ui 화면 안에 Editor를 어떤 경계로 넣고 언제 독립 authoring 상태로 다뤄야 하는지 정리합니다.Build & Operate
Go to App Composition Guide
NextAuthoring Playbook생성, 스트리밍, 검수, 직접 편집, 배포 직전 검증까지 이어지는 운영형 authoring 워크플로우Build & Operate