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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) ")).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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user