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 @@
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 }));
}