Skip to content

Streaming Diff

Show exactly what changed between responses — word-level diff highlights populated after every regenerate, retry, or subsequent send.

How It Works

stream.diff is null on the first send. On every subsequent send, once the stream completes, it's populated with a DiffChunk[] comparing the previous response to the new one.

While streaming: stream.text is live, stream.diff stays null. After done: stream.diff is populated.

Setup

Import and pass defaultDiff (built-in word-level diff, zero dependencies) or your own function:

svelte
<script lang="ts">
  import { Stream, defaultDiff } from "@aibind/sveltekit";

  const stream = new Stream({ model: "smart", diff: defaultDiff });
</script>

<button onclick={() => stream.send("Explain gravity")}>Send</button>
<button onclick={() => stream.retry()}>Regenerate</button>

{#if stream.diff}
  {#each stream.diff as chunk}
    {#if chunk.type === "keep"}
      <span>{chunk.text}</span>
    {:else if chunk.type === "add"}
      <ins>{chunk.text}</ins>
    {:else}
      <del>{chunk.text}</del>
    {/if}
  {/each}
{:else}
  <p>{stream.text}</p>
{/if}
tsx
"use client";
import { useStream, defaultDiff } from "@aibind/nextjs";

export default function Chat() {
  const { text, diff, send, retry } = useStream({
    model: "smart",
    diff: defaultDiff,
  });

  return (
    <>
      <button onClick={() => send("Explain gravity")}>Send</button>
      <button onClick={() => retry()}>Regenerate</button>
      {diff ? (
        <p>
          {diff.map((chunk, i) =>
            chunk.type === "keep" ? (
              <span key={i}>{chunk.text}</span>
            ) : chunk.type === "add" ? (
              <ins key={i}>{chunk.text}</ins>
            ) : (
              <del key={i}>{chunk.text}</del>
            ),
          )}
        </p>
      ) : (
        <p>{text}</p>
      )}
    </>
  );
}
vue
<script setup lang="ts">
import { useStream, defaultDiff } from "@aibind/nuxt";

const { text, diff, send, retry } = useStream({
  model: "smart",
  diff: defaultDiff,
});
</script>

<template>
  <button @click="send('Explain gravity')">Send</button>
  <button @click="retry()">Regenerate</button>
  <p v-if="diff">
    <template v-for="chunk in diff" :key="chunk.text">
      <span v-if="chunk.type === 'keep'">{{ chunk.text }}</span>
      <ins v-else-if="chunk.type === 'add'">{{ chunk.text }}</ins>
      <del v-else>{{ chunk.text }}</del>
    </template>
  </p>
  <p v-else>{{ text }}</p>
</template>
tsx
import { useStream, defaultDiff } from "@aibind/solidstart";
import { For, Show } from "solid-js";

export default function Chat() {
  const { text, diff, send, retry } = useStream({
    model: "smart",
    diff: defaultDiff,
  });

  return (
    <>
      <button onClick={() => send("Explain gravity")}>Send</button>
      <button onClick={() => retry()}>Regenerate</button>
      <Show when={diff()} fallback={<p>{text()}</p>}>
        <p>
          <For each={diff()!}>
            {(chunk) =>
              chunk.type === "keep" ? (
                <span>{chunk.text}</span>
              ) : chunk.type === "add" ? (
                <ins>{chunk.text}</ins>
              ) : (
                <del>{chunk.text}</del>
              )
            }
          </For>
        </p>
      </Show>
    </>
  );
}

Plug In Your Own Diff Library

diff accepts any function (prev: string, next: string) => DiffChunk[]. One-liner adapters for every major library:

ts
import { diffWords } from "diff";

const stream = new Stream({
  model: "smart",
  diff: (prev, next) =>
    diffWords(prev, next).map((c) => ({
      type: c.added ? "add" : c.removed ? "remove" : "keep",
      text: c.value,
    })),
});

fast-diff — character-level, 1.5kB

ts
import diff from "fast-diff";

const stream = new Stream({
  model: "smart",
  diff: (prev, next) =>
    diff(prev, next).map(([op, text]) => ({
      type: op === 1 ? "add" : op === -1 ? "remove" : "keep",
      text,
    })),
});

diff-match-patch — semantic cleanup available

ts
import { diff_match_patch, DIFF_INSERT, DIFF_DELETE } from "diff-match-patch";

const dmp = new diff_match_patch();

const stream = new Stream({
  model: "smart",
  diff: (prev, next) => {
    const diffs = dmp.diff_main(prev, next);
    dmp.diff_cleanupSemantic(diffs); // optional — improves readability
    return diffs.map(([op, text]) => ({
      type: op === DIFF_INSERT ? "add" : op === DIFF_DELETE ? "remove" : "keep",
      text,
    }));
  },
});

Built-in defaultDiff

defaultDiff is a word-level LCS diff with no dependencies. It splits on whitespace boundaries, runs standard O(m·n) LCS, and merges adjacent same-type chunks.

Good for: typical AI response sizes (< ~2,000 words). For very long outputs or semantic-quality diffs, use diff with diffSentences or diff-match-patch with diff_cleanupSemantic.

API Reference

Option

OptionTypeDescription
diffDiffFnFunction called after every completed stream to compute the diff. Pass defaultDiff or your own adapter. Omit to disable.

DiffChunk

ts
interface DiffChunk {
  type: "keep" | "add" | "remove";
  text: string;
}

DiffFn

ts
type DiffFn = (prev: string, next: string) => DiffChunk[];

Reactive state

PropertyTypeDescription
stream.diff / diff()DiffChunk[] | nullnull on first send or while streaming. Populated once stream is done and a previous response exists.

Released under the MIT License.