Ai Llm Chat
blockaiChatGPT/Claude-style LLM chat surface. Collapsible left sidebar with New chat, searchable thread history grouped Today/Yesterday/Previous 7 days/Earlier, account row. Centered conversation: empty state with suggested-prompt tiles, user/assistant turns with code-block rendering and copy/regenerate/feedback actions, streaming dots, stop button. Composer with model picker (Opus/Sonnet/Haiku), attach, Enter-to-send, autosizing textarea. Stateful demo — wire `send()` to your streaming endpoint.
Installation
$pnpm dlx uipkge-ng@latest add ai-llm-chat$npx uipkge-ng@latest add ai-llm-chat$yarn dlx uipkge-ng@latest add ai-llm-chat$bunx uipkge-ng@latest add ai-llm-chatManifest:
https://uipkge.dev/r/angular/ai-llm-chat.jsonInstalls to:src/app/components/blocks/ai-llm-chat/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
AiLlmChatTurninterface AiLlmChatTurn {
id: string
role: AiLlmChatRole
body: string
code?: { lang: string; content: string }
createdAt: Date
}AiLlmChatThreadinterface AiLlmChatThread {
id: string
title: string
updatedAt: Date
turns: AiLlmChatTurn[]
}Modelinterface Model {
id: string
label: string
hint: string
}npm dependencies
Includes
Files installed (2)
src/app/components/blocks/ai-llm-chat/ai-llm-chat.component.ts38 kB
import { Component, DestroyRef, ElementRef, EventEmitter, Output, ViewChild, afterNextRender, afterRenderEffect, computed, inject, signal, ChangeDetectionStrategy, } from '@angular/core' import { ArrowUp, BookOpen, Check, ChevronDown, Code, Copy, Lightbulb, LucideAngularModule, MessageSquarePlus, MoreHorizontal, Paperclip, PanelLeft, PanelLeftClose, PenLine, Pencil, RefreshCw, Search, Settings, Sparkles, Square, ThumbsDown, ThumbsUp, Trash2, X, type LucideIconData, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiDropdownMenuComponent, UiDropdownMenuContentComponent, UiDropdownMenuItemComponent, UiDropdownMenuSeparatorComponent, UiDropdownMenuTriggerComponent, } from '@/ui/dropdown-menu/dropdown-menu.component' export type AiLlmChatRole = 'user' | 'assistant' export type AiLlmChatFeedback = 'up' | 'down' export interface AiLlmChatTurn { id: string role: AiLlmChatRole body: string code?: { lang: string; content: string } createdAt: Date } export interface AiLlmChatThread { id: string title: string updatedAt: Date turns: AiLlmChatTurn[] } interface Model { id: string label: string hint: string } const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000) const sevenDays = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) const PLACEHOLDER_REPLY = 'Here is a placeholder response. Wire `onSend` to your streaming endpoint and push assistant tokens onto the last turn as they arrive.' const suggestions: { icon: LucideIconData; label: string; body: string }[] = [ { icon: Code, label: 'Explain this code', body: 'Walk me through what `useRegistry()` does line by line.' }, { icon: PenLine, label: 'Draft a release note', body: 'Write a release note for shipping the new inbox + chat blocks.', }, { icon: Lightbulb, label: 'Brainstorm', body: 'Give me five ideas for verticals to build on top of the uipkge registry.', }, { icon: BookOpen, label: 'Summarise', body: 'Summarise the CLAUDE.md "Primitive vs Block" boundary in three bullets.', }, ] const models: Model[] = [ { id: 'opus-4-7', label: 'Opus 4.7', hint: 'Most capable' }, { id: 'sonnet-4-6', label: 'Sonnet 4.6', hint: 'Balanced' }, { id: 'haiku-4-5', label: 'Haiku 4.5', hint: 'Fastest' }, ] const initialThreads: AiLlmChatThread[] = [ { id: 't1', title: 'Designing the registry block boundary', updatedAt: new Date(now.getTime() - 12 * 60 * 1000), turns: [ { id: 'u1', role: 'user', body: 'When should I extract a repeating tile pattern across blocks into a primitive?', createdAt: new Date(now.getTime() - 22 * 60 * 1000), }, { id: 'a1', role: 'assistant', body: 'Short answer: almost never until you have 5+ distinct blocks calling for the *exact same* shape, and the extraction is slot-only with no layout opinion.\n\nThe trap is that StatCard-style primitives — ones that bake an "icon top-right, value below title, trend pill bottom-right" layout — look reusable but lock every block that consumes them. The user installing the block can\'t swap one tile into a chart-led tile without un-abstracting the primitive first.\n\nThe heuristic the CLAUDE.md spells out:', createdAt: new Date(now.getTime() - 21 * 60 * 1000), }, { id: 'a1b', role: 'assistant', body: 'Here is the minimal check you can run on any candidate primitive:', code: { lang: 'ts', content: '// If the prop changes BEHAVIOR -> primitive (variant, disabled, multiple)\n// If the prop changes LAYOUT or WHICH FIELDS RENDER -> block territory\n//\n// Red flag: a primitive that accepts an `items` array of structured records\n// and renders them with a fixed shape. That is layout opinion in a prop.\ninterface KpiItem { title: string; value: string; icon: any; trend: number }\n// ^ if you see this on a `registry:ui` item, stop.', }, createdAt: new Date(now.getTime() - 20 * 60 * 1000), }, { id: 'u2', role: 'user', body: 'And if the same 3 tiles repeat inside one block file?', createdAt: new Date(now.getTime() - 14 * 60 * 1000), }, { id: 'a2', role: 'assistant', body: 'Leave them inline. The registry rule is "three similar lines is better than a premature abstraction." Blocks are supposed to read top-to-bottom at the call site — that is the whole reason a user installs a block instead of a primitive. The cost of writing the tile out three times is small; the cost of pre-abstracting and getting the shape slightly wrong is high (every consumer downstream pays for it).', createdAt: new Date(now.getTime() - 13 * 60 * 1000), }, ], }, { id: 't2', title: 'Tailwind v4 OKLCH dark mode tokens', updatedAt: new Date(now.getTime() - 3 * 60 * 60 * 1000), turns: [], }, { id: 't3', title: 'Zero-downtime migration plan', updatedAt: yesterday, turns: [], }, { id: 't4', title: 'Composing kanban + calendar in one view', updatedAt: new Date(now.getTime() - 30 * 60 * 60 * 1000), turns: [], }, { id: 't5', title: 'shadcn-vue resolver circular warning', updatedAt: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000), turns: [], }, { id: 't6', title: 'Cloudflare Pages preview env wiring', updatedAt: new Date(now.getTime() - 9 * 24 * 60 * 60 * 1000), turns: [], }, ] function formatTime(d: Date): string { if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) if (d.toDateString() === yesterday.toDateString()) return 'Yesterday' return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } function titleFromBody(body: string): string { return body.slice(0, 48) + (body.length > 48 ? '…' : '') } function newThread(): AiLlmChatThread { return { id: `t${Date.now()}`, title: 'New chat', updatedAt: new Date(), turns: [] } } // The streaming dots' keyframes (React injects the same rules as a <style> tag). Longhand // animation properties so the per-dot `[animation-delay:…]` utilities (React's inline // animationDelay) are not reset by an unlayered `animation` shorthand. const TYPING_STYLES = ` .typing-dot { display: inline-block; width: 6px; height: 6px; border-radius: 9999px; background: currentColor; opacity: 0.5; animation-name: typing-bounce; animation-duration: 1s; animation-iteration-count: infinite; animation-timing-function: ease-in-out; } @keyframes typing-bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.35; } 40% { transform: translateY(-3px); opacity: 0.9; } } @media (prefers-reduced-motion: reduce) { .typing-dot { animation-name: none; opacity: 0.7; } } ` /** * ChatGPT/Claude-style LLM chat surface (Angular port of the React `AiLlmChat` block). * Stateful demo: `send` pushes a placeholder assistant turn on a timer. Listen to * `(send)` / `(regenerate)` / `(stop)` (React `onSend` / `onRegenerate` / `onStop`) and * wire them to your streaming endpoint. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-ai-llm-chat, [ui-ai-llm-chat]', standalone: true, host: { class: 'contents' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiButtonComponent, UiInputComponent, UiDropdownMenuComponent, UiDropdownMenuTriggerComponent, UiDropdownMenuContentComponent, UiDropdownMenuItemComponent, UiDropdownMenuSeparatorComponent, ], styles: [TYPING_STYLES], template: ` <ng-template #searchIcon><lucide-icon [img]="Search" class="size-3.5" /></ng-template> <div data-slot="ai-llm-chat" [class]="rootClass()"> <aside class="bg-muted/30 flex min-h-0 min-w-0 flex-col overflow-hidden border-r"> <div class="flex h-14 shrink-0 items-center gap-2 border-b px-3"> <button ui-button class="h-9 flex-1 justify-start gap-2 rounded-lg" variant="outline" (click)="newChat()"> <lucide-icon [img]="MessageSquarePlus" class="size-4" /> New chat </button> <button ui-button variant="ghost" size="icon" class="size-8 shrink-0" aria-label="Collapse sidebar" (click)="sidebarOpen.set(false)" > <lucide-icon [img]="PanelLeftClose" class="size-4" /> </button> </div> <div class="px-3 pt-3 pb-2"> <ui-input [value]="sidebarSearch()" (valueChange)="sidebarSearch.set($event)" placeholder="Search chats" size="small" [prefixIcon]="searchIcon" class="rounded-lg" /> </div> <div class="min-h-0 flex-1 overflow-y-auto pb-3"> @if (filteredThreads().length === 0) { <p class="text-muted-foreground px-3 py-6 text-center text-xs"> No chats match “{{ sidebarSearch().trim() }}” </p> } @for (group of groupedThreads(); track group.key) { <p class="text-muted-foreground px-3 pt-3 pb-1 text-xs font-medium tracking-wide uppercase"> {{ group.key }} </p> @for (t of group.items; track t.id) { <div [class]="threadRowClass(t)"> @if (renamingId() === t.id) { <!-- No autofocus, on purpose: React's autoFocus here is swallowed by Radix's menu focus trap, so the field opens unfocused and focus returns to the row's trigger. Focusing it now would be blurred by that focus return, which commits and closes the rename. --> <input [value]="renameDraft()" (input)="onRenameInput($event)" class="border-input bg-background focus-visible:ring-ring h-7 min-w-0 flex-1 rounded-md border px-2 text-sm outline-none focus-visible:ring-2" (keydown)="onRenameKey($event)" (blur)="commitRename()" /> } @else { <button type="button" [class]="threadTitleClass(t)" (click)="selectThread(t.id)"> {{ t.title }} </button> } <ui-dropdown-menu> <button ui-button ui-dropdown-menu-trigger variant="ghost" size="icon" class="text-muted-foreground size-7 shrink-0 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100" aria-label="Thread actions" (click)="$event.stopPropagation()" > <lucide-icon [img]="MoreHorizontal" class="size-3.5" /> </button> <ui-dropdown-menu-content align="end" class="w-40"> <ui-dropdown-menu-item (select)="startRename(t)"> <lucide-icon [img]="Pencil" class="size-3.5" /> Rename </ui-dropdown-menu-item> <ui-dropdown-menu-separator /> <ui-dropdown-menu-item variant="destructive" (select)="deleteThread(t.id)"> <lucide-icon [img]="Trash2" class="size-3.5" /> Delete </ui-dropdown-menu-item> </ui-dropdown-menu-content> </ui-dropdown-menu> </div> } } </div> <div class="border-t px-3 py-3"> <div class="flex items-center gap-2"> <ui-avatar class="size-8"> <ui-avatar-fallback class="text-xs font-medium">U</ui-avatar-fallback> </ui-avatar> <div class="min-w-0 flex-1"> <p class="truncate text-xs font-medium">You</p> <p class="text-muted-foreground truncate text-xs">Free plan</p> </div> <ui-dropdown-menu> <button ui-button ui-dropdown-menu-trigger variant="ghost" size="icon" class="size-8" aria-label="Account menu" > <lucide-icon [img]="MoreHorizontal" class="size-4" /> </button> <ui-dropdown-menu-content align="end" class="w-44"> <ui-dropdown-menu-item> <lucide-icon [img]="Settings" class="size-3.5" /> Settings </ui-dropdown-menu-item> <ui-dropdown-menu-item>Upgrade plan</ui-dropdown-menu-item> </ui-dropdown-menu-content> </ui-dropdown-menu> </div> </div> </aside> <section class="flex min-h-0 min-w-0 flex-col"> <header class="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4"> <div class="flex min-w-0 items-center gap-2"> @if (!sidebarOpen()) { <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Open sidebar" (click)="sidebarOpen.set(true)" > <lucide-icon [img]="PanelLeft" class="size-4" /> </button> } <ui-dropdown-menu> <button type="button" ui-dropdown-menu-trigger class="hover:bg-muted/60 flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors" > <lucide-icon [img]="Sparkles" class="text-primary size-3.5" /> {{ activeModel().label }} <lucide-icon [img]="ChevronDown" class="text-muted-foreground size-3.5" /> </button> <ui-dropdown-menu-content align="start" class="w-56"> @for (m of models; track m.id) { <ui-dropdown-menu-item (select)="activeModel.set(m)"> <div class="min-w-0 flex-1"> <p class="font-medium">{{ m.label }}</p> <p class="text-muted-foreground text-xs">{{ m.hint }}</p> </div> @if (m.id === activeModel().id) { <lucide-icon [img]="Check" class="text-primary size-3.5 shrink-0" /> } </ui-dropdown-menu-item> } </ui-dropdown-menu-content> </ui-dropdown-menu> </div> <ui-dropdown-menu> <button ui-button ui-dropdown-menu-trigger variant="ghost" size="icon" class="size-8" aria-label="More options" > <lucide-icon [img]="MoreHorizontal" class="size-4" /> </button> <ui-dropdown-menu-content align="end" class="w-48"> <ui-dropdown-menu-item [disabled]="isEmpty()" (select)="copyConversation()"> <lucide-icon [img]="Copy" class="size-3.5" /> {{ copiedId() === 'conversation' ? 'Copied' : 'Copy conversation' }} </ui-dropdown-menu-item> <ui-dropdown-menu-separator /> <ui-dropdown-menu-item variant="destructive" [disabled]="isEmpty()" (select)="clearThread()"> <lucide-icon [img]="Trash2" class="size-3.5" /> Clear thread </ui-dropdown-menu-item> </ui-dropdown-menu-content> </ui-dropdown-menu> </header> <div #scrollRoot class="min-h-0 flex-1 overflow-y-auto" role="log" aria-live="polite" [attr.aria-busy]="isStreaming()" > @if (isEmpty()) { <div class="mx-auto flex h-full max-w-2xl flex-col items-center justify-center gap-6 px-6"> <div class="bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full"> <lucide-icon [img]="Sparkles" class="size-6" /> </div> <div class="text-center"> <h2 class="text-xl font-semibold tracking-tight">How can I help today?</h2> <p class="text-muted-foreground mt-1 text-sm">Ask anything, or try one of these to get started.</p> </div> <div class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2"> @for (s of suggestions; track s.label) { <button type="button" class="bg-card hover:bg-muted/60 group flex items-start gap-3 rounded-lg border p-3 text-left transition-colors" (click)="send(s.body)" > <div class="bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary flex size-8 shrink-0 items-center justify-center rounded-md transition-colors" > <lucide-icon [img]="s.icon" class="size-4" /> </div> <div class="min-w-0"> <p class="text-foreground text-xs font-medium">{{ s.label }}</p> <p class="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">{{ s.body }}</p> </div> </button> } </div> </div> } @else { <div class="mx-auto max-w-3xl space-y-6 px-6 py-6"> @for (t of turns(); track t.id) { <div class="group"> @if (t.role === 'user') { <div class="flex justify-end"> <div class="bg-primary/10 text-foreground max-w-[80%] rounded-2xl rounded-tr-md px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap" > {{ t.body }} </div> </div> } @else { <div class="flex items-start gap-3"> <div class="bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full" > <lucide-icon [img]="Sparkles" class="size-3.5" /> </div> <div class="min-w-0 flex-1 space-y-3"> <p class="text-foreground text-sm leading-relaxed whitespace-pre-wrap">{{ t.body }}</p> @if (t.code; as code) { <div class="bg-muted/60 overflow-hidden rounded-lg border"> <div class="bg-muted/80 text-muted-foreground flex items-center justify-between px-3 py-1.5 font-mono text-xs" > <span>{{ code.lang }}</span> <button type="button" class="hover:text-foreground inline-flex items-center gap-1 transition-colors" (click)="copyTurn(t.id + '-code', code.content)" > @if (copiedId() === t.id + '-code') { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } {{ copiedId() === t.id + '-code' ? 'Copied' : 'Copy' }} </button> </div> <pre class="overflow-x-auto px-3 py-2.5 font-mono text-xs leading-relaxed" ><code>{{ code.content }}</code></pre> </div> } <div class="flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:focus-within:opacity-100" > <button type="button" class="text-muted-foreground hover:bg-muted hover:text-foreground inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs transition-colors" (click)="copyTurn(t.id, t.body)" > @if (copiedId() === t.id) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } {{ copiedId() === t.id ? 'Copied' : 'Copy' }} </button> <button type="button" class="text-muted-foreground hover:bg-muted hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors" aria-label="Regenerate" (click)="regenerate(t.id)" > <lucide-icon [img]="RefreshCw" class="size-3" /> </button> <button type="button" [class]="feedbackClass(t.id, 'up')" aria-label="Good response" [attr.aria-pressed]="hasFeedback(t.id, 'up')" (click)="toggleFeedback(t.id, 'up')" > <lucide-icon [img]="ThumbsUp" class="size-3" /> </button> <button type="button" [class]="feedbackClass(t.id, 'down')" aria-label="Bad response" [attr.aria-pressed]="hasFeedback(t.id, 'down')" (click)="toggleFeedback(t.id, 'down')" > <lucide-icon [img]="ThumbsDown" class="size-3" /> </button> <span class="text-muted-foreground ml-auto text-xs tabular-nums">{{ formatTime(t.createdAt) }}</span> </div> </div> </div> } </div> } @if (isStreaming()) { <div class="flex items-start gap-3"> <div class="bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full" > <lucide-icon [img]="Sparkles" class="size-3.5" /> </div> <div class="text-muted-foreground flex items-center gap-1.5 pt-1.5" aria-label="Generating"> <span class="typing-dot"></span> <span class="typing-dot [animation-delay:0.15s]"></span> <span class="typing-dot [animation-delay:0.3s]"></span> </div> </div> } </div> } </div> <div class="bg-muted/20 shrink-0 border-t px-4 py-3"> <div class="mx-auto max-w-3xl"> <div class="bg-background border-input/60 focus-within:border-primary/40 focus-within:ring-primary/10 flex flex-col overflow-hidden rounded-2xl border shadow-sm transition-colors duration-150 focus-within:shadow-md focus-within:ring-4" > @if (attachment(); as file) { <div class="flex items-center gap-2 px-3 pt-3"> <span class="bg-muted text-foreground inline-flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs" > <lucide-icon [img]="Paperclip" class="size-3 shrink-0" /> <span class="truncate">{{ file.name }}</span> <button type="button" class="hover:text-destructive" aria-label="Remove attachment" (click)="attachment.set(null)" > <lucide-icon [img]="X" class="size-3" /> </button> </span> </div> } <textarea #composer [value]="draft()" (input)="onDraftInput($event)" [placeholder]="'Message ' + activeModel().label" rows="1" class="placeholder:text-muted-foreground/70 max-h-48 min-h-12 w-full resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed outline-none" (keydown)="onComposerKey($event)" ></textarea> <div class="flex items-center justify-between gap-2 px-2 pb-2"> <div class="flex items-center gap-0.5"> <input #fileInput type="file" class="sr-only" (change)="onAttach($event)" /> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" aria-label="Attach file" (click)="fileInput.click()" > <lucide-icon [img]="Paperclip" class="size-4" /> </button> <span class="text-muted-foreground bg-muted ml-1 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium" > <lucide-icon [img]="Sparkles" class="text-primary size-3" /> {{ activeModel().label }} </span> </div> <div class="flex items-center gap-2"> <kbd class="bg-muted text-muted-foreground hidden h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-xs sm:inline-flex" > ↵ </kbd> @if (isStreaming()) { <button ui-button variant="outline" class="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium" aria-label="Stop generating" (click)="stop()" > <lucide-icon [img]="Square" class="size-3 fill-current" /> Stop </button> } @else { <button ui-button class="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium" [disabled]="!draft().trim() && !attachment()" aria-label="Send" (click)="send()" > Send <lucide-icon [img]="ArrowUp" class="size-3.5" /> </button> } </div> </div> </div> <p class="text-muted-foreground/70 mt-2 text-center text-xs"> AI can make mistakes. Verify important info. </p> </div> </div> </section> </div> `, }) export class UiAiLlmChatComponent { /** React `onSend`: the user turn's content (draft plus "Attached: <file>"). */ @Output('send') readonly sent = new EventEmitter<string>() /** React `onRegenerate`: the id of the assistant turn being regenerated. */ @Output('regenerate') readonly regenerated = new EventEmitter<string>() /** React `onStop`: the user stopped the stream. */ @Output('stop') readonly stopped = new EventEmitter<void>() @ViewChild('scrollRoot', { static: true }) scrollRoot?: ElementRef<HTMLDivElement> @ViewChild('composer', { static: true }) composer?: ElementRef<HTMLTextAreaElement> protected readonly suggestions = suggestions protected readonly models = models readonly threads = signal<AiLlmChatThread[]>(initialThreads) readonly activeId = signal('t1') readonly draft = signal('') readonly sidebarOpen = signal(true) readonly sidebarSearch = signal('') readonly activeModel = signal<Model>(models[0]!) readonly isStreaming = signal(false) readonly copiedId = signal<string | null>(null) readonly feedback = signal<Partial<Record<string, AiLlmChatFeedback>>>({}) readonly attachment = signal<{ name: string } | null>(null) readonly renamingId = signal<string | null>(null) readonly renameDraft = signal('') private streamTimer: ReturnType<typeof setTimeout> | null = null private copyTimer: ReturnType<typeof setTimeout> | null = null readonly activeThread = computed(() => this.threads().find((t) => t.id === this.activeId()) ?? this.threads()[0]) readonly turns = computed(() => this.activeThread()?.turns ?? []) readonly isEmpty = computed(() => this.turns().length === 0) readonly filteredThreads = computed(() => { const q = this.sidebarSearch().trim().toLowerCase() return q ? this.threads().filter((t) => t.title.toLowerCase().includes(q)) : this.threads() }) readonly groupedThreads = computed(() => { const today: AiLlmChatThread[] = [] const ydy: AiLlmChatThread[] = [] const week: AiLlmChatThread[] = [] const earlier: AiLlmChatThread[] = [] for (const t of [...this.filteredThreads()].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())) { if (t.updatedAt >= todayStart) today.push(t) else if (t.updatedAt.toDateString() === yesterday.toDateString()) ydy.push(t) else if (t.updatedAt >= sevenDays) week.push(t) else earlier.push(t) } return [ { key: 'Today', items: today }, { key: 'Yesterday', items: ydy }, { key: 'Previous 7 days', items: week }, { key: 'Earlier', items: earlier }, ].filter((g) => g.items.length > 0) }) readonly rootClass = computed(() => cn( 'bg-background text-foreground grid h-[680px] w-full overflow-hidden rounded-xl border shadow-sm', this.sidebarOpen() ? 'grid-cols-[16.25rem_1fr]' : 'grid-cols-[0fr_1fr]', ), ) constructor() { // React's mount effect: start open, collapse after the first render on narrow screens. afterNextRender(() => { if (window.innerWidth < 768) this.sidebarOpen.set(false) }) // React effects: keep the log scrolled to the newest turn, and autosize the composer. afterRenderEffect(() => { this.activeId() this.turns().length this.isStreaming() this.scrollToBottom() }) afterRenderEffect(() => { this.draft() this.autosizeComposer() }) inject(DestroyRef).onDestroy(() => { if (this.streamTimer) clearTimeout(this.streamTimer) if (this.copyTimer) clearTimeout(this.copyTimer) }) } formatTime(d: Date): string { return formatTime(d) } threadRowClass(t: AiLlmChatThread): string { return cn( 'group flex w-full items-center gap-0.5 px-1.5 py-0.5', t.id === this.activeId() ? 'bg-primary/10' : 'hover:bg-muted/60', ) } threadTitleClass(t: AiLlmChatThread): string { return cn( 'min-w-0 flex-1 truncate px-1.5 py-1.5 text-left text-sm', t.id === this.activeId() ? 'text-foreground font-medium' : 'text-foreground/80', ) } hasFeedback(id: string, value: AiLlmChatFeedback): boolean { return this.feedback()[id] === value } feedbackClass(id: string, value: AiLlmChatFeedback): string { const on = this.hasFeedback(id, value) const tone = value === 'up' ? on ? 'text-success' : 'text-muted-foreground hover:text-success' : on ? 'text-destructive' : 'text-muted-foreground hover:text-destructive' return cn('hover:bg-muted inline-flex size-7 items-center justify-center rounded-md transition-colors', tone) } private scrollToBottom(): void { const el = this.scrollRoot?.nativeElement if (!el) return el.scrollTop = el.scrollHeight } private autosizeComposer(): void { const el = this.composer?.nativeElement if (!el) return el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, 192)}px` } selectThread(id: string): void { this.activeId.set(id) this.renamingId.set(null) if (window.innerWidth < 768) this.sidebarOpen.set(false) } newChat(): void { const t = newThread() this.threads.update((prev) => [t, ...prev]) this.activeId.set(t.id) this.draft.set('') this.attachment.set(null) requestAnimationFrame(() => this.composer?.nativeElement.focus()) } private patchThread(id: string, updater: (t: AiLlmChatThread) => AiLlmChatThread): void { this.threads.update((prev) => prev.map((t) => (t.id === id ? updater(t) : t))) } private startStream(threadId: string): void { this.isStreaming.set(true) if (this.streamTimer) clearTimeout(this.streamTimer) this.streamTimer = setTimeout(() => { this.patchThread(threadId, (t) => ({ ...t, turns: [ ...t.turns, { id: `a${Date.now()}`, role: 'assistant', body: PLACEHOLDER_REPLY, createdAt: new Date() }, ], })) this.isStreaming.set(false) this.streamTimer = null }, 1400) } send(prefill?: string): void { const body = (prefill ?? this.draft()).trim() const attachment = this.attachment() if ((!body && !attachment) || this.isStreaming()) return const content = attachment ? `${body}${body ? '\n\n' : ''}Attached: ${attachment.name}` : body const threadId = this.activeId() this.patchThread(threadId, (t) => ({ ...t, updatedAt: new Date(), title: t.title === 'New chat' ? titleFromBody(content) : t.title, turns: [...t.turns, { id: `u${Date.now()}`, role: 'user', body: content, createdAt: new Date() }], })) this.draft.set('') this.attachment.set(null) this.sent.emit(content) this.startStream(threadId) } stop(): void { if (this.streamTimer) { clearTimeout(this.streamTimer) this.streamTimer = null } this.isStreaming.set(false) this.stopped.emit() } regenerate(id: string): void { if (this.isStreaming()) return const threadId = this.activeId() this.patchThread(threadId, (t) => { const idx = t.turns.findIndex((turn) => turn.id === id) if (idx < 0) return t return { ...t, turns: t.turns.slice(0, idx) } }) this.regenerated.emit(id) this.startStream(threadId) } copyTurn(id: string, body: string): void { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(body).catch(() => {}) } this.copiedId.set(id) if (this.copyTimer) clearTimeout(this.copyTimer) this.copyTimer = setTimeout(() => { this.copiedId.update((curr) => (curr === id ? null : curr)) }, 1500) } copyConversation(): void { const text = this.turns() .map((t) => `${t.role === 'user' ? 'You' : 'Assistant'}: ${t.body}`) .join('\n\n') this.copyTurn('conversation', text) } clearThread(): void { this.stop() this.patchThread(this.activeId(), (t) => ({ ...t, turns: [], title: 'New chat' })) } deleteThread(id: string): void { const remaining = this.threads().filter((t) => t.id !== id) if (remaining.length === 0) { const t = newThread() this.activeId.set(t.id) this.threads.set([t]) return } if (id === this.activeId()) this.activeId.set(remaining[0]!.id) this.threads.set(remaining) } startRename(t: AiLlmChatThread): void { this.renamingId.set(t.id) this.renameDraft.set(t.title) } commitRename(): void { const id = this.renamingId() if (!id) return const next = this.renameDraft().trim() if (next) this.threads.update((prev) => prev.map((t) => (t.id === id ? { ...t, title: next } : t))) this.renamingId.set(null) } toggleFeedback(id: string, value: AiLlmChatFeedback): void { this.feedback.update((prev) => { const next = { ...prev } if (next[id] === value) delete next[id] else next[id] = value return next }) } onRenameInput(event: Event): void { this.renameDraft.set((event.target as HTMLInputElement).value) } onRenameKey(event: KeyboardEvent): void { if (event.key === 'Enter') { event.preventDefault() this.commitRename() } if (event.key === 'Escape') { event.preventDefault() this.renamingId.set(null) } } onDraftInput(event: Event): void { this.draft.set((event.target as HTMLTextAreaElement).value) } onComposerKey(event: KeyboardEvent): void { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() this.send() } } onAttach(event: Event): void { const input = event.target as HTMLInputElement const file = input.files?.[0] if (file) this.attachment.set({ name: file.name }) input.value = '' } protected readonly MessageSquarePlus = MessageSquarePlus protected readonly PanelLeftClose = PanelLeftClose protected readonly PanelLeft = PanelLeft protected readonly Search = Search protected readonly MoreHorizontal = MoreHorizontal protected readonly Pencil = Pencil protected readonly Trash2 = Trash2 protected readonly Settings = Settings protected readonly Sparkles = Sparkles protected readonly ChevronDown = ChevronDown protected readonly Check = Check protected readonly Copy = Copy protected readonly RefreshCw = RefreshCw protected readonly ThumbsUp = ThumbsUp protected readonly ThumbsDown = ThumbsDown protected readonly Paperclip = Paperclip protected readonly X = X protected readonly Square = Square protected readonly ArrowUp = ArrowUp }src/app/components/blocks/ai-llm-chat/index.ts0.2 kB
export { UiAiLlmChatComponent, type AiLlmChatFeedback, type AiLlmChatRole, type AiLlmChatThread, type AiLlmChatTurn, } from './ai-llm-chat.component'
Raw manifest:https://uipkge.dev/r/angular/ai-llm-chat.json