stream.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import { formatTime, h, jsonPreview, replaceChildren } from "../dom";
  2. import type { DevtoolsEvent, TokenUsage } from "../../types";
  3. import type { PanelContext, TabDef, TabInstance } from "../types";
  4. const TAB_ID = "stream";
  5. interface Generation {
  6. startedAt: number;
  7. endedAt?: number;
  8. ok?: boolean;
  9. events: DevtoolsEvent[];
  10. usage?: TokenUsage;
  11. }
  12. /**
  13. * Stream Log tab — shows spec patches, text chunks, lifecycle markers,
  14. * and token usage grouped by generation (lifecycle start -> end).
  15. */
  16. export function streamTab(): TabDef {
  17. return {
  18. id: TAB_ID,
  19. label: "Stream",
  20. badge(ctx) {
  21. const gens = groupGenerations(ctx.events.snapshot());
  22. return gens.length || undefined;
  23. },
  24. mount(root, ctx) {
  25. return mountStreamTab(root, ctx);
  26. },
  27. };
  28. }
  29. function mountStreamTab(root: HTMLElement, ctx: PanelContext): TabInstance {
  30. const container = h("div", {
  31. style: { height: "100%", display: "flex", flexDirection: "column" },
  32. });
  33. const toolbar = h("div", { className: "jr-toolbar" });
  34. const body = h("div", {
  35. style: { overflow: "auto", flex: "1", minHeight: "0" },
  36. });
  37. container.appendChild(toolbar);
  38. container.appendChild(body);
  39. root.appendChild(container);
  40. function render() {
  41. const all = ctx.events.snapshot();
  42. const gens = groupGenerations(all);
  43. const orphan = collectOrphanEvents(all);
  44. replaceChildren(
  45. toolbar,
  46. h(
  47. "span",
  48. { className: "jr-badge" },
  49. `${gens.length} generation${gens.length === 1 ? "" : "s"}`,
  50. ),
  51. );
  52. if (gens.length === 0 && orphan.length === 0) {
  53. replaceChildren(
  54. body,
  55. h(
  56. "div",
  57. { className: "jr-empty" },
  58. "No stream events yet. Tap a stream or pass messages to capture patches.",
  59. ),
  60. );
  61. return;
  62. }
  63. const content: HTMLElement[] = [];
  64. // Newest first; flatten orphan into a pseudo-generation.
  65. if (orphan.length > 0) {
  66. content.push(
  67. renderGeneration(
  68. { startedAt: orphan[0]?.at ?? Date.now(), events: orphan },
  69. "live",
  70. true,
  71. ),
  72. );
  73. }
  74. for (let i = gens.length - 1; i >= 0; i--) {
  75. const gen = gens[i]!;
  76. content.push(renderGeneration(gen, `gen-${i}`, i === gens.length - 1));
  77. }
  78. replaceChildren(body, content);
  79. }
  80. render();
  81. return { update: render };
  82. }
  83. function groupGenerations(events: DevtoolsEvent[]): Generation[] {
  84. const gens: Generation[] = [];
  85. let current: Generation | null = null;
  86. for (const evt of events) {
  87. if (evt.kind === "stream-lifecycle") {
  88. if (evt.phase === "start") {
  89. if (current) gens.push(current);
  90. current = { startedAt: evt.at, events: [] };
  91. } else {
  92. if (!current) {
  93. current = { startedAt: evt.at, events: [] };
  94. }
  95. current.endedAt = evt.at;
  96. current.ok = evt.ok;
  97. gens.push(current);
  98. current = null;
  99. }
  100. continue;
  101. }
  102. if (
  103. evt.kind === "stream-patch" ||
  104. evt.kind === "stream-text" ||
  105. evt.kind === "stream-usage"
  106. ) {
  107. if (!current) current = { startedAt: evt.at, events: [] };
  108. current.events.push(evt);
  109. if (evt.kind === "stream-usage") current.usage = evt.usage;
  110. }
  111. }
  112. if (current) gens.push(current);
  113. return gens;
  114. }
  115. /**
  116. * Stream events recorded outside any lifecycle pair (e.g. when the app taps
  117. * message parts without emitting explicit start/end markers).
  118. */
  119. function collectOrphanEvents(events: DevtoolsEvent[]): DevtoolsEvent[] {
  120. const hasAnyLifecycle = events.some((e) => e.kind === "stream-lifecycle");
  121. if (hasAnyLifecycle) return [];
  122. return events.filter(
  123. (e) =>
  124. e.kind === "stream-patch" ||
  125. e.kind === "stream-text" ||
  126. e.kind === "stream-usage",
  127. );
  128. }
  129. function renderGeneration(
  130. gen: Generation,
  131. key: string,
  132. expandedByDefault: boolean,
  133. ): HTMLElement {
  134. const patchCount = gen.events.filter((e) => e.kind === "stream-patch").length;
  135. const textCount = gen.events.filter((e) => e.kind === "stream-text").length;
  136. const duration =
  137. gen.endedAt !== undefined ? gen.endedAt - gen.startedAt : undefined;
  138. const tone =
  139. gen.endedAt === undefined ? "amber" : gen.ok === false ? "red" : "green";
  140. const status =
  141. gen.endedAt === undefined ? "live" : gen.ok === false ? "error" : "done";
  142. const body = h(
  143. "div",
  144. {
  145. style: {
  146. borderTop: "1px solid var(--jr-border)",
  147. background: "var(--jr-bg-soft)",
  148. display: expandedByDefault ? "block" : "none",
  149. },
  150. },
  151. ...gen.events.map(renderStreamEvent),
  152. );
  153. const header = h(
  154. "div",
  155. {
  156. className: "jr-row",
  157. style: { cursor: "pointer" },
  158. onClick: () => {
  159. body.style.display = body.style.display === "none" ? "block" : "none";
  160. },
  161. },
  162. h("span", { className: "jr-row-time" }, formatTime(gen.startedAt)),
  163. h(
  164. "span",
  165. { className: "jr-row-main" },
  166. h("strong", null, `generation #${key}`),
  167. h(
  168. "span",
  169. { className: "jr-tree-summary", style: { marginLeft: "8px" } },
  170. `${patchCount} patch${patchCount === 1 ? "" : "es"}`,
  171. textCount > 0
  172. ? `, ${textCount} text chunk${textCount === 1 ? "" : "s"}`
  173. : "",
  174. ),
  175. ),
  176. h(
  177. "span",
  178. { className: "jr-row-meta" },
  179. h("span", { className: "jr-badge", "data-tone": tone }, status),
  180. duration !== undefined
  181. ? h("span", { style: { marginLeft: "6px" } }, `${duration}ms`)
  182. : null,
  183. gen.usage
  184. ? h(
  185. "span",
  186. { style: { marginLeft: "6px" } },
  187. `${formatTokens(gen.usage.totalTokens)} tok`,
  188. )
  189. : null,
  190. ),
  191. );
  192. return h("div", null, header, body);
  193. }
  194. function renderStreamEvent(evt: DevtoolsEvent): HTMLElement {
  195. if (evt.kind === "stream-patch") {
  196. return h(
  197. "div",
  198. { className: "jr-row" },
  199. h("span", { className: "jr-row-time" }, formatTime(evt.at)),
  200. h(
  201. "span",
  202. { className: "jr-row-main" },
  203. h(
  204. "span",
  205. { className: "jr-badge", "data-tone": "accent" },
  206. evt.patch.op,
  207. ),
  208. h(
  209. "span",
  210. { className: "jr-tree-summary", style: { marginLeft: "8px" } },
  211. evt.patch.path,
  212. evt.patch.value !== undefined
  213. ? ` = ${jsonPreview(evt.patch.value, 80)}`
  214. : "",
  215. ),
  216. ),
  217. h("span", { className: "jr-row-meta" }, evt.source),
  218. );
  219. }
  220. if (evt.kind === "stream-text") {
  221. return h(
  222. "div",
  223. { className: "jr-row" },
  224. h("span", { className: "jr-row-time" }, formatTime(evt.at)),
  225. h(
  226. "span",
  227. { className: "jr-row-main", style: { whiteSpace: "pre-wrap" } },
  228. evt.text,
  229. ),
  230. h(
  231. "span",
  232. { className: "jr-row-meta" },
  233. h("span", { className: "jr-badge" }, "text"),
  234. ),
  235. );
  236. }
  237. if (evt.kind === "stream-usage") {
  238. return h(
  239. "div",
  240. { className: "jr-row" },
  241. h("span", { className: "jr-row-time" }, formatTime(evt.at)),
  242. h(
  243. "span",
  244. { className: "jr-row-main" },
  245. `prompt ${formatTokens(evt.usage.promptTokens)} / completion ${formatTokens(
  246. evt.usage.completionTokens,
  247. )} / total ${formatTokens(evt.usage.totalTokens)}`,
  248. ),
  249. h(
  250. "span",
  251. { className: "jr-row-meta" },
  252. h("span", { className: "jr-badge", "data-tone": "accent" }, "usage"),
  253. ),
  254. );
  255. }
  256. return h("div");
  257. }
  258. function formatTokens(n: number): string {
  259. if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
  260. return String(n);
  261. }