AI 스트리밍
useCalendarSuggestion shadow draft, NDJSON 패치 스트림, 승인 전 라이브 오버레이
reopt design업데이트
1. shadow draft 모델
useCalendarSuggestion(store)는 AI 패치를 라이브 spec이 아닌 격리된 shadow draft store로 스트리밍합니다. 라이브 spec은 approve()를 부르기 전까지 절대 바뀌지 않습니다. 승인하면 draft 전체가 하나의 undo 엔트리로 라이브 store에 적용됩니다.
"use client";
import { useCalendarSuggestion } from "@reopt-ai/opt-calendar";
const ai = useCalendarSuggestion(store);
// { suggestion, start, approve, reject, refine, abort,
// approveEntity, rejectEntity }
// NDJSON 패치 스트림을 격리된 shadow draft로 흘려보냄 (라이브 spec은 그대로)
await ai.start(patchStream);
// 승인 — draft 전체가 하나의 undo 엔트리로 라이브 store에 적용
ai.approve();
// 폐기 — draft를 버리고 라이브 spec 유지
ai.reject();
// 이어붙이기 — 기존 draft에 패치를 추가로 스트리밍
await ai.refine(morePatches);
// 진행 중 스트림 중단 (지금까지 받은 draft는 ready 상태로 유지)
ai.abort();2. draft 오버레이 — 승인 전 미리보기
ai.suggestion을 <Calendar>에 넘기면, 아직 승인되지 않은 변경 엔티티가 pending 오버레이로 그리드에 렌더됩니다. CalendarSuggestion의 changedIds는 "container/id" 키 집합입니다.
// CalendarSuggestion 형태
// {
// status: "streaming" | "ready" | "error",
// draftSpec: CalendarSpec, // AI 변경이 반영된 draft
// changedIds: ReadonlySet<string>, // "events/standup" 같은 키
// patches: JsonPatchOp[], // 누적 패치
// error: Error | null,
// appliedCount: number, // 지금까지 적용된 op 수
// }
// draft를 그리드에 pending으로 오버레이 (라이브 spec은 불변)
<Calendar store={store} suggestion={ai.suggestion} timeZone="Asia/Seoul" />;
// 엔티티 단위 검토 — 리뷰 UI에 사용
ai.approveEntity({ container: "events", id: "standup" }); // 이 변경만 적용
ai.rejectEntity({ container: "events", id: "standup" }); // 이 변경만 되돌림3. NDJSON 패치 프로토콜
모델은 RFC 6902 JSON Patch 연산을 한 줄에 하나씩(NDJSON) 출력합니다. 각 줄은 독립된 JSON 객체이며 배열로 감싸지 않습니다. 엔티티는 전체 객체를 하나의 add로 생성하고, 이후 수정은 필드 단위 replace로 합니다.
{"op":"add","path":"/availability/wk","value":{"id":"wk","name":"Working hours","timeZone":"Asia/Seoul","rules":[{"day":1,"start":"09:00","end":"18:00"},{"day":3,"start":"09:00","end":"18:00"}]}}
{"op":"add","path":"/eventTypes/sync30","value":{"id":"sync30","slug":"30min","title":"30 Minute Sync","durationMinutes":30,"availabilityId":"wk"}}
{"op":"add","path":"/events/standup","value":{"id":"standup","title":"Team Standup","start":"2026-07-06T09:00:00+09:00","end":"2026-07-06T09:15:00+09:00","rrule":"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"}}
{"op":"replace","path":"/events/standup/start","value":"2026-07-06T09:30:00+09:00"}StreamCompiler는 부분 청크를 버퍼링하고 완전한 줄만 파싱합니다(malformed 줄은 스킵, SSE data: 접두사 허용). normalizeCalendarPatchSource는 어떤 PatchSource든 배치 단위 AsyncIterable로 정규화합니다.
import {
StreamCompiler,
normalizeCalendarPatchSource,
} from "@reopt-ai/opt-calendar";
// StreamCompiler — 부분 청크를 버퍼링하고 완전한 줄만 파싱
const compiler = new StreamCompiler();
const ops1 = compiler.feed('{"op":"replace","path":'); // [] — 불완전
const ops2 = compiler.feed('"/events/standup/start","value":"2026-07-06T09:30:00+09:00"}\n');
// → [{ op: "replace", path: "/events/standup/start", value: "..." }]
const remaining = compiler.flush(); // 남은 버퍼 flush + status "done"
compiler.appliedCount; // 파싱된 op 수
// normalizeCalendarPatchSource — 어떤 PatchSource든 배치 단위로 정규화
// PatchSource: ReadableStream<Uint8Array> (fetch body) | ReadableStream<string>
// | AsyncIterable<string> | AsyncIterable<JsonPatchOp>
for await (const ops of normalizeCalendarPatchSource(source)) {
store.applyPatch(ops);
}이미 파싱된 op 이터러블은 tagPatchOps(PATCH_OPS_BRAND)로 감싸면 normalizer가 재파싱 없이 그대로 통과시킵니다. 보통은 raw 스트림을 ai.start(source)에 바로 넘기고, StreamCompiler는 하위 레벨 seam으로만 필요합니다.
4. 카탈로그 스키마 — 모델의 타깃
defaultCalendarCatalog(또는 defineCalendarCatalog로 만든 커스텀 카탈로그)는 엔티티 종류 레지스트리입니다. 각 종류는 자신이 안착할 CalendarSpec 맵(container)을 선언합니다. prompt()는 모델용 시스템 프롬프트를, jsonSchema()는 Structured Output용 JSON Schema를 생성합니다.
import { defaultCalendarCatalog } from "@reopt-ai/opt-calendar";
// 모델에게 줄 시스템 프롬프트 (스펙 구조 + 스트리밍 규칙 + 예제)
const systemPrompt = defaultCalendarCatalog.prompt();
// → "# Calendar Schema\n## Entity Kinds\n..."
// Structured Output용 JSON Schema (5개 맵 + timeZone + version)
const jsonSchema = defaultCalendarCatalog.jsonSchema();
// → { $schema: "...", title: "CalendarSpec", ... }
// 기본 카탈로그가 다루는 종류:
// event / all-day-event / task / calendar / availability / event-type / booking
// validateAttrs(spec) 로 모든 엔티티를 스키마 대비 검증할 수 있습니다.5. 서버 / AI SDK 연결
서버 sub-export @reopt-ai/opt-calendar/server의 createCalendarHandler는 JSON Patch op의 평문 NDJSON 스트림을 반환하는 Route Handler를 만듭니다. 프롬프트에 "Current Moment"(now)를 주입해 모델이 "내일"·"다음 주 화요일 3시" 같은 상대 날짜를 해석하게 합니다. ai는 이 라우트에서만 동적 import되는 optional peer입니다.
// app/api/calendar-ai/route.ts
import { createCalendarHandler } from "@reopt-ai/opt-calendar/server";
import { anthropic } from "@ai-sdk/anthropic";
export const POST = createCalendarHandler({
model: anthropic("claude-sonnet-4-5"),
timeZone: "Asia/Seoul",
weekStartsOn: 1,
// catalog?, systemContext?, maxTokens?, temperature?, now?
});클라이언트 sub-export @reopt-ai/opt-calendar/ai-sdk의 useCalendarAI는 useCalendarSuggestion 위에 얹혀, 위 엔드포인트를 호출하고 결과를 shadow draft로 받습니다. 모든 패치는 draft에 안착하므로 approve()로 적용하거나 reject()로 폐기합니다.
"use client";
import { useCalendarAI } from "@reopt-ai/opt-calendar/ai-sdk";
const ai = useCalendarAI({ store, api: "/api/calendar-ai" });
// 자연어 → 패치 → shadow draft
await ai.schedule("다음 주 화요일 오후 2시에 30분 팀 싱크 잡아줘");
// 선택 범위 편집 (selection + prompt → 패치)
await ai.edit("이 이벤트들을 1시간씩 미뤄줘", {
type: "events",
eventIds: ["standup"],
});
ai.approve(); // draft를 라이브에 적용
// 오버레이는 useCalendarSuggestion과 동일하게 동작
<Calendar store={store} suggestion={ai.suggestion} timeZone="Asia/Seoul" />;opt-chat 스트림에 캘린더 편집을 함께 실어 보내려면 data-calendar-patch 파트를 씁니다 — 서버에서 writeCalendarPatch/writeCalendarPatches로 op을 쓰고, 클라이언트에서 extractCalendarPatch로 꺼냅니다.