Menu

Change language

GitHub repo

Rag Pipeline Visualizer

blockai

LlamaIndex and LangChain style Retrieval-Augmented Generation (RAG) pipeline debugger and chunk retrieval inspector. Features 5-stage interactive DAG flow, HyDE expansion preview, hybrid dense/sparse search telemetry, cross-encoder reranking, and grounded response synthesis.

Installation

$npx uipkge-ng@latest add rag-pipeline-visualizer
Manifest:https://uipkge.dev/r/angular/rag-pipeline-visualizer.jsonInstalls to:src/app/components/blocks/rag-pipeline-visualizer/

Variants

Loading interactive previews…

Schema

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

RagPipelineStage
interface RagPipelineStage {
  id: number
  name: string
  subtitle: string
  latency: string
  durationMs: number
  status: 'completed' | 'active' | 'queued' | 'bypassed'
  icon: LucideIconData
  tag: string
  details: {
    description: string
    metrics: { label: string; value: string }[]
    codeOrData?: { title: string; content: string }
  }
}
RagRetrievedChunk
interface RagRetrievedChunk {
  id: string
  citationIndex: number
  relevanceScore: number
  rerankBoost: number
  cosineScore: number
  bm25Score: number
  documentPath: string
  namespace: string
  chunkOffset: string
  tokenCount: number
  updatedAgo: string
  title: string
  snippetPrefix: string
  highlightedText: string
  snippetSuffix: string
  fullSnippet: string
}

Files installed (2)

  • src/app/components/blocks/rag-pipeline-visualizer/rag-pipeline-visualizer.component.ts50.4 kB
    // Edit this file to change the pipeline stages, the sample chunks, the presets or the copy.
    // Everything is local sample data: wire `handleRunPipeline` to your own retrieval backend.
    import { Component, DestroyRef, Input, computed, inject, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      BookOpen,
      Bot,
      Check,
      ChevronRight,
      Code2,
      Copy,
      Cpu,
      Database,
      FileCode,
      Filter,
      Layers,
      LucideAngularModule,
      Play,
      RefreshCw,
      Search,
      ShieldCheck,
      SlidersHorizontal,
      Sparkles,
      Terminal,
      ThumbsDown,
      ThumbsUp,
      Workflow,
      Zap,
      type LucideIconData,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSliderComponent } from '@/ui/slider/slider.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    
    export interface RagPipelineStage {
      id: number
      name: string
      subtitle: string
      latency: string
      durationMs: number
      status: 'completed' | 'active' | 'queued' | 'bypassed'
      icon: LucideIconData
      tag: string
      details: {
        description: string
        metrics: { label: string; value: string }[]
        codeOrData?: { title: string; content: string }
      }
    }
    
    export interface RagRetrievedChunk {
      id: string
      citationIndex: number
      relevanceScore: number
      rerankBoost: number
      cosineScore: number
      bm25Score: number
      documentPath: string
      namespace: string
      chunkOffset: string
      tokenCount: number
      updatedAgo: string
      title: string
      snippetPrefix: string
      highlightedText: string
      snippetSuffix: string
      fullSnippet: string
    }
    
    const presetQueries = [
      'How do I configure OKLCH color palettes in Tailwind v4 with UIPKGE?',
      'Cross-Encoder vs Bi-Encoder reranker score calibration',
      'HyDE prompt expansion for zero-shot semantic retrieval',
      'Pinecone HNSW index tuning: efSearch & M parameter tradeoffs',
    ]
    
    const allChunks: RagRetrievedChunk[] = [
      {
        id: 'chunk-1',
        citationIndex: 1,
        relevanceScore: 0.942,
        rerankBoost: 0.142,
        cosineScore: 0.8,
        bm25Score: 0.785,
        documentPath: 'docs/styling/tailwind-v4-oklch.md',
        namespace: 'docs',
        chunkOffset: 'Chunk #3 · Lines 42–78',
        tokenCount: 384,
        updatedAgo: '2 hours ago',
        title: 'Tailwind v4 OKLCH Architecture & @theme Configuration',
        snippetPrefix: 'Tailwind CSS v4 introduces native CSS-first token configuration with ',
        highlightedText:
          '@theme inline and OKLCH color spaces. In contrast to RGB/HSL, OKLCH ensures perceptually uniform lightness across hues',
        snippetSuffix:
          ', preventing contrast degradation in dark mode variants while preserving single-source token truth.',
        fullSnippet:
          'Tailwind CSS v4 introduces native CSS-first token configuration with `@theme inline` and OKLCH color spaces. In contrast to RGB/HSL, OKLCH ensures perceptually uniform lightness across hues, preventing contrast degradation in dark mode variants while preserving single-source token truth.',
      },
      {
        id: 'chunk-2',
        citationIndex: 2,
        relevanceScore: 0.887,
        rerankBoost: 0.095,
        cosineScore: 0.792,
        bm25Score: 0.71,
        documentPath: 'docs/tokens/color-derivation.md',
        namespace: 'docs',
        chunkOffset: 'Chunk #1 · Lines 1–36',
        tokenCount: 412,
        updatedAgo: '1 day ago',
        title: 'Color Tokens Derivation & Contrast Calibration',
        snippetPrefix: 'Deriving consistent dark-mode contrasts requires ',
        highlightedText:
          'anchoring chroma and shifting lightness along the OKLCH L-axis. Use --color-primary: oklch(0.65 0.22 260) for vibrant interactive states',
        snippetSuffix:
          ' and calibrate border contrast with `--color-border: oklch(0.28 0.01 260)` for WCAG AA compliance across both frameworks.',
        fullSnippet:
          'Deriving consistent dark-mode contrasts requires anchoring chroma and shifting lightness along the OKLCH L-axis. Use `--color-primary: oklch(0.65 0.22 260)` for vibrant interactive states and calibrate border contrast with `--color-border: oklch(0.28 0.01 260)` for WCAG AA compliance across both frameworks.',
      },
      {
        id: 'chunk-3',
        citationIndex: 3,
        relevanceScore: 0.824,
        rerankBoost: 0.048,
        cosineScore: 0.776,
        bm25Score: 0.65,
        documentPath: 'blog/2026/design-systems-monorepo.md',
        namespace: 'blog',
        chunkOffset: 'Chunk #5 · Lines 112–164',
        tokenCount: 526,
        updatedAgo: '3 days ago',
        title: 'Dual-Framework Component Registry Architecture',
        snippetPrefix: 'When architecting a dual-framework registry (Vue + React), ',
        highlightedText:
          'shared design tokens must compile cleanly without runtime overhead. We leverage CSS custom properties to mirror CVA component variants',
        snippetSuffix: ' across both frameworks with zero extra runtime.',
        fullSnippet:
          'When architecting a dual-framework registry (Vue + React), shared design tokens must compile cleanly without runtime overhead. We leverage modern CSS custom properties and PostCSS pipelines to mirror CVA component variants across both frameworks with zero extra runtime.',
      },
    ]
    
    const COPY_ANSWER_TEXT =
      'To configure OKLCH color palettes in Tailwind CSS v4 with UIPKGE: 1. Define Theme Tokens in CSS (@theme inline)... 2. Calibrate Dark Mode Contrasts... 3. Maintain Dual-Framework CVA Parity...'
    
    const CITATION_BUTTON_CLASS =
      'bg-primary/10 py-0.2 text-primary hover:bg-primary hover:text-primary-foreground mx-1 inline-flex min-h-6 cursor-pointer items-center rounded px-1 font-mono text-xs font-bold transition-colors'
    
    /**
     * RagPipelineVisualizer -- the React block, 1:1: a RAG pipeline debugger with a header
     * (trace export + test run), a query bar with presets, Top-K slider and reranker switch,
     * a clickable 5-stage execution graph with a stage inspector, the retrieved chunks with
     * highlighted spans and scores, and the grounded answer whose citations light up their chunk.
     *
     * Icons: lucide-angular renders the <svg> inside a <lucide-icon> host. `class="..."` lands on
     * both the host and the svg, so icons that carry a margin, transform or animation use
     * `[class]` (svg only) to avoid applying it twice.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-rag-pipeline-visualizer, [ui-rag-pipeline-visualizer]',
      standalone: true,
      // React renders the root <div> itself: keep this wrapper out of the layout.
      host: { class: 'contents' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiCardDescriptionComponent,
        UiCardContentComponent,
        UiInputComponent,
        UiProgressComponent,
        UiSeparatorComponent,
        UiSliderComponent,
        UiSwitchComponent,
      ],
      template: `
        <div [class]="rootClass">
          <!-- 1. Pipeline Header -->
          <ui-card class="border-border bg-card shadow-xs">
            <ui-card-header class="pb-4">
              <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
                <div class="space-y-1.5">
                  <div class="flex flex-wrap items-center gap-2">
                    <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                      <lucide-icon [img]="Workflow" class="size-4.5" />
                    </div>
                    <h2 class="text-foreground text-lg font-semibold tracking-tight sm:text-xl">
                      enterprise_knowledge_rag_v3
                    </h2>
                    <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success">
                      <span class="bg-success mr-1.5 size-1.5 rounded-full motion-safe:animate-pulse"></span>
                      Pipeline Active
                    </span>
                    <span ui-badge variant="secondary" class="font-mono text-xs">p99 {{ totalPipelineLatency() }}</span>
                  </div>
                  <p class="text-muted-foreground text-sm">
                    Production Retrieval-Augmented Generation pipeline with HyDE query rewriting, hybrid reciprocal rank
                    fusion, and cross-encoder validation.
                  </p>
                </div>
    
                <div class="flex flex-wrap items-center gap-2">
                  <!-- px-2.5 / px-1.5 on icon buttons: React's size gets it from has-[>svg], but here the svg sits inside <lucide-icon>. -->
                  <button
                    ui-button
                    variant="outline"
                    size="sm"
                    class="gap-1.5 px-2.5 shadow-xs"
                    (click)="showTraceJson.set(!showTraceJson())"
                  >
                    <lucide-icon [img]="FileCode" class="size-4" />
                    <span>{{ showTraceJson() ? 'Hide Trace' : 'Export Trace JSON' }}</span>
                  </button>
                  <button
                    ui-button
                    size="sm"
                    class="gap-1.5 px-2.5 shadow-xs"
                    [disabled]="isExecuting()"
                    (click)="handleRunPipeline()"
                  >
                    @if (isExecuting()) {
                      <lucide-icon [img]="RefreshCw" [class]="'size-4 motion-safe:animate-spin'" />
                    } @else {
                      <lucide-icon [img]="Play" class="size-4 fill-current" />
                    }
                    <span>{{ isExecuting() ? 'Executing Pipeline...' : 'Test Query Pipeline' }}</span>
                  </button>
                </div>
              </div>
    
              <!-- Pipeline Metadata Specs Bar -->
              <div class="border-border mt-4 grid grid-cols-2 gap-2 border-t pt-4 text-xs sm:grid-cols-4">
                <div class="text-muted-foreground flex items-center gap-2">
                  <lucide-icon [img]="Cpu" class="text-primary size-3.5 shrink-0" />
                  <span class="truncate">
                    <strong class="text-foreground font-medium">Embedding:</strong> OpenAI text-embedding-3-small · 1536-dim
                  </span>
                </div>
                <div class="text-muted-foreground flex items-center gap-2">
                  <lucide-icon [img]="Database" class="text-primary size-3.5 shrink-0" />
                  <span class="truncate">
                    <strong class="text-foreground font-medium">Vector DB:</strong> Pinecone Serverless · HNSW
                  </span>
                </div>
                <div class="text-muted-foreground flex items-center gap-2">
                  <lucide-icon [img]="Layers" class="text-primary size-3.5 shrink-0" />
                  <span class="truncate">
                    <strong class="text-foreground font-medium">Index Space:</strong> 482,910 chunks · 4 namespaces
                  </span>
                </div>
                <div class="text-muted-foreground flex items-center gap-2">
                  <lucide-icon [img]="ShieldCheck" class="text-success size-3.5 shrink-0" />
                  <span class="truncate">
                    <strong class="text-foreground font-medium">Grounding:</strong> 100% strict context citation
                  </span>
                </div>
              </div>
            </ui-card-header>
          </ui-card>
    
          <!-- Optional Trace JSON Drawer -->
          @if (showTraceJson()) {
            <ui-card class="border-border bg-card/95 shadow-xs">
              <ui-card-header class="pb-2">
                <div class="flex items-center justify-between">
                  <div class="flex items-center gap-2">
                    <lucide-icon [img]="Terminal" class="text-primary size-4" />
                    <ui-card-title class="text-sm font-medium">OpenTelemetry Pipeline Execution Trace</ui-card-title>
                  </div>
                  <button ui-button variant="ghost" size="xs" class="gap-1 px-1.5" (click)="handleCopyTrace()">
                    @if (copiedTrace()) {
                      <lucide-icon [img]="Check" class="text-success size-3.5" />
                    } @else {
                      <lucide-icon [img]="Copy" class="size-3.5" />
                    }
                    <span>{{ copiedTrace() ? 'Copied JSON' : 'Copy Trace' }}</span>
                  </button>
                </div>
              </ui-card-header>
              <ui-card-content>
                <pre class="bg-muted/70 text-foreground max-h-64 overflow-x-auto rounded-md p-3 font-mono text-xs">{{
                  traceJson()
                }}</pre>
              </ui-card-content>
            </ui-card>
          }
    
          <!-- 2. Query Input & Runtime Controls Bar -->
          <ui-card class="border-border bg-card shadow-xs">
            <ui-card-content class="space-y-4 p-4 sm:p-6">
              <div class="space-y-2">
                <div class="flex items-center justify-between">
                  <label for="rag-query-input" class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                    Interactive Test Query
                  </label>
                  <span class="text-muted-foreground text-xs">Press Enter or click Test Query to simulate</span>
                </div>
    
                <div class="relative flex items-center">
                  <ui-input
                    id="rag-query-input"
                    [value]="currentQuery()"
                    (valueChange)="currentQuery.set($event)"
                    (keydown.enter)="handleRunPipeline()"
                    class="pr-20 text-sm font-medium shadow-xs"
                    placeholder="Enter search prompt or technical query..."
                    [prefixIcon]="searchIcon"
                  />
                  <ng-template #searchIcon>
                    <lucide-icon [img]="Search" class="text-muted-foreground size-4" />
                  </ng-template>
                  <button
                    ui-button
                    size="xs"
                    class="absolute right-1.5 gap-1 px-1.5"
                    [disabled]="isExecuting()"
                    (click)="handleRunPipeline()"
                  >
                    <lucide-icon [img]="Zap" class="size-3" />
                    <span>Run</span>
                  </button>
                </div>
              </div>
    
              <!-- Query Presets Chips -->
              <div class="flex flex-wrap items-center gap-1.5">
                <span class="text-muted-foreground text-xs font-medium">Presets:</span>
                @for (preset of presetQueries; track preset) {
                  <button type="button" [class]="presetClass(preset)" (click)="handleSelectPreset(preset)">
                    {{ preset }}
                  </button>
                }
              </div>
    
              <ui-separator />
    
              <!-- Controls Toolbar: Top-K Slider + Reranker Switch + Metrics -->
              <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
                <!-- Top-K Slider -->
                <div class="space-y-2">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-1.5">
                      <lucide-icon [img]="SlidersHorizontal" class="text-primary size-3.5" />
                      <span class="text-foreground text-xs font-medium">Top-K Chunks</span>
                    </div>
                    <span ui-badge variant="secondary" class="font-mono text-xs">Top {{ topK() }} Chunks</span>
                  </div>
                  <ui-slider
                    [value]="[topK()]"
                    [min]="1"
                    [max]="3"
                    [step]="1"
                    [tooltip]="false"
                    class="w-full"
                    (valueChange)="onTopKChange($event)"
                  />
                  <div class="text-muted-foreground flex justify-between font-mono text-xs">
                    <span>k=1</span>
                    <span>k=2</span>
                    <span>k=3</span>
                  </div>
                </div>
    
                <!-- Reranker Toggle -->
                <div class="border-border/80 bg-muted/20 flex flex-col justify-between space-y-2 rounded-lg border p-3">
                  <div class="flex items-center justify-between">
                    <div class="space-y-0.5">
                      <div class="flex items-center gap-1.5">
                        <lucide-icon [img]="Filter" class="text-primary size-3.5" />
                        <span class="text-foreground text-xs font-semibold">Cohere Rerank v3</span>
                      </div>
                      <p class="text-muted-foreground text-xs">Cross-encoder contextual re-scoring</p>
                    </div>
                    <button ui-switch [checked]="rerankerEnabled()" (checkedChange)="rerankerEnabled.set($event)"></button>
                  </div>
                  <div class="flex items-center gap-2">
                    <span ui-badge [variant]="rerankerEnabled() ? 'outline' : 'secondary'" [class]="rerankerBadgeClass()">
                      {{ rerankerEnabled() ? 'Active (+0.142 boost)' : 'Bypassed (Cosine only)' }}
                    </span>
                  </div>
                </div>
    
                <!-- Pipeline Telemetry Overview -->
                <div
                  class="border-border/80 bg-muted/20 flex flex-col justify-between space-y-1.5 rounded-lg border p-3 sm:col-span-2 lg:col-span-1"
                >
                  <div class="flex items-center justify-between">
                    <span class="text-foreground text-xs font-semibold">Execution Latency</span>
                    <span class="text-primary font-mono text-xs font-medium">{{ totalPipelineLatency() }} total</span>
                  </div>
                  <div class="space-y-1">
                    <div class="text-muted-foreground flex justify-between text-xs">
                      <span>Dense + BM25</span>
                      <span class="font-mono">106ms</span>
                    </div>
                    <ui-progress [value]="15" class="h-1.5" />
                  </div>
                  <div class="space-y-1">
                    <div class="text-muted-foreground flex justify-between text-xs">
                      <span>LLM Synthesis</span>
                      <span class="font-mono">820ms (85%)</span>
                    </div>
                    <ui-progress [value]="85" class="h-1.5" />
                  </div>
                </div>
              </div>
            </ui-card-content>
          </ui-card>
    
          <!-- 3. Interactive RAG 5-Stage Architecture Flow -->
          <div class="space-y-3">
            <div class="flex items-center justify-between">
              <div>
                <h3 class="text-foreground text-xs font-semibold tracking-wider uppercase">RAG Pipeline Execution Graph</h3>
                <p class="text-muted-foreground text-xs">
                  Click any stage node to inspect low-level telemetry, prompt variables, and intermediate embeddings.
                </p>
              </div>
              <span ui-badge variant="outline" class="font-mono text-xs">5 Connected Stages</span>
            </div>
    
            <!-- 5-Stage Connected Cards -->
            <div class="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-5">
              @for (stage of stages(); track stage.id) {
                <div
                  [class]="stageCardClass(stage)"
                  tabindex="0"
                  role="button"
                  [attr.aria-pressed]="selectedStageId() === stage.id"
                  (click)="selectedStageId.set(stage.id)"
                  (keydown)="onStageKeydown($event, stage)"
                >
                  <div class="space-y-2">
                    <div class="flex items-center justify-between">
                      <div [class]="stageIconClass(stage)">
                        <lucide-icon [img]="stage.icon" class="size-3.5" />
                      </div>
                      <span ui-badge variant="secondary" [class]="stageLatencyClass(stage)">{{ stage.latency }}</span>
                    </div>
    
                    <div>
                      <p class="text-foreground text-xs leading-tight font-semibold">{{ stage.name }}</p>
                      <p class="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">{{ stage.subtitle }}</p>
                    </div>
                  </div>
    
                  <div
                    class="border-border/60 text-muted-foreground mt-3 flex items-center justify-between border-t pt-2 text-xs"
                  >
                    <span class="truncate font-medium">{{ stage.tag }}</span>
                    <lucide-icon [img]="ChevronRight" [class]="stageChevronClass(stage)" />
                  </div>
                </div>
              }
            </div>
    
            <!-- Stage Detail Drill-down Panel -->
            <ui-card class="border-border bg-card/60 shadow-xs">
              <ui-card-header class="pb-3">
                <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                  <div class="flex items-center gap-2">
                    <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                      <lucide-icon [img]="currentStage().icon" class="size-4" />
                    </div>
                    <div>
                      <ui-card-title class="text-foreground text-sm font-semibold">
                        {{ currentStage().name }} — Inspector
                      </ui-card-title>
                      <ui-card-description class="text-xs">{{ currentStage().details.description }}</ui-card-description>
                    </div>
                  </div>
                  <span ui-badge variant="outline" class="w-fit font-mono text-xs">
                    Stage {{ currentStage().id }} of 5 · {{ currentStage().latency }}
                  </span>
                </div>
              </ui-card-header>
              <ui-card-content class="space-y-4 pt-0">
                <!-- Metrics Grid for Stage -->
                <div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
                  @for (metric of currentStage().details.metrics; track metric.label) {
                    <div class="border-border/80 bg-muted/40 rounded-md border p-2.5">
                      <p class="text-muted-foreground text-xs">{{ metric.label }}</p>
                      <p class="text-foreground mt-0.5 truncate font-mono text-xs font-semibold">{{ metric.value }}</p>
                    </div>
                  }
                </div>
    
                <!-- Code or Data Artifact Preview -->
                @if (currentStage().details.codeOrData; as codeOrData) {
                  <div class="space-y-1.5">
                    <div class="text-muted-foreground flex items-center justify-between text-xs font-medium">
                      <span class="flex items-center gap-1.5">
                        <lucide-icon [img]="Code2" class="text-primary size-3.5" />
                        {{ codeOrData.title }}
                      </span>
                      <span class="font-mono text-xs">read-only buffer</span>
                    </div>
                    <pre
                      class="bg-muted/80 text-foreground max-h-40 overflow-x-auto rounded-md p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap"
                      >{{ codeOrData.content }}</pre>
                  </div>
                }
              </ui-card-content>
            </ui-card>
          </div>
    
          <!-- 4. Retrieved Context Chunks Inspector -->
          <div class="space-y-3">
            <div class="flex items-center justify-between">
              <div>
                <h3 class="text-foreground text-xs font-semibold tracking-wider uppercase">
                  Retrieved Context Chunks ({{ displayedChunks().length }} Active Chunks)
                </h3>
                <p class="text-muted-foreground text-xs">
                  Ranked by cross-encoder relevance score. Keyword spans matching user intent are highlighted.
                </p>
              </div>
              <div class="flex items-center gap-2">
                <span ui-badge variant="secondary" class="font-mono text-xs">Fusion: Dense (70%) + Sparse (30%)</span>
              </div>
            </div>
    
            <div class="space-y-3">
              @for (chunk of displayedChunks(); track chunk.id) {
                <ui-card [class]="chunkCardClass(chunk)">
                  <ui-card-header class="p-4 pb-2">
                    <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                      <div class="flex flex-wrap items-center gap-2">
                        <!-- Citation Badge Pill -->
                        <span [class]="citationPillClass(chunk)">[{{ chunk.citationIndex }}]</span>
    
                        <!-- Relevancy Score Badge -->
                        <span ui-badge variant="outline" [class]="scoreBadgeClass(chunk)">
                          Score: {{ chunkScore(chunk) }}
                        </span>
    
                        <!-- Rerank Delta Badge -->
                        @if (rerankerEnabled()) {
                          <span ui-badge variant="secondary" class="text-muted-foreground font-mono text-xs">
                            +{{ chunk.rerankBoost.toFixed(3) }} boost (Rank #{{ chunk.citationIndex }})
                          </span>
                        }
    
                        <!-- Document Path -->
                        <span class="text-foreground font-mono text-xs font-medium">{{ chunk.documentPath }}</span>
                      </div>
    
                      <!-- Metadata & Copy Action -->
                      <div class="flex items-center gap-2">
                        <span class="text-muted-foreground hidden font-mono text-xs sm:inline">
                          {{ chunk.chunkOffset }} · {{ chunk.tokenCount }} tok
                        </span>
                        <button
                          ui-button
                          variant="ghost"
                          size="xs"
                          class="text-muted-foreground hover:text-foreground gap-1 px-1.5"
                          (click)="handleCopyChunk(chunk.id, chunk.fullSnippet)"
                        >
                          @if (copiedChunkId() === chunk.id) {
                            <lucide-icon [img]="Check" class="text-success size-3" />
                          } @else {
                            <lucide-icon [img]="Copy" class="size-3" />
                          }
                          <span class="text-xs">{{ copiedChunkId() === chunk.id ? 'Copied' : 'Copy' }}</span>
                        </button>
                      </div>
                    </div>
                  </ui-card-header>
    
                  <ui-card-content class="p-4 pt-2">
                    <!-- Chunk Text with Highlighted Spans -->
                    <div
                      class="border-border/70 bg-muted/40 text-foreground rounded-md border p-3 font-mono text-xs leading-relaxed"
                    >
                      <span>{{ chunk.snippetPrefix }}</span>
                      <mark class="bg-primary/20 text-primary dark:bg-primary/30 rounded px-1 py-0.5 font-semibold">{{
                        chunk.highlightedText
                      }}</mark>
                      <span>{{ chunk.snippetSuffix }}</span>
                    </div>
    
                    <!-- Scoring Breakdown Sub-bar -->
                    <div class="text-muted-foreground mt-2.5 flex flex-wrap items-center justify-between gap-2 text-xs">
                      <div class="flex flex-wrap items-center gap-3 font-mono">
                        <span>
                          Dense Cosine:
                          <strong class="text-foreground font-semibold">{{ chunk.cosineScore.toFixed(3) }}</strong>
                        </span>
                        <span>
                          Sparse BM25:
                          <strong class="text-foreground font-semibold">{{ chunk.bm25Score.toFixed(3) }}</strong>
                        </span>
                        @if (rerankerEnabled()) {
                          <span>
                            Cross-Encoder:
                            <strong class="text-success font-semibold">{{ chunk.relevanceScore.toFixed(3) }}</strong>
                          </span>
                        }
                      </div>
                      <span class="text-muted-foreground text-xs">Updated {{ chunk.updatedAgo }}</span>
                    </div>
                  </ui-card-content>
                </ui-card>
              }
            </div>
          </div>
    
          <!-- 5. Final Synthesized Output Card with Citations -->
          <ui-card class="border-border bg-card shadow-xs">
            <ui-card-header class="pb-3">
              <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div class="flex items-center gap-2">
                  <div class="bg-primary text-primary-foreground flex size-7 items-center justify-center rounded-md">
                    <lucide-icon [img]="Sparkles" class="size-4" />
                  </div>
                  <div>
                    <ui-card-title class="text-foreground text-base font-semibold"
                      >Synthesized Grounded Response</ui-card-title
                    >
                    <ui-card-description class="text-xs">
                      Generated via Claude 3.5 Sonnet streaming with verified inline citations.
                    </ui-card-description>
                  </div>
                </div>
    
                <div class="flex flex-wrap items-center gap-2">
                  <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success font-mono text-xs">
                    <lucide-icon [img]="ShieldCheck" [class]="'mr-1 size-3'" />
                    100% Grounded · 0 Hallucinations
                  </span>
                  <span ui-badge variant="secondary" class="font-mono text-xs">820ms (TTFT 180ms)</span>
                </div>
              </div>
            </ui-card-header>
    
            <ui-card-content class="space-y-4">
              <!-- Markdown Formatted Synthesized Body -->
              <div
                class="border-border/80 bg-muted/20 text-foreground space-y-3 rounded-lg border p-4 text-sm leading-relaxed sm:p-5"
              >
                <p class="text-foreground font-medium">To configure OKLCH color palettes in Tailwind CSS v4 with UIPKGE:</p>
    
                <!-- Text runs touch the citation buttons with no whitespace, as in the React JSX, and each
                  button's label sits tight (>[N]</button) so its textContent is exactly '[N]'. -->
                <ol class="text-foreground/90 list-decimal space-y-2.5 pl-5 text-sm">
                  <li class="leading-relaxed">
                    <strong>Define Theme Tokens in CSS:</strong> Use
                    <code class="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono text-xs">&#64;theme inline</code>
                    in your root stylesheet to declare single-source-of-truth OKLCH variables<button
                      type="button"
                      [class]="citationButtonClass"
                      (mouseenter)="activeCitationHover.set(1)"
                      (mouseleave)="activeCitationHover.set(null)"
                      >[1]</button
                    >. Unlike legacy RGB/HSL, OKLCH ensures perceptually uniform lightness across hue shifts<button
                      type="button"
                      [class]="citationButtonClass"
                      (mouseenter)="activeCitationHover.set(1)"
                      (mouseleave)="activeCitationHover.set(null)"
                      >[1]</button
                    >.
                  </li>
    
                  <li class="leading-relaxed">
                    <strong>Calibrate Dark Mode Contrasts:</strong> Anchor chroma and shift lightness along the OKLCH L-axis
                    (e.g.,
                    <code class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs"
                      >--color-primary: oklch(0.65 0.22 260)</code
                    >
                    and
                    <code class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs"
                      >--color-border: oklch(0.28 0.01 260)</code
                    >) to maintain WCAG AA compliance across both light and dark modes<button
                      type="button"
                      [class]="citationButtonClass"
                      (mouseenter)="activeCitationHover.set(2)"
                      (mouseleave)="activeCitationHover.set(null)"
                      >[2]</button
                    >.
                  </li>
    
                  <li class="leading-relaxed">
                    <strong>Maintain Dual-Framework CVA Parity:</strong> Wire CSS tokens directly through Class Variance
                    Authority (<code class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">cva()</code>) variants so that
                    both Vue and React registry components share identical token namespaces with zero runtime bundle
                    overhead<button
                      type="button"
                      [class]="citationButtonClass"
                      (mouseenter)="activeCitationHover.set(3)"
                      (mouseleave)="activeCitationHover.set(null)"
                      >[3]</button
                    >.
                  </li>
                </ol>
    
                <!-- Streaming cursor pulse -->
                <div class="text-muted-foreground flex items-center gap-1.5 pt-1 font-mono text-xs">
                  <span class="bg-success size-2 rounded-full motion-safe:animate-ping"></span>
                  <span>Generation Complete · 214 tokens streamed</span>
                </div>
              </div>
    
              <!-- Citation Footnotes Bar -->
              <div class="border-border/70 bg-muted/40 space-y-1.5 rounded-md border p-3 text-xs">
                <span class="text-foreground flex items-center gap-1.5 font-semibold">
                  <lucide-icon [img]="BookOpen" class="text-primary size-3.5" />
                  Referenced Citations (Hover citation to locate chunk):
                </span>
                <div class="grid grid-cols-1 gap-1.5 sm:grid-cols-3">
                  @for (chunk of displayedChunks(); track chunk.id) {
                    <div
                      [class]="footnoteClass(chunk)"
                      (mouseenter)="activeCitationHover.set(chunk.citationIndex)"
                      (mouseleave)="activeCitationHover.set(null)"
                    >
                      <span class="truncate">[{{ chunk.citationIndex }}] {{ fileName(chunk) }}</span>
                      <span class="font-semibold">{{ chunk.relevanceScore.toFixed(3) }}</span>
                    </div>
                  }
                </div>
              </div>
    
              <!-- Telemetry Footer & Action Buttons -->
              <div
                class="border-border text-muted-foreground flex flex-col gap-3 border-t pt-2 text-xs sm:flex-row sm:items-center sm:justify-between"
              >
                <div class="flex flex-wrap items-center gap-3 font-mono">
                  <span>Input: <strong class="text-foreground">1,248 tok</strong></span>
                  <span>Output: <strong class="text-foreground">214 tok</strong></span>
                  <span>Cost: <strong class="text-foreground">$0.00438</strong></span>
                  <span>Grounding: <strong class="text-success">99.4%</strong></span>
                </div>
    
                <div class="flex items-center gap-2">
                  <div class="border-border bg-card flex items-center rounded-md border p-0.5">
                    <button
                      ui-button
                      aria-label="Thumbs up"
                      variant="ghost"
                      size="xs"
                      [class]="feedbackClass('up')"
                      (click)="toggleFeedback('up')"
                    >
                      <lucide-icon [img]="ThumbsUp" class="size-3.5" />
                    </button>
                    <ui-separator orientation="vertical" class="h-4" />
                    <button
                      ui-button
                      aria-label="Thumbs down"
                      variant="ghost"
                      size="xs"
                      [class]="feedbackClass('down')"
                      (click)="toggleFeedback('down')"
                    >
                      <lucide-icon [img]="ThumbsDown" class="size-3.5" />
                    </button>
                  </div>
    
                  <button ui-button variant="outline" size="xs" class="gap-1 px-1.5" (click)="handleRunPipeline()">
                    <lucide-icon [img]="RefreshCw" class="size-3" />
                    <span>Regenerate</span>
                  </button>
    
                  <button ui-button size="xs" class="gap-1 px-1.5" (click)="handleCopyResponse(copyAnswerText)">
                    @if (copiedResponse()) {
                      <lucide-icon [img]="Check" class="size-3" />
                    } @else {
                      <lucide-icon [img]="Copy" class="size-3" />
                    }
                    <span>{{ copiedResponse() ? 'Copied' : 'Copy Answer' }}</span>
                  </button>
                </div>
              </div>
            </ui-card-content>
          </ui-card>
        </div>
      `,
    })
    export class UiRagPipelineVisualizerComponent {
      @Input('class') className?: string
    
      protected readonly Workflow = Workflow
      protected readonly FileCode = FileCode
      protected readonly RefreshCw = RefreshCw
      protected readonly Play = Play
      protected readonly Cpu = Cpu
      protected readonly Database = Database
      protected readonly Layers = Layers
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Terminal = Terminal
      protected readonly Check = Check
      protected readonly Copy = Copy
      protected readonly Search = Search
      protected readonly Zap = Zap
      protected readonly SlidersHorizontal = SlidersHorizontal
      protected readonly Filter = Filter
      protected readonly ChevronRight = ChevronRight
      protected readonly Code2 = Code2
      protected readonly Sparkles = Sparkles
      protected readonly BookOpen = BookOpen
      protected readonly ThumbsUp = ThumbsUp
      protected readonly ThumbsDown = ThumbsDown
    
      protected readonly presetQueries = presetQueries
      protected readonly copyAnswerText = COPY_ANSWER_TEXT
      protected readonly citationButtonClass = CITATION_BUTTON_CLASS
    
      readonly currentQuery = signal('How do I configure OKLCH color palettes in Tailwind v4 with UIPKGE?')
      readonly topK = signal(3)
      readonly rerankerEnabled = signal(true)
      readonly selectedStageId = signal(3)
      readonly isExecuting = signal(false)
      readonly copiedChunkId = signal<string | null>(null)
      readonly copiedResponse = signal(false)
      readonly copiedTrace = signal(false)
      readonly activeCitationHover = signal<number | null>(null)
      readonly showTraceJson = signal(false)
      readonly userFeedback = signal<'up' | 'down' | null>(null)
    
      private readonly timers = new Set<ReturnType<typeof setTimeout>>()
    
      constructor() {
        inject(DestroyRef).onDestroy(() => this.timers.forEach(clearTimeout))
      }
    
      readonly stages = computed<RagPipelineStage[]>(() => {
        const rerankerEnabled = this.rerankerEnabled()
        const topK = this.topK()
        return [
          {
            id: 1,
            name: '1. User Query & HyDE Expansion',
            subtitle: 'Query rewritten with hypothetical document embedding · 42ms',
            latency: '42ms',
            durationMs: 42,
            status: 'completed',
            icon: Sparkles,
            tag: 'Prompt Rewriting',
            details: {
              description:
                'Generates a synthetic hypothetical answer using Claude 3.5 Haiku to bridge vocabulary mismatch between conversational queries and technical documentation.',
              metrics: [
                { label: 'Expansion Model', value: 'Claude 3.5 Haiku' },
                { label: 'Latency', value: '42ms' },
                { label: 'Prompt Tokens', value: '48 tokens' },
                { label: 'Hypothesis Length', value: '112 tokens' },
              ],
              codeOrData: {
                title: 'Generated Hypothetical Document (HyDE)',
                content:
                  'Tailwind CSS v4 defines color tokens using CSS variables inside `@theme inline` with `oklch(L C H)` functions. In dual-framework component registries like UIPKGE, OKLCH ensures uniform perceptual lightness across hue shifts in light and dark variants without breaking WCAG AA contrast.',
              },
            },
          },
          {
            id: 2,
            name: '2. Hybrid Dense + Sparse Search',
            subtitle: 'Dense Cosine + BM25 Lexical search across 480k chunks · 64ms',
            latency: '64ms',
            durationMs: 64,
            status: 'completed',
            icon: Database,
            tag: 'Reciprocal Rank Fusion',
            details: {
              description:
                'Performs vector similarity search on 1536-dim embeddings combined with sparse BM25 keyword matching across 482,910 document chunks.',
              metrics: [
                { label: 'Vector Index', value: 'Pinecone Serverless (HNSW)' },
                { label: 'Dimension', value: '1536-dim (text-embedding-3-small)' },
                { label: 'Fusion Weight', value: 'α = 0.70 Dense + 0.30 Sparse' },
                { label: 'Candidate Pool', value: '20 Candidate Chunks' },
              ],
              codeOrData: {
                title: 'Dense Vector Projection (32-float slice)',
                content:
                  '[-0.048, 0.135, 0.092, -0.018, 0.245, -0.110, 0.049, 0.174, -0.082, 0.001, 0.325, -0.059, 0.097, 0.191, -0.149, 0.068, -0.024, 0.212, 0.082, -0.094, 0.121, -0.037, 0.156, 0.031, -0.172, 0.099, 0.056, -0.078, 0.195, -0.013, 0.072, 0.141]',
              },
            },
          },
          {
            id: 3,
            name: '3. Cross-Encoder Reranker',
            subtitle: rerankerEnabled
              ? 'Cohere Rerank v3: re-scores Top 20 -> Top 3 · 28ms'
              : 'Bypassed (Using raw hybrid search ranking) · 0ms',
            latency: rerankerEnabled ? '28ms' : '0ms',
            durationMs: rerankerEnabled ? 28 : 0,
            status: rerankerEnabled ? 'completed' : 'bypassed',
            icon: Filter,
            tag: rerankerEnabled ? 'Cross-Attention Re-Scoring' : 'Bypassed',
            details: {
              description: rerankerEnabled
                ? 'Applies deep transformer cross-attention to score query-document pairs simultaneously, eliminating false-positive semantic matches.'
                : 'Reranker is currently bypassed. Chunks are ordered purely by initial hybrid reciprocal rank scores.',
              metrics: [
                { label: 'Model', value: 'cohere-rerank-v3.0-multilingual' },
                { label: 'Top-K Retained', value: `Top ${topK} of 20` },
                { label: 'Relevance Cutoff', value: '≥ 0.800 score' },
                { label: 'Max Rank Shift', value: '+3 positions (Chunk #3)' },
              ],
              codeOrData: {
                title: 'Reranker Score Calibration Delta',
                content:
                  'Chunk #1: 0.800 (Cosine) -> 0.942 (Rerank)  [+0.142 boost]\nChunk #2: 0.792 (Cosine) -> 0.887 (Rerank)  [+0.095 boost]\nChunk #3: 0.776 (Cosine) -> 0.824 (Rerank)  [+0.048 boost]\n17 candidates pruned below 0.800 relevance threshold',
              },
            },
          },
          {
            id: 4,
            name: '4. Context Window Assembly',
            subtitle: '1,248 tokens packed, 98% prompt density · 8ms',
            latency: '8ms',
            durationMs: 8,
            status: 'completed',
            icon: Layers,
            tag: 'Token Packaging',
            details: {
              description:
                'Deduplicates overlapping chunk boundaries, injects citation boundary anchors `[1]`, `[2]`, `[3]`, and formats system directives for zero-hallucination grounding.',
              metrics: [
                { label: 'Window Budget', value: '1,248 / 8,192 tokens (15.2%)' },
                { label: 'Prompt Density', value: '98.4% relevance tokens' },
                { label: 'Chunks Packaged', value: `${Math.min(topK, 3)} chunks` },
                { label: 'Delimiter Format', value: '<context_document_id>' },
              ],
              codeOrData: {
                title: 'Assembled Prompt Context Envelope',
                content:
                  '<system>\nYou are a Lead Design Systems Architect. Synthesize answers strictly from provided context chunks.\nAlways ground every technical claim with an inline citation key [1], [2], or [3].\n</system>\n\n<context>\n[1] doc: docs/styling/tailwind-v4-oklch.md (score: 0.942)\n[2] doc: docs/tokens/color-derivation.md (score: 0.887)\n[3] doc: blog/2026/design-systems-monorepo.md (score: 0.824)\n</context>',
              },
            },
          },
          {
            id: 5,
            name: '5. LLM Synthesis & Grounding',
            subtitle: 'Claude 3.5 Sonnet streaming generation · 820ms',
            latency: '820ms',
            durationMs: 820,
            status: 'completed',
            icon: Bot,
            tag: 'Streaming Synthesis',
            details: {
              description:
                'Generates final structured markdown answer with streaming tokens, verifies every claim against citation sources, and produces grounded output.',
              metrics: [
                { label: 'Model', value: 'claude-3-5-sonnet-20241022' },
                { label: 'Time to First Token', value: '180ms' },
                { label: 'Generation Speed', value: '68.2 tokens/sec' },
                { label: 'Grounding Verification', value: '100% (3/3 facts grounded)' },
              ],
              codeOrData: {
                title: 'Telemetry & Cost Summary',
                content:
                  'Prompt Tokens: 1,248 ($0.00374)\nCompletion Tokens: 214 ($0.00064)\nTotal Cost: $0.00438\nHallucination Detection: 0 violations detected',
              },
            },
          },
        ]
      })
    
      readonly displayedChunks = computed(() => {
        const count = Math.min(Math.max(1, this.topK()), allChunks.length)
        return allChunks.slice(0, count)
      })
    
      readonly currentStage = computed(() => {
        const stages = this.stages()
        return stages.find((s) => s.id === this.selectedStageId()) ?? stages[0]!
      })
    
      readonly totalPipelineLatency = computed(() => `${this.stages().reduce((acc, s) => acc + s.durationMs, 0)}ms`)
    
      readonly traceJson = computed(() => {
        const rerankerEnabled = this.rerankerEnabled()
        return JSON.stringify(
          {
            traceId: 'trc_rag_948201a4',
            pipeline: 'enterprise_knowledge_rag_v3',
            timestamp: new Date().toISOString(),
            query: this.currentQuery(),
            config: {
              embeddingModel: 'text-embedding-3-small',
              dimensions: 1536,
              vectorDb: 'Pinecone Serverless',
              topK: this.topK(),
              reranker: rerankerEnabled ? 'cohere-rerank-v3' : 'none',
              alphaFusion: 0.7,
            },
            stages: this.stages().map((s) => ({
              id: s.id,
              name: s.name,
              durationMs: s.durationMs,
              status: s.status,
            })),
            retrievedChunks: this.displayedChunks().map((c) => ({
              citation: c.citationIndex,
              docPath: c.documentPath,
              relevance: rerankerEnabled ? c.relevanceScore : c.cosineScore,
              rerankBoost: rerankerEnabled ? c.rerankBoost : 0,
              tokenCount: c.tokenCount,
            })),
            synthesis: {
              model: 'claude-3-5-sonnet-20241022',
              promptTokens: 1248,
              completionTokens: 214,
              groundedFactRatio: 1.0,
              latencyMs: 820,
            },
          },
          null,
          2,
        )
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6 font-sans', this.className)
      }
    
      private later(fn: () => void, ms: number): void {
        const id = setTimeout(() => {
          this.timers.delete(id)
          fn()
        }, ms)
        this.timers.add(id)
      }
    
      handleRunPipeline(): void {
        if (this.isExecuting()) return
        this.isExecuting.set(true)
        this.selectedStageId.set(1)
        this.later(() => this.selectedStageId.set(2), 200)
        this.later(() => this.selectedStageId.set(3), 450)
        this.later(() => this.selectedStageId.set(4), 700)
        this.later(() => {
          this.selectedStageId.set(5)
          this.isExecuting.set(false)
        }, 950)
      }
    
      handleSelectPreset(preset: string): void {
        this.currentQuery.set(preset)
        this.handleRunPipeline()
      }
    
      handleCopyChunk(id: string, text: string): void {
        void navigator.clipboard?.writeText(text)
        this.copiedChunkId.set(id)
        this.later(() => this.copiedChunkId.set(null), 2000)
      }
    
      handleCopyResponse(text: string): void {
        void navigator.clipboard?.writeText(text)
        this.copiedResponse.set(true)
        this.later(() => this.copiedResponse.set(false), 2000)
      }
    
      handleCopyTrace(): void {
        void navigator.clipboard?.writeText(this.traceJson())
        this.copiedTrace.set(true)
        this.later(() => this.copiedTrace.set(false), 2000)
      }
    
      onTopKChange(value: number[]): void {
        this.topK.set(value[0] ?? this.topK())
      }
    
      onStageKeydown(event: KeyboardEvent, stage: RagPipelineStage): void {
        if (event.key === 'Enter' || event.key === ' ') {
          event.preventDefault()
          this.selectedStageId.set(stage.id)
        }
      }
    
      toggleFeedback(value: 'up' | 'down'): void {
        this.userFeedback.set(this.userFeedback() === value ? null : value)
      }
    
      chunkScore(chunk: RagRetrievedChunk): string {
        return this.rerankerEnabled() ? chunk.relevanceScore.toFixed(3) : chunk.cosineScore.toFixed(3)
      }
    
      fileName(chunk: RagRetrievedChunk): string {
        return chunk.documentPath.split('/').pop() ?? ''
      }
    
      presetClass(preset: string): string {
        return cn(
          'focus-visible:ring-ring min-h-6 cursor-pointer rounded-full border px-2.5 py-0.5 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-hidden',
          this.currentQuery() === preset
            ? 'border-primary bg-primary/10 text-primary font-medium'
            : 'border-border bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground',
        )
      }
    
      rerankerBadgeClass(): string {
        return cn(
          'font-mono text-xs',
          this.rerankerEnabled() ? 'border-success/30 bg-success/10 text-success' : 'text-muted-foreground',
        )
      }
    
      stageCardClass(stage: RagPipelineStage): string {
        return cn(
          'group focus-visible:ring-ring relative flex cursor-pointer flex-col justify-between rounded-lg border p-3.5 transition-colors focus-visible:ring-2 focus-visible:outline-hidden',
          this.selectedStageId() === stage.id
            ? 'border-primary bg-primary/5 ring-primary/40 shadow-xs ring-1'
            : 'border-border bg-card hover:border-primary/40 hover:bg-muted/30',
        )
      }
    
      stageIconClass(stage: RagPipelineStage): string {
        return cn(
          'flex size-7 items-center justify-center rounded-md text-xs font-semibold',
          this.selectedStageId() === stage.id
            ? 'bg-primary text-primary-foreground'
            : 'bg-muted text-foreground group-hover:bg-primary/20 group-hover:text-primary',
        )
      }
    
      stageLatencyClass(stage: RagPipelineStage): string {
        return cn('font-mono text-xs', stage.status === 'bypassed' && 'text-muted-foreground line-through opacity-70')
      }
    
      stageChevronClass(stage: RagPipelineStage): string {
        return cn(
          'size-3.5 transition-transform',
          this.selectedStageId() === stage.id ? 'text-primary translate-x-0.5' : 'text-muted-foreground',
        )
      }
    
      chunkCardClass(chunk: RagRetrievedChunk): string {
        return cn(
          'border-border bg-card shadow-xs transition-colors',
          this.activeCitationHover() === chunk.citationIndex && 'border-primary/80 ring-primary/20 bg-primary/5 ring-2',
        )
      }
    
      citationPillClass(chunk: RagRetrievedChunk): string {
        return cn(
          'flex size-6 items-center justify-center rounded-md font-mono text-xs font-bold transition-colors',
          this.activeCitationHover() === chunk.citationIndex
            ? 'bg-primary text-primary-foreground'
            : 'bg-muted text-foreground',
        )
      }
    
      scoreBadgeClass(chunk: RagRetrievedChunk): string {
        return cn(
          'font-mono text-xs font-semibold',
          chunk.relevanceScore >= 0.9
            ? 'border-success/30 bg-success/10 text-success'
            : chunk.relevanceScore >= 0.85
              ? 'border-success/30 bg-success/10 text-success'
              : 'border-info/30 bg-info/10 text-info',
        )
      }
    
      footnoteClass(chunk: RagRetrievedChunk): string {
        return cn(
          'flex cursor-pointer items-center justify-between rounded border p-1.5 font-mono text-xs transition-colors',
          this.activeCitationHover() === chunk.citationIndex
            ? 'border-primary bg-primary/10 text-primary'
            : 'border-border/60 bg-card text-muted-foreground hover:bg-muted',
        )
      }
    
      /** px-1.5 (not React's `px-2`): React's has-[>svg]:px-1.5 outranks it; the svg here sits in <lucide-icon>. */
      feedbackClass(value: 'up' | 'down'): string {
        return cn('px-1.5', this.userFeedback() === value && (value === 'up' ? 'text-success' : 'text-destructive'))
      }
    }
    
  • src/app/components/blocks/rag-pipeline-visualizer/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/rag-pipeline-visualizer.json