Editor
스트리밍 프로토콜
RFC 6902 NDJSON 패치 프로토콜, StreamCompiler, useEditorStream 훅, 재시도/이어쓰기 메커니즘을 다룹니다.
reopt designUpdated
1. NDJSON 패치 프로토콜
AI 에이전트는 RFC 6902 JSON Patch 연산을 한 줄씩 출력합니다. 각 줄은 독립된 JSON 객체입니다.
jsonl
{"op":"add","path":"/elements/h1","value":{"id":"h1","type":"heading","props":{"text":"Title","level":1}}}
{"op":"add","path":"/root/-","value":"h1"}
{"op":"add","path":"/elements/p1","value":{"id":"p1","type":"paragraph","props":{"text":"Hello **world**"}}}
{"op":"add","path":"/root/-","value":"p1"}2. 핵심 규칙
- 1. 원소 먼저, root 나중 — /elements/id에 원소를 추가한 뒤 /root/-에 ID를 추가
- 2. ID 일치— element.id는 /elements/{key}의 key와 동일해야 함
- 3. Inside-out 중첩 — table: cell → row → table 순서로 생성
- 4. 텍스트 업데이트— replace 연산으로 /elements/{id}/props/text 수정
- 5. 인라인 마크다운 — 텍스트에 **bold**, *italic*, `code`, ~~strike~~, [link](url) 사용 가능
3. 테이블 스트리밍 예제
3단 중첩 (cell → row → table) inside-out 패턴:
jsonl
{"op":"add","path":"/elements/tc1","value":{"id":"tc1","type":"table-cell","props":{"text":"Name","header":true}}}
{"op":"add","path":"/elements/tc2","value":{"id":"tc2","type":"table-cell","props":{"text":"Role","header":true}}}
{"op":"add","path":"/elements/tr1","value":{"id":"tr1","type":"table-row","props":{},"children":["tc1","tc2"]}}
{"op":"add","path":"/elements/tc3","value":{"id":"tc3","type":"table-cell","props":{"text":"Alice","header":false}}}
{"op":"add","path":"/elements/tc4","value":{"id":"tc4","type":"table-cell","props":{"text":"Engineer","header":false}}}
{"op":"add","path":"/elements/tr2","value":{"id":"tr2","type":"table-row","props":{},"children":["tc3","tc4"]}}
{"op":"add","path":"/elements/t1","value":{"id":"t1","type":"table","props":{},"children":["tr1","tr2"]}}
{"op":"add","path":"/root/-","value":"t1"}4. StreamCompiler
부분 청크를 버퍼링하고 완전한 JSON 줄만 파싱합니다. 잘못된 JSON은 자동 스킵합니다.
tsx
import { StreamCompiler } from "@reopt-ai/opt-editor";
const compiler = new StreamCompiler();
// 부분 청크 입력
const ops1 = compiler.feed('{"op":"add","path":'); // [] (불완전)
const ops2 = compiler.feed('"/root/-","value":"p1"}\n'); // [{ op: "add", ... }]
// 스트림 종료 시
const remaining = compiler.flush();
compiler.status; // "done"
compiler.appliedCount; // 성공 파싱된 패치 수5. useEditorStream
tsx
import { useEditorStream, createEditorStore } from "@reopt-ai/opt-editor";
const store = createEditorStore();
const { start, resume, abort, status, error, appliedCount } = useEditorStream(store);
// 시작
await start(readableStream);
// 실패 시 이어쓰기 (같은 패치 시퀀스를 재생하는 새 스트림)
if (status === "error") {
const newStream = await fetchNewStream();
await resume(newStream);
// → appliedCount만큼 자동 스킵 후 나머지만 적용
}
// 중단
abort();6. 재시도/이어쓰기
스트림이 네트워크 오류로 중단되면, 같은 패치 시퀀스를 재생하는 새 스트림으로 resume()을 호출합니다.
appliedCount— 이미 적용된 패치 수 (UI 진행률 표시에도 사용)resume(source)— appliedCount만큼 스킵 후 나머지만 store에 적용start(source)— 전체 초기화 후 처음부터 시작
7. AI 프롬프트 자동 생성
catalog.prompt()는 등록된 모든 블록 타입, 스트리밍 규칙, 전체 예제를 포함하는 시스템 프롬프트를 생성합니다. catalog.jsonSchema()는 Structured Output API용 JSON Schema를 생성합니다.
tsx
const systemPrompt = catalog.prompt();
// → "# Document Schema\n## Structure\n..."
const jsonSchema = catalog.jsonSchema();
// → { $schema: "...", title: "EditorSpec", ... }
// AI API 호출
const response = await anthropic.messages.create({
system: systemPrompt,
messages: [{ role: "user", content: "Write an article about React" }],
stream: true,
});