hooks.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
  1. import { createSignal, onCleanup, createMemo } from "solid-js";
  2. import type {
  3. Spec,
  4. UIElement,
  5. FlatElement,
  6. JsonPatch,
  7. SpecDataPart,
  8. } from "@json-render/core";
  9. import {
  10. setByPath,
  11. getByPath,
  12. addByPath,
  13. removeByPath,
  14. createMixedStreamParser,
  15. applySpecPatch,
  16. nestedToFlat,
  17. SPEC_DATA_PART_TYPE,
  18. } from "@json-render/core";
  19. /**
  20. * Token usage metadata from AI generation
  21. */
  22. export interface TokenUsage {
  23. promptTokens: number;
  24. completionTokens: number;
  25. totalTokens: number;
  26. }
  27. /**
  28. * Parse result for a single line -- either a patch or usage metadata
  29. */
  30. type ParsedLine =
  31. | { type: "patch"; patch: JsonPatch }
  32. | { type: "usage"; usage: TokenUsage }
  33. | null;
  34. /**
  35. * Parse a single JSON line (patch or metadata)
  36. */
  37. function parseLine(line: string): ParsedLine {
  38. try {
  39. const trimmed = line.trim();
  40. if (!trimmed || trimmed.startsWith("//")) {
  41. return null;
  42. }
  43. const parsed = JSON.parse(trimmed);
  44. // Check for usage metadata
  45. if (parsed.__meta === "usage") {
  46. return {
  47. type: "usage",
  48. usage: {
  49. promptTokens: parsed.promptTokens ?? 0,
  50. completionTokens: parsed.completionTokens ?? 0,
  51. totalTokens: parsed.totalTokens ?? 0,
  52. },
  53. };
  54. }
  55. return { type: "patch", patch: parsed as JsonPatch };
  56. } catch {
  57. return null;
  58. }
  59. }
  60. /**
  61. * Set a value at a spec path (for add/replace operations).
  62. */
  63. function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
  64. if (path === "/root") {
  65. newSpec.root = value as string;
  66. return;
  67. }
  68. if (path === "/state") {
  69. newSpec.state = value as Record<string, unknown>;
  70. return;
  71. }
  72. if (path.startsWith("/state/")) {
  73. if (!newSpec.state) newSpec.state = {};
  74. const statePath = path.slice("/state".length); // e.g. "/posts"
  75. setByPath(newSpec.state as Record<string, unknown>, statePath, value);
  76. return;
  77. }
  78. if (path.startsWith("/elements/")) {
  79. const pathParts = path.slice("/elements/".length).split("/");
  80. const elementKey = pathParts[0];
  81. if (!elementKey) return;
  82. if (pathParts.length === 1) {
  83. newSpec.elements[elementKey] = value as UIElement;
  84. } else {
  85. const element = newSpec.elements[elementKey];
  86. if (element) {
  87. const propPath = "/" + pathParts.slice(1).join("/");
  88. const newElement = { ...element };
  89. setByPath(
  90. newElement as unknown as Record<string, unknown>,
  91. propPath,
  92. value,
  93. );
  94. newSpec.elements[elementKey] = newElement;
  95. }
  96. }
  97. }
  98. }
  99. /**
  100. * Remove a value at a spec path.
  101. */
  102. function removeSpecValue(newSpec: Spec, path: string): void {
  103. if (path === "/state") {
  104. delete newSpec.state;
  105. return;
  106. }
  107. if (path.startsWith("/state/") && newSpec.state) {
  108. const statePath = path.slice("/state".length);
  109. removeByPath(newSpec.state as Record<string, unknown>, statePath);
  110. return;
  111. }
  112. if (path.startsWith("/elements/")) {
  113. const pathParts = path.slice("/elements/".length).split("/");
  114. const elementKey = pathParts[0];
  115. if (!elementKey) return;
  116. if (pathParts.length === 1) {
  117. const { [elementKey]: _, ...rest } = newSpec.elements;
  118. newSpec.elements = rest;
  119. } else {
  120. const element = newSpec.elements[elementKey];
  121. if (element) {
  122. const propPath = "/" + pathParts.slice(1).join("/");
  123. const newElement = { ...element };
  124. removeByPath(
  125. newElement as unknown as Record<string, unknown>,
  126. propPath,
  127. );
  128. newSpec.elements[elementKey] = newElement;
  129. }
  130. }
  131. }
  132. }
  133. /**
  134. * Get a value at a spec path.
  135. */
  136. function getSpecValue(spec: Spec, path: string): unknown {
  137. if (path === "/root") return spec.root;
  138. if (path === "/state") return spec.state;
  139. if (path.startsWith("/state/") && spec.state) {
  140. const statePath = path.slice("/state".length);
  141. return getByPath(spec.state as Record<string, unknown>, statePath);
  142. }
  143. return getByPath(spec as unknown as Record<string, unknown>, path);
  144. }
  145. /**
  146. * Apply an RFC 6902 JSON patch to the current spec.
  147. * Supports add, remove, replace, move, copy, and test operations.
  148. */
  149. function applyPatch(spec: Spec, patch: JsonPatch): Spec {
  150. const newSpec = {
  151. ...spec,
  152. elements: { ...spec.elements },
  153. ...(spec.state ? { state: { ...spec.state } } : {}),
  154. };
  155. switch (patch.op) {
  156. case "add":
  157. case "replace": {
  158. setSpecValue(newSpec, patch.path, patch.value);
  159. break;
  160. }
  161. case "remove": {
  162. removeSpecValue(newSpec, patch.path);
  163. break;
  164. }
  165. case "move": {
  166. if (!patch.from) break;
  167. const moveValue = getSpecValue(newSpec, patch.from);
  168. removeSpecValue(newSpec, patch.from);
  169. setSpecValue(newSpec, patch.path, moveValue);
  170. break;
  171. }
  172. case "copy": {
  173. if (!patch.from) break;
  174. const copyValue = getSpecValue(newSpec, patch.from);
  175. setSpecValue(newSpec, patch.path, copyValue);
  176. break;
  177. }
  178. case "test": {
  179. // test is a no-op for rendering purposes (validation only)
  180. break;
  181. }
  182. }
  183. return newSpec;
  184. }
  185. /**
  186. * Options for useUIStream
  187. */
  188. export interface UseUIStreamOptions {
  189. /** API endpoint */
  190. api: string;
  191. /** Callback when complete */
  192. onComplete?: (spec: Spec) => void;
  193. /** Callback on error */
  194. onError?: (error: Error) => void;
  195. }
  196. /**
  197. * Return type for useUIStream
  198. */
  199. export interface UseUIStreamReturn {
  200. /** Current UI spec */
  201. readonly spec: Spec | null;
  202. /** Whether currently streaming */
  203. readonly isStreaming: boolean;
  204. /** Error if any */
  205. readonly error: Error | null;
  206. /** Token usage from the last generation */
  207. readonly usage: TokenUsage | null;
  208. /** Raw JSONL lines received from the stream (JSON patch lines) */
  209. readonly rawLines: string[];
  210. /** Send a prompt to generate UI */
  211. send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
  212. /** Clear the current spec */
  213. clear: () => void;
  214. }
  215. /**
  216. * Hook for streaming UI generation
  217. */
  218. export function useUIStream(options: UseUIStreamOptions): UseUIStreamReturn {
  219. const [spec, setSpec] = createSignal<Spec | null>(null);
  220. const [isStreaming, setIsStreaming] = createSignal(false);
  221. const [error, setError] = createSignal<Error | null>(null);
  222. const [usage, setUsage] = createSignal<TokenUsage | null>(null);
  223. const [rawLines, setRawLines] = createSignal<string[]>([]);
  224. let abortController: AbortController | null = null;
  225. const clear = () => {
  226. setSpec(null);
  227. setError(null);
  228. };
  229. const send = async (prompt: string, context?: Record<string, unknown>) => {
  230. // Abort any existing request
  231. abortController?.abort();
  232. abortController = new AbortController();
  233. setIsStreaming(true);
  234. setError(null);
  235. setUsage(null);
  236. setRawLines([]);
  237. // Start with previous spec if provided, otherwise empty spec
  238. const previousSpec = context?.previousSpec as Spec | undefined;
  239. let currentSpec: Spec =
  240. previousSpec && previousSpec.root
  241. ? { ...previousSpec, elements: { ...previousSpec.elements } }
  242. : { root: "", elements: {} };
  243. setSpec(currentSpec);
  244. try {
  245. const response = await fetch(options.api, {
  246. method: "POST",
  247. headers: { "Content-Type": "application/json" },
  248. body: JSON.stringify({
  249. prompt,
  250. context,
  251. currentSpec,
  252. }),
  253. signal: abortController.signal,
  254. });
  255. if (!response.ok) {
  256. // Try to parse JSON error response for better error messages
  257. let errorMessage = `HTTP error: ${response.status}`;
  258. try {
  259. const errorData = await response.json();
  260. if (errorData.message) {
  261. errorMessage = errorData.message;
  262. } else if (errorData.error) {
  263. errorMessage = errorData.error;
  264. }
  265. } catch {
  266. // Ignore JSON parsing errors, use default message
  267. }
  268. throw new Error(errorMessage);
  269. }
  270. const reader = response.body?.getReader();
  271. if (!reader) {
  272. throw new Error("No response body");
  273. }
  274. const decoder = new TextDecoder();
  275. let buffer = "";
  276. while (true) {
  277. const { done, value } = await reader.read();
  278. if (done) break;
  279. buffer += decoder.decode(value, { stream: true });
  280. // Process complete lines
  281. const lines = buffer.split("\n");
  282. buffer = lines.pop() ?? "";
  283. for (const line of lines) {
  284. const trimmed = line.trim();
  285. if (!trimmed) continue;
  286. const result = parseLine(trimmed);
  287. if (!result) continue;
  288. if (result.type === "usage") {
  289. setUsage(result.usage);
  290. } else {
  291. setRawLines((prev) => [...prev, trimmed]);
  292. currentSpec = applyPatch(currentSpec, result.patch);
  293. setSpec({ ...currentSpec });
  294. }
  295. }
  296. }
  297. // Process any remaining buffer
  298. if (buffer.trim()) {
  299. const trimmed = buffer.trim();
  300. const result = parseLine(trimmed);
  301. if (result) {
  302. if (result.type === "usage") {
  303. setUsage(result.usage);
  304. } else {
  305. setRawLines((prev) => [...prev, trimmed]);
  306. currentSpec = applyPatch(currentSpec, result.patch);
  307. setSpec({ ...currentSpec });
  308. }
  309. }
  310. }
  311. options.onComplete?.(currentSpec);
  312. } catch (err) {
  313. if ((err as Error).name === "AbortError") {
  314. return;
  315. }
  316. const resolvedError = err instanceof Error ? err : new Error(String(err));
  317. setError(resolvedError);
  318. options.onError?.(resolvedError);
  319. } finally {
  320. setIsStreaming(false);
  321. }
  322. };
  323. // Cleanup on disposal
  324. onCleanup(() => {
  325. abortController?.abort();
  326. });
  327. return {
  328. get spec() {
  329. return spec();
  330. },
  331. get isStreaming() {
  332. return isStreaming();
  333. },
  334. get error() {
  335. return error();
  336. },
  337. get usage() {
  338. return usage();
  339. },
  340. get rawLines() {
  341. return rawLines();
  342. },
  343. send,
  344. clear,
  345. };
  346. }
  347. /**
  348. * Convert a flat element list to a Spec.
  349. * Input elements use key/parentKey to establish identity and relationships.
  350. * Output spec uses the map-based format where key is the map entry key
  351. * and parent-child relationships are expressed through children arrays.
  352. */
  353. export function flatToTree(elements: FlatElement[]): Spec {
  354. const elementMap: Record<string, UIElement> = {};
  355. let root = "";
  356. // First pass: add all elements to map
  357. for (const element of elements) {
  358. elementMap[element.key] = {
  359. type: element.type,
  360. props: element.props,
  361. children: [],
  362. visible: element.visible,
  363. };
  364. }
  365. // Second pass: build parent-child relationships
  366. for (const element of elements) {
  367. if (element.parentKey) {
  368. const parent = elementMap[element.parentKey];
  369. if (parent) {
  370. if (!parent.children) {
  371. parent.children = [];
  372. }
  373. parent.children.push(element.key);
  374. }
  375. } else {
  376. root = element.key;
  377. }
  378. }
  379. return { root, elements: elementMap };
  380. }
  381. // =============================================================================
  382. // useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
  383. // =============================================================================
  384. // Re-export useStateStore access for useBoundProp without circular import
  385. import { useStateStore as useStateStoreFromContext } from "./contexts/state";
  386. /**
  387. * Hook for two-way bound props. Returns `[value, setValue]` where:
  388. *
  389. * - `value` is the already-resolved prop value (passed through from render props)
  390. * - `setValue` writes back to the bound state path (no-op if not bound)
  391. *
  392. * Designed to work with the `bindings` map that the renderer provides when
  393. * a prop uses `{ $bindState: "/path" }` or `{ $bindItem: "field" }`.
  394. *
  395. * @example
  396. * ```tsx
  397. * import { useBoundProp } from "@json-render/solid";
  398. *
  399. * const Input: ComponentRenderer = (ctx) => {
  400. * const [value, setValue] = useBoundProp<string>(ctx.props.value, ctx.bindings?.value);
  401. * return <input value={value ?? ""} onInput={(e) => setValue(e.target.value)} />;
  402. * };
  403. * ```
  404. */
  405. export function useBoundProp<T>(
  406. propValue: T | undefined,
  407. bindingPath: string | undefined,
  408. ): [T | undefined, (value: T) => void] {
  409. const { set } = useStateStoreFromContext();
  410. const setValue = (value: T) => {
  411. if (bindingPath) set(bindingPath, value);
  412. };
  413. return [propValue, setValue];
  414. }
  415. // =============================================================================
  416. // buildSpecFromParts — Derive Spec from AI SDK data parts
  417. // =============================================================================
  418. /**
  419. * A single part from the AI SDK's `message.parts` array. This is a minimal
  420. * structural type so that library helpers do not depend on the AI SDK.
  421. * Fields are optional because different part types carry different data:
  422. * - Text parts have `text`
  423. * - Data parts have `data`
  424. */
  425. export interface DataPart {
  426. type: string;
  427. text?: string;
  428. data?: unknown;
  429. }
  430. /**
  431. * Build a `Spec` by replaying all spec data parts from a message's
  432. * parts array. Returns `null` if no spec data parts are present.
  433. *
  434. * This function is designed to work with the AI SDK's `UIMessage.parts` array.
  435. * It picks out parts whose `type` is {@link SPEC_DATA_PART_TYPE} and processes them based
  436. * on the payload's `type` discriminator:
  437. *
  438. * - `"patch"`: Applies the JSON Patch operation incrementally via `applySpecPatch`.
  439. * - `"flat"`: Replaces the spec with the complete flat spec.
  440. * - `"nested"`: Assigns the nested spec directly (future: nested-to-flat conversion).
  441. *
  442. * The function has no AI SDK dependency -- it operates on a generic array of
  443. * `{ type: string; data: unknown }` objects.
  444. *
  445. * @example
  446. * ```tsx
  447. * const spec = buildSpecFromParts(message.parts);
  448. * if (spec) {
  449. * return <MyRenderer spec={spec} />;
  450. * }
  451. * ```
  452. */
  453. /**
  454. * Type guard that validates a data part payload looks like a valid
  455. * {@link SpecDataPart} before we cast it. Returns `false` (and the
  456. * part is silently skipped) for malformed payloads.
  457. */
  458. function isSpecDataPart(data: unknown): data is SpecDataPart {
  459. if (typeof data !== "object" || data === null) return false;
  460. const obj = data as Record<string, unknown>;
  461. switch (obj.type) {
  462. case "patch":
  463. return typeof obj.patch === "object" && obj.patch !== null;
  464. case "flat":
  465. case "nested":
  466. return typeof obj.spec === "object" && obj.spec !== null;
  467. default:
  468. return false;
  469. }
  470. }
  471. export function buildSpecFromParts(parts: DataPart[]): Spec | null {
  472. const spec: Spec = { root: "", elements: {} };
  473. let hasSpec = false;
  474. for (const part of parts) {
  475. if (part.type === SPEC_DATA_PART_TYPE) {
  476. if (!isSpecDataPart(part.data)) continue;
  477. const payload = part.data;
  478. if (payload.type === "patch") {
  479. hasSpec = true;
  480. applySpecPatch(spec, payload.patch);
  481. } else if (payload.type === "flat") {
  482. hasSpec = true;
  483. Object.assign(spec, payload.spec);
  484. } else if (payload.type === "nested") {
  485. hasSpec = true;
  486. const flat = nestedToFlat(payload.spec);
  487. Object.assign(spec, flat);
  488. }
  489. }
  490. }
  491. return hasSpec ? spec : null;
  492. }
  493. /**
  494. * Extract and join all text content from a message's parts array.
  495. *
  496. * Filters for parts with `type === "text"`, trims each one, and joins them
  497. * with double newlines so that text from separate agent steps renders as
  498. * distinct paragraphs in markdown.
  499. *
  500. * Has no AI SDK dependency -- operates on a generic `DataPart[]`.
  501. *
  502. * @example
  503. * ```tsx
  504. * const text = getTextFromParts(message.parts);
  505. * if (text) {
  506. * return <Streamdown>{text}</Streamdown>;
  507. * }
  508. * ```
  509. */
  510. export function getTextFromParts(parts: DataPart[]): string {
  511. return parts
  512. .filter(
  513. (p): p is DataPart & { text: string } =>
  514. p.type === "text" && typeof p.text === "string",
  515. )
  516. .map((p) => p.text.trim())
  517. .filter(Boolean)
  518. .join("\n\n");
  519. }
  520. // =============================================================================
  521. // useJsonRenderMessage — extract spec + text from message parts
  522. // =============================================================================
  523. /**
  524. * Hook that extracts both the json-render spec and text content from a
  525. * message's parts array. Combines `buildSpecFromParts` and `getTextFromParts`
  526. * into a single call with memoized results.
  527. *
  528. * **Memoization behavior:** Results are recomputed only when the `parts`
  529. * accessor returns a new array reference with a different length or last
  530. * element. This is optimized for the typical AI SDK streaming pattern where
  531. * parts are appended incrementally.
  532. *
  533. * @example
  534. * ```tsx
  535. * import { useJsonRenderMessage } from "@json-render/solid";
  536. *
  537. * function MessageBubble(props: { parts: DataPart[] }) {
  538. * const msg = useJsonRenderMessage(() => props.parts);
  539. *
  540. * return (
  541. * <div>
  542. * {msg.text && <Markdown>{msg.text}</Markdown>}
  543. * {msg.hasSpec && <MyRenderer spec={msg.spec!} />}
  544. * </div>
  545. * );
  546. * }
  547. * ```
  548. */
  549. export function useJsonRenderMessage(getParts: () => DataPart[]) {
  550. const result = createMemo(() => {
  551. const parts = getParts();
  552. return {
  553. spec: buildSpecFromParts(parts),
  554. text: getTextFromParts(parts),
  555. };
  556. });
  557. return {
  558. get spec() {
  559. return result().spec;
  560. },
  561. get text() {
  562. return result().text;
  563. },
  564. get hasSpec() {
  565. const s = result().spec;
  566. return s !== null && Object.keys(s.elements || {}).length > 0;
  567. },
  568. };
  569. }
  570. // =============================================================================
  571. // useChatUI — Chat + GenUI hook
  572. // =============================================================================
  573. /**
  574. * A single message in the chat, which may contain text, a rendered UI spec, or both.
  575. */
  576. export interface ChatMessage {
  577. /** Unique message ID */
  578. id: string;
  579. /** Who sent this message */
  580. role: "user" | "assistant";
  581. /** Text content (conversational prose) */
  582. text: string;
  583. /** json-render Spec built from JSONL patches (null if no UI was generated) */
  584. spec: Spec | null;
  585. }
  586. /**
  587. * Options for useChatUI
  588. */
  589. export interface UseChatUIOptions {
  590. /** API endpoint that accepts `{ messages: Array<{ role, content }> }` and returns a text stream */
  591. api: string;
  592. /** Callback when streaming completes for a message */
  593. onComplete?: (message: ChatMessage) => void;
  594. /** Callback on error */
  595. onError?: (error: Error) => void;
  596. }
  597. /**
  598. * Return type for useChatUI
  599. */
  600. export interface UseChatUIReturn {
  601. /** All messages in the conversation */
  602. readonly messages: ChatMessage[];
  603. /** Whether currently streaming an assistant response */
  604. readonly isStreaming: boolean;
  605. /** Error from the last request, if any */
  606. readonly error: Error | null;
  607. /** Send a user message */
  608. send: (text: string) => Promise<void>;
  609. /** Clear all messages and reset the conversation */
  610. clear: () => void;
  611. }
  612. let chatMessageIdCounter = 0;
  613. function generateChatId(): string {
  614. if (
  615. typeof crypto !== "undefined" &&
  616. typeof crypto.randomUUID === "function"
  617. ) {
  618. return crypto.randomUUID();
  619. }
  620. chatMessageIdCounter += 1;
  621. return `msg-${Date.now()}-${chatMessageIdCounter}`;
  622. }
  623. /**
  624. * Hook for chat + GenUI experiences.
  625. *
  626. * Manages a multi-turn conversation where each assistant message can contain
  627. * both conversational text and a json-render UI spec. The hook sends the full
  628. * message history to the API endpoint, reads the streamed response, and
  629. * separates text lines from JSONL patch lines using `createMixedStreamParser`.
  630. *
  631. * @example
  632. * ```tsx
  633. * const chat = useChatUI({ api: "/api/chat" });
  634. *
  635. * // Send a message
  636. * await chat.send("Compare weather in NYC and Tokyo");
  637. *
  638. * // Render messages
  639. * <For each={chat.messages}>
  640. * {(msg) => (
  641. * <div>
  642. * {msg.text && <p>{msg.text}</p>}
  643. * {msg.spec && <MyRenderer spec={msg.spec} />}
  644. * </div>
  645. * )}
  646. * </For>
  647. * ```
  648. */
  649. export function useChatUI(options: UseChatUIOptions): UseChatUIReturn {
  650. const [messages, setMessages] = createSignal<ChatMessage[]>([]);
  651. const [isStreaming, setIsStreaming] = createSignal(false);
  652. const [error, setError] = createSignal<Error | null>(null);
  653. let abortController: AbortController | null = null;
  654. const clear = () => {
  655. setMessages([]);
  656. setError(null);
  657. };
  658. const send = async (text: string) => {
  659. if (!text.trim()) return;
  660. // Abort any existing request
  661. abortController?.abort();
  662. abortController = new AbortController();
  663. const userMessage: ChatMessage = {
  664. id: generateChatId(),
  665. role: "user",
  666. text: text.trim(),
  667. spec: null,
  668. };
  669. const assistantId = generateChatId();
  670. const assistantMessage: ChatMessage = {
  671. id: assistantId,
  672. role: "assistant",
  673. text: "",
  674. spec: null,
  675. };
  676. // Append user message and empty assistant placeholder
  677. setMessages((prev) => [...prev, userMessage, assistantMessage]);
  678. setIsStreaming(true);
  679. setError(null);
  680. // Build messages array for the API (full conversation history + new message).
  681. // Read current messages signal for latest state (avoids stale closure).
  682. const historyForApi = [
  683. ...messages()
  684. .filter((m) => m.id !== userMessage.id && m.id !== assistantId)
  685. .map((m) => ({
  686. role: m.role,
  687. content: m.text,
  688. })),
  689. { role: "user" as const, content: text.trim() },
  690. ];
  691. // Mutable state for accumulating the assistant response
  692. let accumulatedText = "";
  693. let currentSpec: Spec = { root: "", elements: {} };
  694. let hasSpec = false;
  695. try {
  696. const response = await fetch(options.api, {
  697. method: "POST",
  698. headers: { "Content-Type": "application/json" },
  699. body: JSON.stringify({ messages: historyForApi }),
  700. signal: abortController.signal,
  701. });
  702. if (!response.ok) {
  703. let errorMessage = `HTTP error: ${response.status}`;
  704. try {
  705. const errorData = await response.json();
  706. if (errorData.message) {
  707. errorMessage = errorData.message;
  708. } else if (errorData.error) {
  709. errorMessage = errorData.error;
  710. }
  711. } catch {
  712. // Ignore JSON parsing errors
  713. }
  714. throw new Error(errorMessage);
  715. }
  716. const reader = response.body?.getReader();
  717. if (!reader) {
  718. throw new Error("No response body");
  719. }
  720. const decoder = new TextDecoder();
  721. // Use createMixedStreamParser to classify lines
  722. const parser = createMixedStreamParser({
  723. onPatch(patch) {
  724. hasSpec = true;
  725. applySpecPatch(currentSpec, patch);
  726. setMessages((prev) =>
  727. prev.map((m) =>
  728. m.id === assistantId
  729. ? {
  730. ...m,
  731. spec: {
  732. root: currentSpec.root,
  733. elements: { ...currentSpec.elements },
  734. ...(currentSpec.state
  735. ? { state: { ...currentSpec.state } }
  736. : {}),
  737. },
  738. }
  739. : m,
  740. ),
  741. );
  742. },
  743. onText(line) {
  744. accumulatedText += (accumulatedText ? "\n" : "") + line;
  745. setMessages((prev) =>
  746. prev.map((m) =>
  747. m.id === assistantId ? { ...m, text: accumulatedText } : m,
  748. ),
  749. );
  750. },
  751. });
  752. while (true) {
  753. const { done, value } = await reader.read();
  754. if (done) break;
  755. parser.push(decoder.decode(value, { stream: true }));
  756. }
  757. parser.flush();
  758. // Build final message for onComplete callback
  759. const finalMessage: ChatMessage = {
  760. id: assistantId,
  761. role: "assistant",
  762. text: accumulatedText,
  763. spec: hasSpec
  764. ? {
  765. root: currentSpec.root,
  766. elements: { ...currentSpec.elements },
  767. ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}),
  768. }
  769. : null,
  770. };
  771. options.onComplete?.(finalMessage);
  772. } catch (err) {
  773. if ((err as Error).name === "AbortError") {
  774. return;
  775. }
  776. const resolvedError = err instanceof Error ? err : new Error(String(err));
  777. setError(resolvedError);
  778. // Remove empty assistant message on error
  779. setMessages((prev) =>
  780. prev.filter((m) => m.id !== assistantId || m.text.length > 0),
  781. );
  782. options.onError?.(resolvedError);
  783. } finally {
  784. setIsStreaming(false);
  785. }
  786. };
  787. // Cleanup on disposal
  788. onCleanup(() => {
  789. abortController?.abort();
  790. });
  791. return {
  792. get messages() {
  793. return messages();
  794. },
  795. get isStreaming() {
  796. return isStreaming();
  797. },
  798. get error() {
  799. return error();
  800. },
  801. send,
  802. clear,
  803. };
  804. }