use-playground-stream.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. "use client";
  2. import { useState, useCallback, useRef, useEffect } from "react";
  3. import type { Spec, JsonPatch, EditMode } from "@json-render/core";
  4. import { deepMergeSpec, diffToPatches } from "@json-render/core";
  5. import { parse as yamlParse, stringify as yamlStringify } from "yaml";
  6. import { applyPatch as applyUnifiedDiff } from "diff";
  7. import {
  8. createYamlStreamCompiler,
  9. YAML_SPEC_FENCE,
  10. YAML_EDIT_FENCE,
  11. YAML_PATCH_FENCE,
  12. DIFF_FENCE,
  13. FENCE_CLOSE,
  14. } from "@json-render/yaml";
  15. import { applySpecPatch } from "./spec-patch";
  16. export type StreamFormat = "jsonl" | "yaml";
  17. export interface TokenUsage {
  18. promptTokens: number;
  19. completionTokens: number;
  20. totalTokens: number;
  21. cachedTokens: number;
  22. cacheWriteTokens: number;
  23. }
  24. export interface UsePlaygroundStreamOptions {
  25. api: string;
  26. format: StreamFormat;
  27. editModes?: EditMode[];
  28. onError?: (error: Error) => void;
  29. onComplete?: (spec: Spec) => void;
  30. }
  31. export interface UsePlaygroundStreamReturn {
  32. spec: Spec | null;
  33. isStreaming: boolean;
  34. error: Error | null;
  35. usage: TokenUsage | null;
  36. rawLines: string[];
  37. send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
  38. clear: () => void;
  39. }
  40. // ── JSONL helpers ──
  41. type ParsedLine =
  42. | { type: "patch"; patch: JsonPatch }
  43. | { type: "usage"; usage: TokenUsage }
  44. | { type: "json-edit"; mergeObj: Record<string, unknown> }
  45. | null;
  46. function parseLine(line: string): ParsedLine {
  47. try {
  48. const trimmed = line.trim();
  49. if (!trimmed || trimmed.startsWith("//")) return null;
  50. const parsed = JSON.parse(trimmed);
  51. if (parsed.__meta === "usage") {
  52. return {
  53. type: "usage",
  54. usage: {
  55. promptTokens: parsed.promptTokens ?? 0,
  56. completionTokens: parsed.completionTokens ?? 0,
  57. totalTokens: parsed.totalTokens ?? 0,
  58. cachedTokens: parsed.cachedTokens ?? 0,
  59. cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
  60. },
  61. };
  62. }
  63. if (parsed.__json_edit === true) {
  64. const mergeObj = { ...parsed };
  65. delete mergeObj.__json_edit;
  66. return { type: "json-edit", mergeObj };
  67. }
  68. return { type: "patch", patch: parsed as JsonPatch };
  69. } catch {
  70. return null;
  71. }
  72. }
  73. type FenceState = "outside" | "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff";
  74. // ── Hook ──
  75. export function usePlaygroundStream({
  76. api,
  77. format,
  78. editModes,
  79. onError,
  80. onComplete,
  81. }: UsePlaygroundStreamOptions): UsePlaygroundStreamReturn {
  82. const [spec, setSpec] = useState<Spec | null>(null);
  83. const [isStreaming, setIsStreaming] = useState(false);
  84. const [error, setError] = useState<Error | null>(null);
  85. const [usage, setUsage] = useState<TokenUsage | null>(null);
  86. const [rawLines, setRawLines] = useState<string[]>([]);
  87. const rawLinesRef = useRef<string[]>([]);
  88. const abortControllerRef = useRef<AbortController | null>(null);
  89. const onCompleteRef = useRef(onComplete);
  90. onCompleteRef.current = onComplete;
  91. const onErrorRef = useRef(onError);
  92. onErrorRef.current = onError;
  93. const formatRef = useRef(format);
  94. formatRef.current = format;
  95. const editModesRef = useRef(editModes);
  96. editModesRef.current = editModes;
  97. const clear = useCallback(() => {
  98. setSpec(null);
  99. setError(null);
  100. }, []);
  101. const send = useCallback(
  102. async (prompt: string, context?: Record<string, unknown>) => {
  103. abortControllerRef.current = new AbortController();
  104. setIsStreaming(true);
  105. setError(null);
  106. setUsage(null);
  107. rawLinesRef.current = [];
  108. setRawLines([]);
  109. const previousSpec = context?.previousSpec as Spec | undefined;
  110. let currentSpec: Spec =
  111. previousSpec && previousSpec.root
  112. ? { ...previousSpec, elements: { ...previousSpec.elements } }
  113. : { root: "", elements: {} };
  114. setSpec(currentSpec);
  115. try {
  116. const response = await fetch(api, {
  117. method: "POST",
  118. headers: { "Content-Type": "application/json" },
  119. body: JSON.stringify({
  120. prompt,
  121. context,
  122. format: formatRef.current,
  123. editModes: editModesRef.current,
  124. }),
  125. signal: abortControllerRef.current.signal,
  126. });
  127. if (!response.ok) {
  128. let errorMessage = `HTTP error: ${response.status}`;
  129. try {
  130. const errorData = await response.json();
  131. if (errorData.message) errorMessage = errorData.message;
  132. else if (errorData.error) errorMessage = errorData.error;
  133. } catch {
  134. // use default
  135. }
  136. throw new Error(errorMessage);
  137. }
  138. const reader = response.body?.getReader();
  139. if (!reader) throw new Error("No response body");
  140. const decoder = new TextDecoder();
  141. let buffer = "";
  142. if (formatRef.current === "yaml") {
  143. // ── YAML streaming ──
  144. let fenceState: FenceState = "outside";
  145. const compiler = createYamlStreamCompiler<Record<string, unknown>>();
  146. let yamlEditAccumulated = "";
  147. let yamlPatchAccumulated = "";
  148. let diffAccumulated = "";
  149. while (true) {
  150. const { done, value } = await reader.read();
  151. if (done) break;
  152. buffer += decoder.decode(value, { stream: true });
  153. const lines = buffer.split("\n");
  154. buffer = lines.pop() ?? "";
  155. for (const line of lines) {
  156. const trimmed = line.trim();
  157. // Check for usage metadata (appended after stream)
  158. if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
  159. try {
  160. const parsed = JSON.parse(trimmed);
  161. if (parsed.__meta === "usage") {
  162. setUsage({
  163. promptTokens: parsed.promptTokens ?? 0,
  164. completionTokens: parsed.completionTokens ?? 0,
  165. totalTokens: parsed.totalTokens ?? 0,
  166. cachedTokens: parsed.cachedTokens ?? 0,
  167. cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
  168. });
  169. continue;
  170. }
  171. } catch {
  172. // not JSON
  173. }
  174. }
  175. rawLinesRef.current.push(line);
  176. if (fenceState === "outside") {
  177. if (
  178. trimmed === YAML_SPEC_FENCE ||
  179. trimmed.startsWith(YAML_SPEC_FENCE + " ")
  180. ) {
  181. fenceState = "yaml-spec";
  182. compiler.reset();
  183. } else if (
  184. trimmed === YAML_EDIT_FENCE ||
  185. trimmed.startsWith(YAML_EDIT_FENCE + " ")
  186. ) {
  187. fenceState = "yaml-edit";
  188. yamlEditAccumulated = "";
  189. } else if (
  190. trimmed === YAML_PATCH_FENCE ||
  191. trimmed.startsWith(YAML_PATCH_FENCE + " ")
  192. ) {
  193. fenceState = "yaml-patch";
  194. yamlPatchAccumulated = "";
  195. } else if (
  196. trimmed === DIFF_FENCE ||
  197. trimmed.startsWith(DIFF_FENCE + " ")
  198. ) {
  199. fenceState = "diff";
  200. diffAccumulated = "";
  201. }
  202. } else if (trimmed === FENCE_CLOSE || trimmed === "````") {
  203. if (fenceState === "yaml-spec") {
  204. const { result, newPatches } = compiler.flush();
  205. if (result && typeof result === "object" && result.root) {
  206. for (const patch of newPatches) {
  207. currentSpec = applySpecPatch(currentSpec, patch);
  208. }
  209. setSpec({ ...currentSpec });
  210. }
  211. } else if (fenceState === "yaml-edit") {
  212. try {
  213. const editObj = yamlParse(yamlEditAccumulated);
  214. if (editObj && typeof editObj === "object") {
  215. const merged = deepMergeSpec(
  216. currentSpec as unknown as Record<string, unknown>,
  217. editObj as Record<string, unknown>,
  218. );
  219. const patches = diffToPatches(
  220. currentSpec as unknown as Record<string, unknown>,
  221. merged,
  222. );
  223. for (const patch of patches) {
  224. currentSpec = applySpecPatch(currentSpec, patch);
  225. }
  226. setSpec({ ...currentSpec });
  227. }
  228. } catch {
  229. // Invalid YAML edit
  230. }
  231. } else if (fenceState === "yaml-patch") {
  232. for (const patchLine of yamlPatchAccumulated.split("\n")) {
  233. const t = patchLine.trim();
  234. if (!t) continue;
  235. try {
  236. const patch = JSON.parse(t) as JsonPatch;
  237. if (patch.op) {
  238. currentSpec = applySpecPatch(currentSpec, patch);
  239. }
  240. } catch {
  241. // Skip invalid JSON lines
  242. }
  243. }
  244. setSpec({ ...currentSpec });
  245. } else if (fenceState === "diff") {
  246. try {
  247. const specYaml = yamlStringify(currentSpec, { indent: 2 });
  248. const patched = applyUnifiedDiff(specYaml, diffAccumulated);
  249. if (typeof patched === "string") {
  250. const parsed = yamlParse(patched);
  251. if (parsed && typeof parsed === "object") {
  252. const patches = diffToPatches(
  253. currentSpec as unknown as Record<string, unknown>,
  254. parsed as Record<string, unknown>,
  255. );
  256. for (const patch of patches) {
  257. currentSpec = applySpecPatch(currentSpec, patch);
  258. }
  259. setSpec({ ...currentSpec });
  260. }
  261. }
  262. } catch {
  263. // Diff apply or reparse failed
  264. }
  265. }
  266. fenceState = "outside";
  267. } else if (fenceState === "yaml-spec") {
  268. const { newPatches } = compiler.push(line + "\n");
  269. if (newPatches.length > 0) {
  270. for (const patch of newPatches) {
  271. currentSpec = applySpecPatch(currentSpec, patch);
  272. }
  273. setSpec({ ...currentSpec });
  274. }
  275. } else if (fenceState === "yaml-edit") {
  276. yamlEditAccumulated += line + "\n";
  277. } else if (fenceState === "yaml-patch") {
  278. yamlPatchAccumulated += line + "\n";
  279. } else if (fenceState === "diff") {
  280. diffAccumulated += line + "\n";
  281. }
  282. }
  283. setRawLines([...rawLinesRef.current]);
  284. }
  285. // Process remaining buffer
  286. if (buffer.trim()) {
  287. const trimmed = buffer.trim();
  288. if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
  289. try {
  290. const parsed = JSON.parse(trimmed);
  291. if (parsed.__meta === "usage") {
  292. setUsage({
  293. promptTokens: parsed.promptTokens ?? 0,
  294. completionTokens: parsed.completionTokens ?? 0,
  295. totalTokens: parsed.totalTokens ?? 0,
  296. cachedTokens: parsed.cachedTokens ?? 0,
  297. cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
  298. });
  299. }
  300. } catch {
  301. // not JSON
  302. }
  303. } else if (fenceState === "yaml-spec") {
  304. compiler.push(buffer);
  305. const { result, newPatches } = compiler.flush();
  306. if (result && typeof result === "object" && result.root) {
  307. for (const patch of newPatches) {
  308. currentSpec = applySpecPatch(currentSpec, patch);
  309. }
  310. setSpec({ ...currentSpec });
  311. }
  312. }
  313. }
  314. } else {
  315. // ── JSONL streaming ──
  316. let jsonlDiffState: "outside" | "diff" = "outside";
  317. let jsonlDiffAccumulated = "";
  318. while (true) {
  319. const { done, value } = await reader.read();
  320. if (done) break;
  321. buffer += decoder.decode(value, { stream: true });
  322. const lines = buffer.split("\n");
  323. buffer = lines.pop() ?? "";
  324. for (const line of lines) {
  325. const trimmed = line.trim();
  326. if (!trimmed) continue;
  327. // Diff fence detection within JSONL mode
  328. if (jsonlDiffState === "outside") {
  329. if (
  330. trimmed === DIFF_FENCE ||
  331. trimmed.startsWith(DIFF_FENCE + " ")
  332. ) {
  333. jsonlDiffState = "diff";
  334. jsonlDiffAccumulated = "";
  335. continue;
  336. }
  337. } else if (
  338. jsonlDiffState === "diff" &&
  339. (trimmed === FENCE_CLOSE || trimmed === "````")
  340. ) {
  341. try {
  342. const specJson = JSON.stringify(currentSpec, null, 2);
  343. const patched = applyUnifiedDiff(
  344. specJson,
  345. jsonlDiffAccumulated,
  346. );
  347. if (typeof patched === "string") {
  348. const parsed = JSON.parse(patched);
  349. if (parsed && typeof parsed === "object") {
  350. const patches = diffToPatches(
  351. currentSpec as unknown as Record<string, unknown>,
  352. parsed as Record<string, unknown>,
  353. );
  354. for (const patch of patches) {
  355. currentSpec = applySpecPatch(currentSpec, patch);
  356. }
  357. setSpec({ ...currentSpec });
  358. }
  359. }
  360. } catch {
  361. // Diff apply failed
  362. }
  363. jsonlDiffState = "outside";
  364. continue;
  365. }
  366. if (jsonlDiffState === "diff") {
  367. jsonlDiffAccumulated += line + "\n";
  368. rawLinesRef.current.push(line);
  369. continue;
  370. }
  371. // Standard JSONL line parsing
  372. const result = parseLine(trimmed);
  373. if (!result) continue;
  374. if (result.type === "usage") {
  375. setUsage(result.usage);
  376. } else if (result.type === "json-edit") {
  377. const merged = deepMergeSpec(
  378. currentSpec as unknown as Record<string, unknown>,
  379. result.mergeObj,
  380. );
  381. const patches = diffToPatches(
  382. currentSpec as unknown as Record<string, unknown>,
  383. merged,
  384. );
  385. for (const patch of patches) {
  386. currentSpec = applySpecPatch(currentSpec, patch);
  387. }
  388. rawLinesRef.current.push(trimmed);
  389. setSpec({ ...currentSpec });
  390. } else {
  391. rawLinesRef.current.push(trimmed);
  392. currentSpec = applySpecPatch(currentSpec, result.patch);
  393. setSpec({ ...currentSpec });
  394. }
  395. }
  396. setRawLines([...rawLinesRef.current]);
  397. }
  398. if (buffer.trim()) {
  399. const trimmed = buffer.trim();
  400. const result = parseLine(trimmed);
  401. if (result) {
  402. if (result.type === "usage") {
  403. setUsage(result.usage);
  404. } else if (result.type === "json-edit") {
  405. const merged = deepMergeSpec(
  406. currentSpec as unknown as Record<string, unknown>,
  407. result.mergeObj,
  408. );
  409. const patches = diffToPatches(
  410. currentSpec as unknown as Record<string, unknown>,
  411. merged,
  412. );
  413. for (const patch of patches) {
  414. currentSpec = applySpecPatch(currentSpec, patch);
  415. }
  416. rawLinesRef.current.push(trimmed);
  417. setSpec({ ...currentSpec });
  418. } else {
  419. rawLinesRef.current.push(trimmed);
  420. currentSpec = applySpecPatch(currentSpec, result.patch);
  421. setSpec({ ...currentSpec });
  422. }
  423. }
  424. }
  425. }
  426. onCompleteRef.current?.(currentSpec);
  427. } catch (err) {
  428. if ((err as Error).name === "AbortError") return;
  429. const error = err instanceof Error ? err : new Error(String(err));
  430. setError(error);
  431. onErrorRef.current?.(error);
  432. } finally {
  433. setIsStreaming(false);
  434. }
  435. },
  436. [api],
  437. );
  438. useEffect(() => {
  439. return () => {
  440. abortControllerRef.current?.abort();
  441. };
  442. }, []);
  443. return { spec, isStreaming, error, usage, rawLines, send, clear };
  444. }