Add full-featured AIChat component

AIChat (elements/ai/AIChat) is a complete LLM chat UI built on new hooks:
- useAIChat: streaming conversation state (send, stop, regenerate,
  edit, feedback); `send` may return a string, async iterable,
  ReadableStream or fetch Response
- useAIChatHistory: saved conversations with optional localStorage
  persistence and generated titles, shown in AIChatSidebar
- useAIChatSpeech: read aloud via browser speech synthesis or a
  custom `speak` returning audio
- useAIChatDictation: voice input via browser recognition or a
  custom `transcribe`

Also adds a model picker, message editing, thumbs up/down, markdown
export, scoped markdown styles in base.css, the twuiReadTextStream,
twuiStripMarkdown and twuiGroupByRecency utils (with tests), and a
playground page. AIPromptBlock is kept and marked deprecated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-26 10:45:46 +01:00
co-authored by Claude Opus 5.5
parent 1f74447d1c
commit 1c607c4bcd
21 changed files with 3933 additions and 1 deletions
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import twuiGroupByRecency from "./group-by-recency";
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date(2026, 9, 26, 15, 0, 0).getTime();
describe("twuiGroupByRecency", () => {
test("returns no groups for no items", () => {
expect(twuiGroupByRecency([], (n: number) => n, NOW)).toEqual([]);
});
test("buckets by recency, newest first, skipping empty groups", () => {
const items = [
NOW - 40 * DAY,
NOW - 1000,
NOW - DAY,
NOW - 3 * DAY,
NOW - 2000,
];
const groups = twuiGroupByRecency(items, (n) => n, NOW);
expect(groups.map((g) => g.label)).toEqual([
"Today",
"Yesterday",
"Previous 7 days",
new Date(NOW - 40 * DAY).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
}),
]);
expect(groups[0].items).toEqual([NOW - 1000, NOW - 2000]);
});
test("treats midnight today as today", () => {
const midnight = new Date(2026, 9, 26, 0, 0, 0).getTime();
expect(twuiGroupByRecency([midnight], (n) => n, NOW)[0].label).toBe(
"Today",
);
});
test("does not mutate the input", () => {
const items = [1, NOW];
twuiGroupByRecency(items, (n) => n, NOW);
expect(items).toEqual([1, NOW]);
});
});
+46
View File
@@ -0,0 +1,46 @@
export type TWUIRecencyGroup<T> = {
label: string;
items: T[];
};
const DAY = 24 * 60 * 60 * 1000;
/**
* # Group by recency
* Buckets items into "Today", "Yesterday", "Previous 7 days",
* "Previous 30 days" and then by month, newest first - the grouping chat
* apps use for conversation lists. Empty groups are left out.
*/
export default function twuiGroupByRecency<T>(
items: T[],
getTime: (item: T) => number,
now: number = Date.now(),
): TWUIRecencyGroup<T>[] {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const today = startOfToday.getTime();
const groups = new Map<string, T[]>();
const label = (time: number) => {
if (time >= today) return "Today";
if (time >= today - DAY) return "Yesterday";
if (time >= today - 7 * DAY) return "Previous 7 days";
if (time >= today - 30 * DAY) return "Previous 30 days";
return new Date(time).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
};
[...items]
.sort((a, b) => getTime(b) - getTime(a))
.forEach((item) => {
const key = label(getTime(item));
const list = groups.get(key);
if (list) list.push(item);
else groups.set(key, [item]);
});
return Array.from(groups, ([label, items]) => ({ label, items }));
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import twuiReadTextStream from "./read-text-stream";
async function collect(source: Parameters<typeof twuiReadTextStream>[0]) {
const chunks: string[] = [];
for await (const chunk of twuiReadTextStream(source)) chunks.push(chunk);
return chunks;
}
describe("twuiReadTextStream", () => {
test("yields a whole string once", async () => {
expect(await collect("hello")).toEqual(["hello"]);
});
test("yields nothing for an empty string", async () => {
expect(await collect("")).toEqual([]);
});
test("passes through an async iterable of strings", async () => {
async function* gen() {
yield "a";
yield "";
yield "b";
}
expect(await collect(gen())).toEqual(["a", "b"]);
});
test("decodes a byte stream, keeping split multi-byte characters", async () => {
const bytes = new TextEncoder().encode("é€");
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes.slice(0, 1));
controller.enqueue(bytes.slice(1, 3));
controller.enqueue(bytes.slice(3));
controller.close();
},
});
expect((await collect(stream)).join("")).toBe("é€");
});
test("reads the body of an ok Response", async () => {
const res = new Response("streamed text");
expect((await collect(res)).join("")).toBe("streamed text");
});
test("throws with the body text of a failed Response", async () => {
const res = new Response("Rate limited", { status: 429 });
await expect(collect(res)).rejects.toThrow("Rate limited");
});
test("throws with the status when a failed Response has no body", async () => {
const res = new Response(null, { status: 500 });
await expect(collect(res)).rejects.toThrow("status 500");
});
});
+84
View File
@@ -0,0 +1,84 @@
export type TWUITextStreamChunk = string | Uint8Array;
/**
* Anything a chat backend can hand back: a whole string, an async iterable
* of chunks (e.g. an async generator over an SDK stream), a web
* `ReadableStream`, or a fetch `Response` whose body is the text stream.
*/
export type TWUITextStreamSource =
| string
| AsyncIterable<TWUITextStreamChunk>
| ReadableStream<TWUITextStreamChunk>
| Response;
function isResponse(source: unknown): source is Response {
return typeof Response !== "undefined" && source instanceof Response;
}
function isReadableStream(
source: unknown,
): source is ReadableStream<TWUITextStreamChunk> {
return (
typeof source == "object" &&
source !== null &&
typeof (source as ReadableStream).getReader == "function"
);
}
/**
* # Read a text stream
* Normalizes a {@link TWUITextStreamSource} into an async iterable of text
* chunks. Byte chunks are decoded as UTF-8, keeping multi-byte characters
* split across chunks intact. A non-2xx `Response` throws with its body text.
*/
export default async function* twuiReadTextStream(
source: TWUITextStreamSource,
): AsyncGenerator<string, void, undefined> {
if (typeof source == "string") {
if (source) yield source;
return;
}
if (isResponse(source)) {
if (!source.ok) {
const body = await source.text().catch(() => "");
throw new Error(
body || `Request failed with status ${source.status}`,
);
}
if (!source.body) return;
yield* twuiReadTextStream(source.body);
return;
}
const decoder = new TextDecoder();
const decode = (chunk: TWUITextStreamChunk) =>
typeof chunk == "string"
? chunk
: decoder.decode(chunk, { stream: true });
if (isReadableStream(source)) {
// `getReader` rather than `for await`: not every browser makes
// `ReadableStream` async-iterable
const reader = source.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decode(value);
if (text) yield text;
}
} finally {
reader.releaseLock();
}
} else {
for await (const chunk of source) {
const text = decode(chunk);
if (text) yield text;
}
}
const rest = decoder.decode();
if (rest) yield rest;
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import twuiStripMarkdown from "./strip-markdown";
describe("twuiStripMarkdown", () => {
test("returns empty string for undefined input", () => {
expect(twuiStripMarkdown(undefined)).toBe("");
});
test("removes headings and emphasis", () => {
expect(twuiStripMarkdown("## Title\n\nSome **bold** and _italic_ text")).toBe(
"Title\n\nSome bold and italic text",
);
});
test("keeps link and image text", () => {
expect(twuiStripMarkdown("See [the docs](https://x.y) ![logo](a.png)")).toBe(
"See the docs logo",
);
});
test("removes list markers and blockquotes", () => {
expect(twuiStripMarkdown("- one\n* two\n1. three\n> quoted")).toBe(
"one\ntwo\nthree\nquoted",
);
});
test("unwraps inline code", () => {
expect(twuiStripMarkdown("Run `bun test` now")).toBe("Run bun test now");
});
test("drops fenced code blocks by default", () => {
expect(twuiStripMarkdown("Before\n\n```ts\nconst a = 1;\n```\n\nAfter")).toBe(
"Before\n\nAfter",
);
});
test("keeps code block contents when asked", () => {
expect(
twuiStripMarkdown("```ts\nconst a = 1;\n```", { keepCodeBlocks: true }),
).toBe("const a = 1;");
});
test("drops an unterminated (still streaming) code block", () => {
expect(twuiStripMarkdown("Text\n\n```js\nlet x")).toBe("Text");
});
test("flattens tables into readable rows", () => {
expect(
twuiStripMarkdown("| Prop | Use |\n| --- | --- |\n| send | stream |"),
).toBe("Prop, Use\nsend, stream");
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* # Strip markdown
* Reduces markdown to plain readable text, e.g. for text-to-speech or
* previews. Code blocks are dropped by default: read aloud, they are noise.
*/
export default function twuiStripMarkdown(
markdown?: string,
options?: { keepCodeBlocks?: boolean },
): string {
if (!markdown) return "";
let text = markdown.replace(/\r\n/g, "\n");
text = text.replace(/^(```|~~~)[^\n]*\n([\s\S]*?)(\n\1[ \t]*$|$(?![\s\S]))/gm, (_, __, code) =>
options?.keepCodeBlocks ? code : "",
);
text = text
// Images, then links: keep the visible text
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
// Inline code
.replace(/`([^`]+)`/g, "$1")
// Headings, blockquotes, list markers
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
.replace(/^\s{0,3}>\s?/gm, "")
.replace(/^\s*[-*+]\s+(\[[ xX]\]\s+)?/gm, "")
.replace(/^\s*\d+[.)]\s+/gm, "")
// Horizontal rules and table separator rows
.replace(/^\s*([-*_]\s*){3,}$/gm, "")
.replace(/^\s*\|?(\s*:?-+:?\s*\|)+\s*:?-*:?\s*\|?\s*$/gm, "")
// Table pipes
.replace(/^\s*\|/gm, "")
.replace(/\|\s*$/gm, "")
.replace(/\s*\|\s*/g, ", ")
// Emphasis and strikethrough
.replace(/(\*\*|__)(.+?)\1/g, "$2")
.replace(/(\*|_)(.+?)\1/g, "$2")
.replace(/~~(.+?)~~/g, "$1")
// HTML tags
.replace(/<\/?[a-zA-Z][^>]*>/g, "");
return text
.split("\n")
.map((line) => line.trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}