stream-tap.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import {
  2. SPEC_DATA_PART_TYPE,
  3. applySpecPatch,
  4. nestedToFlat,
  5. type Spec,
  6. type SpecDataPart,
  7. type StreamChunk,
  8. } from "@json-render/core";
  9. import type { EventStore } from "./event-store";
  10. import type { DevtoolsEvent, TokenUsage } from "./types";
  11. /**
  12. * A minimal shape compatible with AI SDK `UIMessage.parts[i]`. Defined
  13. * locally so the devtools package has no dependency on the `ai` package.
  14. */
  15. interface DataPart {
  16. type: string;
  17. text?: string;
  18. data?: unknown;
  19. state?: string;
  20. }
  21. /**
  22. * Wrap a `ReadableStream<StreamChunk>` (e.g. the output of `pipeJsonRender`
  23. * or any `TransformStream<StreamChunk, StreamChunk>`) so spec patches are
  24. * mirrored into a devtools `EventStore` as they fly past.
  25. *
  26. * Returns the original stream unchanged — the tap just forks a copy.
  27. *
  28. * @example
  29. * ```ts
  30. * writer.merge(
  31. * tapJsonRenderStream(
  32. * pipeJsonRender(result.toUIMessageStream()),
  33. * devtoolsEventStore,
  34. * ),
  35. * );
  36. * ```
  37. */
  38. export function tapJsonRenderStream(
  39. stream: ReadableStream<StreamChunk>,
  40. events: EventStore,
  41. ): ReadableStream<StreamChunk> {
  42. const [a, b] = stream.tee();
  43. void consumeToEvents(b, events, "json");
  44. return a;
  45. }
  46. /** YAML variant — identical behaviour with `source: "yaml"`. */
  47. export function tapYamlStream(
  48. stream: ReadableStream<StreamChunk>,
  49. events: EventStore,
  50. ): ReadableStream<StreamChunk> {
  51. const [a, b] = stream.tee();
  52. void consumeToEvents(b, events, "yaml");
  53. return a;
  54. }
  55. async function consumeToEvents(
  56. stream: ReadableStream<StreamChunk>,
  57. events: EventStore,
  58. source: "json" | "yaml",
  59. ): Promise<void> {
  60. events.push({ kind: "stream-lifecycle", at: Date.now(), phase: "start" });
  61. let ok = true;
  62. try {
  63. const reader = stream.getReader();
  64. while (true) {
  65. const { done, value } = await reader.read();
  66. if (done) break;
  67. if (!value) continue;
  68. forwardChunk(value, events, source);
  69. }
  70. } catch (err) {
  71. ok = false;
  72. events.push({
  73. kind: "stream-text",
  74. at: Date.now(),
  75. text: `[stream error: ${String(err)}]`,
  76. });
  77. } finally {
  78. events.push({
  79. kind: "stream-lifecycle",
  80. at: Date.now(),
  81. phase: "end",
  82. ok,
  83. });
  84. }
  85. }
  86. function forwardChunk(
  87. chunk: StreamChunk,
  88. events: EventStore,
  89. source: "json" | "yaml",
  90. ) {
  91. if (chunk.type === SPEC_DATA_PART_TYPE) {
  92. const data = (chunk as { data?: SpecDataPart }).data;
  93. if (!data) return;
  94. if (data.type === "patch") {
  95. events.push({
  96. kind: "stream-patch",
  97. at: Date.now(),
  98. patch: data.patch,
  99. source,
  100. });
  101. }
  102. return;
  103. }
  104. if (chunk.type === "text-delta") {
  105. const delta = (chunk as { delta?: string }).delta ?? "";
  106. if (delta) {
  107. events.push({ kind: "stream-text", at: Date.now(), text: delta });
  108. }
  109. }
  110. }
  111. /**
  112. * Client-side helper: scan a message's `parts` array for spec data parts
  113. * and push matching events into the store. Use this when rendering chat
  114. * UIs that use `@ai-sdk/react` `useChat` and pass in `messages`.
  115. *
  116. * Idempotent via `seenIds`: only parts not already recorded will emit.
  117. * Returns the new `seenIds` set so callers can persist it between calls.
  118. */
  119. export function scanMessageParts(
  120. parts: readonly DataPart[] | undefined,
  121. events: EventStore,
  122. seen: WeakSet<object>,
  123. ): void {
  124. if (!parts) return;
  125. for (const part of parts) {
  126. if (!part || typeof part !== "object") continue;
  127. if (seen.has(part)) continue;
  128. seen.add(part);
  129. if (part.type === SPEC_DATA_PART_TYPE) {
  130. const data = part.data as SpecDataPart | undefined;
  131. if (!data) continue;
  132. if (data.type === "patch") {
  133. events.push({
  134. kind: "stream-patch",
  135. at: Date.now(),
  136. patch: data.patch,
  137. source: "json",
  138. });
  139. }
  140. }
  141. }
  142. }
  143. /**
  144. * Emit a token-usage event. Called by adapters with whatever usage
  145. * object their framework surfaces (AI SDK `result.usage`).
  146. */
  147. export function recordUsage(events: EventStore, usage: TokenUsage): void {
  148. events.push({ kind: "stream-usage", at: Date.now(), usage });
  149. }
  150. /**
  151. * Replay every `SPEC_DATA_PART` in `parts` and return the resulting spec,
  152. * or `null` if the message has no UI. Mirrors the framework-side
  153. * `buildSpecFromParts` helper but without a framework dependency — used by
  154. * the devtools panel's generation switcher to reconstruct each prior
  155. * assistant message's spec so the user can inspect any of them.
  156. */
  157. export function extractSpecFromParts(
  158. parts: readonly DataPart[] | undefined,
  159. ): Spec | null {
  160. if (!parts) return null;
  161. const spec: Spec = { root: "", elements: {} };
  162. let hasSpec = false;
  163. for (const part of parts) {
  164. if (!part || typeof part !== "object") continue;
  165. if (part.type !== SPEC_DATA_PART_TYPE) continue;
  166. const data = part.data as SpecDataPart | undefined;
  167. if (!data) continue;
  168. if (data.type === "patch") {
  169. hasSpec = true;
  170. applySpecPatch(spec, data.patch);
  171. } else if (data.type === "flat") {
  172. hasSpec = true;
  173. Object.assign(spec, data.spec);
  174. } else if (data.type === "nested") {
  175. hasSpec = true;
  176. Object.assign(spec, nestedToFlat(data.spec));
  177. }
  178. }
  179. return hasSpec ? spec : null;
  180. }
  181. /** Emit a pre-built devtools event. Useful for manual instrumentation. */
  182. export function recordEvent(events: EventStore, event: DevtoolsEvent): void {
  183. events.push(event);
  184. }