Menu

Change language

GitHub repo

Ai Llm Chat

blockai

ChatGPT/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

$npx uipkge-ng@latest add ai-llm-chat
Manifest:https://uipkge.dev/r/angular/ai-llm-chat.jsonInstalls to:src/app/components/blocks/ai-llm-chat/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

AiLlmChatTurn
interface AiLlmChatTurn {
  id: string
  role: AiLlmChatRole
  body: string
  code?: { lang: string; content: string }
  createdAt: Date
}
AiLlmChatThread
interface AiLlmChatThread {
  id: string
  title: string
  updatedAt: Date
  turns: AiLlmChatTurn[]
}
Model
interface Model {
  id: string
  label: string
  hint: string
}

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

Raw manifest:https://uipkge.dev/r/angular/ai-llm-chat.json