Menu

Change language

GitHub repo

Prompt Playground

blockai

AI model prompt engineering studio & parameter tuner with system instructions, user input, temperature, max tokens, top-p sliders, JSON/stream modes, and interactive response evaluation.

Installation

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

Variants

Loading interactive previews…

Schema

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

PromptPlaygroundModel
interface PromptPlaygroundModel {
  id: string
  name: string
  provider: string
  contextWindow: string
  inputCostPer1M: number
  outputCostPer1M: number
}
PromptPlaygroundPreset
interface PromptPlaygroundPreset {
  id: string
  name: string
  description: string
  model: string
  temperature: number
  maxTokens: number
  topP: number
  jsonMode: boolean
  stream: boolean
  systemPrompt: string
  userMessage: string
  mockResponse: string
  mockTokens: number
  mockLatency: string
}

Files installed (2)

  • src/app/components/blocks/prompt-playground/prompt-playground.component.ts34.3 kB
    // Edit this file to change the models, the presets (prompts + mock responses) or the copy.
    // Responses are mocked: "Run Prompt" (or Cmd/Ctrl+Enter) replays the preset's response,
    // streamed in 8-character chunks when "Stream Responses" is on.
    import { Component, DestroyRef, Input, computed, inject, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      Bot,
      Braces,
      Check,
      Clock,
      Copy,
      Cpu,
      FileCode,
      LucideAngularModule,
      MessageSquare,
      RotateCcw,
      Save,
      Sliders,
      Sparkles,
      Terminal,
      Zap,
    } 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,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSliderComponent } from '@/ui/slider/slider.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export interface PromptPlaygroundModel {
      id: string
      name: string
      provider: string
      contextWindow: string
      inputCostPer1M: number
      outputCostPer1M: number
    }
    
    export interface PromptPlaygroundPreset {
      id: string
      name: string
      description: string
      model: string
      temperature: number
      maxTokens: number
      topP: number
      jsonMode: boolean
      stream: boolean
      systemPrompt: string
      userMessage: string
      mockResponse: string
      mockTokens: number
      mockLatency: string
    }
    
    const models: PromptPlaygroundModel[] = [
      {
        id: 'claude-3-5-sonnet',
        name: 'Claude 3.5 Sonnet',
        provider: 'Anthropic',
        contextWindow: '200k',
        inputCostPer1M: 3.0,
        outputCostPer1M: 15.0,
      },
      {
        id: 'gpt-4o',
        name: 'GPT-4o',
        provider: 'OpenAI',
        contextWindow: '128k',
        inputCostPer1M: 2.5,
        outputCostPer1M: 10.0,
      },
      {
        id: 'llama-3-3-70b',
        name: 'Llama 3.3 70B',
        provider: 'Meta',
        contextWindow: '128k',
        inputCostPer1M: 0.6,
        outputCostPer1M: 0.8,
      },
    ]
    
    const presets: PromptPlaygroundPreset[] = [
      {
        id: 'code-reviewer',
        name: 'Code Reviewer',
        description: 'Security & vulnerability audit for backend code',
        model: 'claude-3-5-sonnet',
        temperature: 0.2,
        maxTokens: 2048,
        topP: 0.9,
        jsonMode: false,
        stream: true,
        systemPrompt:
          'You are a world-class senior developer assistant specializing in application security, code reviews, and high-performance software architecture. Analyze code with precision, highlight vulnerabilities (OWASP Top 10), and provide clean refactored snippets.',
        userMessage:
          'Review the following authentication middleware for security vulnerabilities and race conditions:\n\n```typescript\nexport async function authMiddleware(req: Request) {\n  const token = req.headers.get("authorization")?.split(" ")[1];\n  if (!token) throw new Error("Unauthorized");\n  const session = await db.session.findUnique({ where: { token } });\n  if (session.expiresAt < new Date()) {\n    await db.session.delete({ where: { token } });\n    throw new Error("Expired");\n  }\n  return session.user;\n}\n```',
        mockResponse:
          '### Security Review & Vulnerability Analysis\n\nI identified 3 key security & stability issues in the provided authentication middleware:\n\n1. **Unhandled Exceptions / Missing Error Envelope:**\n   Throwing raw `Error` instances inside middleware can cause unhandled 500 crashes instead of structured 401/403 HTTP responses.\n2. **Missing Token Existence Check on DB lookup:**\n   If `session` is not found, `session.expiresAt` throws a `TypeError` (cannot read properties of null).\n3. **Non-atomic Cleanup & Database Mutation:**\n   Deleting expired tokens in the read path introduces write amplification on every expired request and isn\'t atomic with the expiration verification.\n\n---\n\n### Recommended Refactoring\n\n```typescript\nimport { NextResponse } from "next/server";\n\nexport async function authMiddleware(req: Request) {\n  const authHeader = req.headers.get("authorization");\n  if (!authHeader?.startsWith("Bearer ")) {\n    return new Response(JSON.stringify({ error: "Missing or malformed Authorization header" }), {\n      status: 401,\n      headers: { "Content-Type": "application/json" },\n    });\n  }\n\n  const token = authHeader.slice(7).trim();\n  const session = await db.session.findUnique({\n    where: { token },\n    include: { user: true },\n  });\n\n  if (!session || session.expiresAt.getTime() <= Date.now()) {\n    return new Response(JSON.stringify({ error: "Invalid or expired session" }), {\n      status: 401,\n      headers: { "Content-Type": "application/json" },\n    });\n  }\n\n  return session.user;\n}\n```\n\n**Key Improvements:**\n- Constant-time prefix check avoiding unexpected array indexing errors.\n- Null-safe session expiration check without throwing uncaught exceptions.\n- Immediate 401 JSON error responses matching standard API conventions.',
        mockTokens: 428,
        mockLatency: '1.4s',
      },
      {
        id: 'customer-support',
        name: 'Customer Support Agent',
        description: 'Empathetic, brand-aligned troubleshooting for customer inquiries',
        model: 'gpt-4o',
        temperature: 0.7,
        maxTokens: 1024,
        topP: 0.95,
        jsonMode: false,
        stream: true,
        systemPrompt:
          'You are an empathetic, concise, and professional Tier-2 support specialist for CloudFlow SaaS. Your goal is to troubleshoot customer issues efficiently while maintaining a helpful, brand-aligned tone.',
        userMessage:
          'A customer wrote: "Our webhooks stopped delivering payload events since 14:00 UTC today. We are seeing intermittent 504 gateway timeout errors on endpoint https://api.client.com/events. Can you check our account ID #ACC-9821?"',
        mockResponse:
          'Hello Team,\n\nThank you for reaching out, and I apologize for the disruption to your event pipeline.\n\nI investigated account **#ACC-9821** and observed the following:\n\n- **Root Cause:** Our webhook dispatch worker encountered downstream 504 Gateway Timeouts from `https://api.client.com/events` when response times exceeded the 5,000ms threshold.\n- **Retry Mechanism:** Failed webhook events have been automatically enrolled into exponential backoff (attempt 3 of 5).\n- **Action Taken:** We temporarily increased the worker handshake timeout to 8,000ms for your tenant while your ingress recovers.\n\n**Recommended Next Steps:**\n1. Check your upstream server logs around 14:00 UTC for concurrent connection saturation or database connection pool starvation.\n2. If you need all 14:00–15:00 UTC events replayed immediately, reply with **"CONFIRM REPLAY #ACC-9821"** and I will trigger a zero-loss backfill.\n\nBest regards,\n**CloudFlow Tier-2 Engineering Support**',
        mockTokens: 286,
        mockLatency: '0.9s',
      },
      {
        id: 'json-extractor',
        name: 'JSON Extractor',
        description: 'Strict schema extraction from unstructured invoices or logs',
        model: 'gpt-4o',
        temperature: 0.0,
        maxTokens: 1024,
        topP: 0.1,
        jsonMode: true,
        stream: false,
        systemPrompt:
          'You are an infallible data extraction engine. Extract structured entities from unstructured receipts, invoices, or logs and return ONLY valid RFC-8259 JSON matching the requested schema. Do not include markdown formatting, explanations, or prologue.',
        userMessage:
          'Extract invoice metadata:\nInvoice Number: INV-2026-8891\nDate: August 21, 2026\nVendor: Acme Cloud Services LLC (Tax ID: US-99182312)\nClient: Cyberdyne Systems\nItems:\n1. GPU Cluster Instance H100 x 48 hrs @ $3.20/hr = $153.60\n2. NVMe High-Speed Storage 2TB x 1 mo @ $80.00/mo = $80.00\nTax: $18.69 (8%)\nTotal Due: $252.29',
        mockResponse:
          '{\n  "invoiceNumber": "INV-2026-8891",\n  "issueDate": "2026-08-21",\n  "vendor": {\n    "name": "Acme Cloud Services LLC",\n    "taxId": "US-99182312"\n  },\n  "client": {\n    "name": "Cyberdyne Systems"\n  },\n  "lineItems": [\n    {\n      "description": "GPU Cluster Instance H100",\n      "quantity": 48,\n      "unit": "hours",\n      "unitPrice": 3.20,\n      "totalPrice": 153.60\n    },\n    {\n      "description": "NVMe High-Speed Storage 2TB",\n      "quantity": 1,\n      "unit": "month",\n      "unitPrice": 80.00,\n      "totalPrice": 80.00\n    }\n  ],\n  "subtotal": 233.60,\n  "taxRate": 0.08,\n  "taxAmount": 18.69,\n  "totalAmount": 252.29,\n  "currency": "USD"\n}',
        mockTokens: 215,
        mockLatency: '0.6s',
      },
    ]
    
    const defaultPreset = presets[0]!
    
    /**
     * PromptPlayground -- the React block, 1:1: an AI prompt studio with a toolbar (model +
     * preset selects, token / cost pill, Save Preset, Run Prompt), system and user message
     * cards, a parameters card (temperature, max tokens, top-p sliders, JSON / stream
     * switches) and a response card with a formatted / raw JSON toggle and copy.
     *
     * Icons inside buttons sit in <lucide-icon>, so the button's `has-[>svg]:px-2.5` padding
     * does not match; `px-2.5` is written out where React gets it from that variant.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-prompt-playground, [ui-prompt-playground]',
      standalone: true,
      // React renders the root <div> itself: keep this wrapper out of the layout.
      host: { class: 'contents', '(window:keydown)': 'onWindowKeydown($event)' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiSliderComponent,
        UiSwitchComponent,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="prompt-playground" [class]="rootClass">
          <!-- Top Toolbar -->
          <div
            class="border-border bg-card/70 flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3.5 shadow-xs backdrop-blur-xs"
          >
            <div class="flex flex-wrap items-center gap-2.5">
              <!-- Model Selector -->
              <div class="w-48">
                <ui-select [value]="selectedModel()" (valueChange)="selectedModel.set($event)">
                  <button ui-select-trigger class="h-9 w-full text-xs font-medium" aria-label="Select AI Model">
                    <ui-select-value placeholder="Select model" />
                  </button>
                  <ui-select-content>
                    @for (m of models; track m.id) {
                      <ui-select-item [value]="m.id">
                        <div class="flex items-center gap-2">
                          <lucide-icon [img]="Sparkles" class="text-primary size-3.5" />
                          <span>{{ m.name }}</span>
                        </div>
                      </ui-select-item>
                    }
                  </ui-select-content>
                </ui-select>
              </div>
    
              <!-- Preset Template Dropdown -->
              <div class="w-52">
                <ui-select [value]="selectedPreset()" (valueChange)="handlePresetChange($event)">
                  <button ui-select-trigger class="h-9 w-full text-xs" aria-label="Select Preset Template">
                    <ui-select-value placeholder="Preset template" />
                  </button>
                  <ui-select-content>
                    @for (p of presets; track p.id) {
                      <ui-select-item [value]="p.id">
                        <span>{{ p.name }}</span>
                      </ui-select-item>
                    }
                  </ui-select-content>
                </ui-select>
              </div>
    
              <!-- Token Counter Pill -->
              <div
                class="border-border bg-muted/60 text-muted-foreground hidden items-center gap-1.5 rounded-full border px-3 py-1 font-mono text-xs md:inline-flex"
              >
                <lucide-icon [img]="Cpu" class="text-primary size-3.5" />
                <span>{{ estimatedTotalTokens().toLocaleString() }} tokens</span>
                <span class="text-foreground font-medium">\${{ estimatedCost() }}</span>
              </div>
            </div>
    
            <div class="flex items-center gap-2">
              <!-- Save Preset Outline Button -->
              <button
                ui-button
                variant="outline"
                size="sm"
                class="h-9 gap-1.5 px-2.5 text-xs font-medium"
                (click)="handleSavePreset()"
              >
                @if (isPresetSaved()) {
                  <lucide-icon [img]="Check" class="text-success size-3.5" />
                } @else {
                  <lucide-icon [img]="Save" class="size-3.5" />
                }
                <span>{{ isPresetSaved() ? 'Saved!' : 'Save Preset' }}</span>
              </button>
    
              <!-- Run Prompt Primary Button -->
              <button ui-button size="sm" [class]="runButtonClass()" [disabled]="isGenerating()" (click)="runPrompt()">
                @if (isGenerating()) {
                  <span
                    class="border-primary-foreground size-3.5 animate-spin rounded-full border-2 border-t-transparent"
                  ></span>
                } @else {
                  <lucide-icon [img]="Zap" class="size-3.5 fill-current" />
                }
                <span>{{ isGenerating() ? 'Running...' : 'Run Prompt' }}</span>
                <kbd
                  class="bg-primary-foreground/20 hidden items-center rounded px-1 py-0.5 font-mono text-xs sm:inline-flex"
                  >⌘↵</kbd
                >
              </button>
            </div>
          </div>
    
          <!-- 2-Column Studio Layout -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
            <!-- Left Panel: Prompt Configuration & Test Input -->
            <div class="space-y-6 lg:col-span-7">
              <!-- System Instructions Card -->
              <ui-card class="border-border bg-card shadow-xs">
                <ui-card-header class="pb-3">
                  <div class="flex flex-wrap items-center 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]="Bot" class="size-4" />
                      </div>
                      <div>
                        <ui-card-title class="text-sm font-semibold">System Instructions</ui-card-title>
                        <ui-card-description class="text-xs"
                          >Behavior, role personality, and operational constraints</ui-card-description
                        >
                      </div>
                    </div>
                    <span ui-badge variant="secondary" class="font-mono text-xs">role: system</span>
                  </div>
                </ui-card-header>
                <ui-card-content class="pt-0">
                  <ui-textarea
                    [value]="systemPrompt()"
                    (valueChange)="systemPrompt.set($event)"
                    placeholder="You are an expert AI assistant..."
                    [rows]="4"
                    noResize
                    class="border-border/70 focus:border-primary font-mono text-xs leading-relaxed"
                  />
                </ui-card-content>
                <ui-card-footer
                  class="border-border text-muted-foreground flex flex-wrap items-center justify-between border-t pt-3 text-xs"
                >
                  <span class="flex flex-wrap items-center gap-1.5">
                    <lucide-icon [img]="FileCode" class="size-3.5" />
                    <span>Context: {{ activeModel().contextWindow }}</span>
                  </span>
                  <span class="font-mono">{{ systemPrompt().length }} chars</span>
                </ui-card-footer>
              </ui-card>
    
              <!-- User Message Input Card -->
              <ui-card class="border-border bg-card shadow-xs">
                <ui-card-header class="pb-3">
                  <div class="flex flex-wrap items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div
                        class="bg-secondary text-secondary-foreground flex size-7 items-center justify-center rounded-md"
                      >
                        <lucide-icon [img]="MessageSquare" class="size-4" />
                      </div>
                      <div>
                        <ui-card-title class="text-sm font-semibold">User Message</ui-card-title>
                        <ui-card-description class="text-xs">Prompt input payload and test variables</ui-card-description>
                      </div>
                    </div>
                    <span ui-badge variant="outline" class="font-mono text-xs">role: user</span>
                  </div>
                </ui-card-header>
                <ui-card-content class="pt-0">
                  <ui-textarea
                    [value]="userMessage()"
                    (valueChange)="userMessage.set($event)"
                    placeholder="Enter user test prompt..."
                    [rows]="7"
                    noResize
                    class="border-border/70 focus:border-primary font-mono text-xs leading-relaxed"
                  />
                </ui-card-content>
                <ui-card-footer
                  class="border-border text-muted-foreground flex flex-wrap items-center justify-between border-t pt-3 text-xs"
                >
                  <div class="flex items-center gap-2">
                    <span class="text-muted-foreground text-xs">Quick inject:</span>
                    <button
                      type="button"
                      class="hover:border-primary/60 hover:text-foreground min-h-6 rounded border border-dashed px-1.5 py-0.5 font-mono text-xs transition-colors"
                      (click)="quickInject('\\n\\nEnsure strict backward compatibility.')"
                    >
                      +compatibility
                    </button>
                    <button
                      type="button"
                      class="hover:border-primary/60 hover:text-foreground min-h-6 rounded border border-dashed px-1.5 py-0.5 font-mono text-xs transition-colors"
                      (click)="quickInject('\\n\\nOutput in concise bullet points.')"
                    >
                      +bullets
                    </button>
                  </div>
                  <span class="font-mono">~{{ estimatedPromptTokens() }} tokens</span>
                </ui-card-footer>
              </ui-card>
            </div>
    
            <!-- Right Panel: Parameters Sidebar + Output Card -->
            <div class="space-y-6 lg:col-span-5">
              <!-- Right Parameter Sidebar Card -->
              <ui-card class="border-border bg-card shadow-xs">
                <ui-card-header class="pb-3">
                  <div class="flex flex-wrap items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md">
                        <lucide-icon [img]="Sliders" class="size-4" />
                      </div>
                      <ui-card-title class="text-sm font-semibold">Parameters</ui-card-title>
                    </div>
                    <button
                      ui-button
                      variant="ghost"
                      size="sm"
                      class="text-muted-foreground hover:text-foreground h-7 px-2.5 text-xs"
                      (click)="handleResetDefaults()"
                    >
                      <lucide-icon [img]="RotateCcw" [class]="'mr-1 size-3'" />
                      <span>Reset</span>
                    </button>
                  </div>
                </ui-card-header>
                <ui-card-content class="space-y-4 pt-0">
                  <!-- Temperature Slider -->
                  <div class="space-y-2">
                    <div class="flex flex-wrap items-center justify-between text-xs">
                      <label class="text-foreground font-medium">Temperature</label>
                      <span class="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono font-medium">{{
                        temperature().toFixed(2)
                      }}</span>
                    </div>
                    <ui-slider
                      [value]="[temperature()]"
                      (valueChange)="temperature.set(first($event))"
                      [min]="0"
                      [max]="1"
                      [step]="0.05"
                    />
                    <div class="text-muted-foreground flex justify-between text-xs">
                      <span>0.0 (Deterministic)</span>
                      <span>1.0 (Creative)</span>
                    </div>
                  </div>
    
                  <!-- Max Tokens Slider -->
                  <div class="space-y-2">
                    <div class="flex flex-wrap items-center justify-between text-xs">
                      <label class="text-foreground font-medium">Max Tokens</label>
                      <span class="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono font-medium">{{
                        maxTokens().toLocaleString()
                      }}</span>
                    </div>
                    <ui-slider
                      [value]="[maxTokens()]"
                      (valueChange)="maxTokens.set(first($event))"
                      [min]="256"
                      [max]="4096"
                      [step]="128"
                    />
                    <div class="text-muted-foreground flex justify-between text-xs">
                      <span>256</span>
                      <span>4,096</span>
                    </div>
                  </div>
    
                  <!-- Top P Slider -->
                  <div class="space-y-2">
                    <div class="flex flex-wrap items-center justify-between text-xs">
                      <label class="text-foreground font-medium">Top P</label>
                      <span class="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono font-medium">{{
                        topP().toFixed(2)
                      }}</span>
                    </div>
                    <ui-slider
                      [value]="[topP()]"
                      (valueChange)="topP.set(first($event))"
                      [min]="0"
                      [max]="1"
                      [step]="0.05"
                    />
                    <div class="text-muted-foreground flex justify-between text-xs">
                      <span>0.0 (Focused)</span>
                      <span>1.0 (Diverse)</span>
                    </div>
                  </div>
    
                  <ui-separator />
    
                  <!-- Switches -->
                  <div class="space-y-3">
                    <div class="flex flex-wrap items-center justify-between gap-2">
                      <div class="space-y-0.5">
                        <label for="angular-json-mode" class="text-foreground cursor-pointer text-xs font-medium">
                          JSON Mode
                        </label>
                        <p class="text-muted-foreground text-xs">Enforce structured JSON output</p>
                      </div>
                      <button
                        ui-switch
                        id="angular-json-mode"
                        [checked]="jsonMode()"
                        (checkedChange)="jsonMode.set($event)"
                      ></button>
                    </div>
    
                    <div class="flex flex-wrap items-center justify-between gap-2">
                      <div class="space-y-0.5">
                        <label for="angular-stream-responses" class="text-foreground cursor-pointer text-xs font-medium">
                          Stream Responses
                        </label>
                        <p class="text-muted-foreground text-xs">Simulate real-time token streaming</p>
                      </div>
                      <button
                        ui-switch
                        id="angular-stream-responses"
                        [checked]="streamResponses()"
                        (checkedChange)="streamResponses.set($event)"
                      ></button>
                    </div>
                  </div>
                </ui-card-content>
              </ui-card>
    
              <!-- Response Output Card -->
              <ui-card class="border-border bg-card shadow-xs">
                <ui-card-header class="pb-3">
                  <div class="flex flex-wrap items-center 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]="Terminal" class="size-4" />
                      </div>
                      <div>
                        <ui-card-title class="text-sm font-semibold">Response Output</ui-card-title>
                      </div>
                    </div>
                    <div class="flex flex-wrap items-center gap-1.5">
                      <button ui-button variant="ghost" size="sm" [class]="rawToggleClass()" (click)="toggleRawJson()">
                        <lucide-icon [img]="Braces" [class]="'mr-1 size-3.5'" />
                        <span>{{ showRawJson() ? 'Formatted' : 'Raw JSON' }}</span>
                      </button>
                      <button
                        ui-button
                        variant="ghost"
                        size="sm"
                        class="text-muted-foreground hover:text-foreground h-7 px-2.5 text-xs"
                        (click)="handleCopyResponse()"
                      >
                        @if (isCopied()) {
                          <lucide-icon [img]="Check" [class]="'text-success mr-1 size-3.5'" />
                        } @else {
                          <lucide-icon [img]="Copy" [class]="'mr-1 size-3.5'" />
                        }
                        <span>{{ isCopied() ? 'Copied' : 'Copy' }}</span>
                      </button>
                    </div>
                  </div>
                  <div class="mt-2 flex items-center gap-2">
                    <span ui-badge variant="outline" [class]="statusBadgeClass()">
                      <span [class]="statusDotClass()"></span>{{ responseStatus() }}
                    </span>
                  </div>
                </ui-card-header>
                <ui-card-content class="pt-0">
                  @if (showRawJson()) {
                    <!-- Raw JSON View -->
                    <div class="relative">
                      <pre
                        class="border-border bg-muted/40 text-foreground max-h-[380px] overflow-x-auto overflow-y-auto rounded-md border p-3.5 font-mono text-xs leading-relaxed"
                      ><code>{{ rawJsonPayload() }}</code></pre>
                    </div>
                  } @else {
                    <!-- Prose / Markdown View: keep the interpolation tight (React renders response + cursor with no whitespace). -->
                    <div
                      class="border-border bg-muted/20 text-foreground max-h-[380px] min-h-[220px] overflow-y-auto rounded-md border p-3.5 font-mono text-xs leading-relaxed whitespace-pre-wrap"
                    >{{ displayedResponse() }}@if (isGenerating()) {
                        <span class="text-primary ml-0.5 inline-block animate-pulse">▋</span>
                      }</div>
                  }
                </ui-card-content>
                <ui-card-footer
                  class="border-border text-muted-foreground flex flex-wrap items-center justify-between border-t pt-3 text-xs"
                >
                  <span class="flex items-center gap-1">
                    <lucide-icon [img]="Clock" class="size-3.5" />
                    <span>Finish: stop</span>
                  </span>
                  <span class="font-mono">Throughput: ~305 tok/s</span>
                </ui-card-footer>
              </ui-card>
            </div>
          </div>
        </div>
      `,
    })
    export class UiPromptPlaygroundComponent {
      /** Extra classes for the root element (React `className`). */
      @Input('class') className?: string
    
      readonly Bot = Bot
      readonly Braces = Braces
      readonly Check = Check
      readonly Clock = Clock
      readonly Copy = Copy
      readonly Cpu = Cpu
      readonly FileCode = FileCode
      readonly MessageSquare = MessageSquare
      readonly RotateCcw = RotateCcw
      readonly Save = Save
      readonly Sliders = Sliders
      readonly Sparkles = Sparkles
      readonly Terminal = Terminal
      readonly Zap = Zap
    
      readonly models = models
      readonly presets = presets
    
      readonly selectedModel = signal('claude-3-5-sonnet')
      readonly selectedPreset = signal('code-reviewer')
    
      readonly systemPrompt = signal(defaultPreset.systemPrompt)
      readonly userMessage = signal(defaultPreset.userMessage)
      readonly temperature = signal(defaultPreset.temperature)
      readonly maxTokens = signal(defaultPreset.maxTokens)
      readonly topP = signal(defaultPreset.topP)
      readonly jsonMode = signal(defaultPreset.jsonMode)
      readonly streamResponses = signal(defaultPreset.stream)
    
      readonly displayedResponse = signal(defaultPreset.mockResponse)
      readonly responseStatus = signal('200 OK · 1.4s · 428 tokens')
      readonly isGenerating = signal(false)
      readonly showRawJson = signal(false)
      readonly isCopied = signal(false)
      readonly isPresetSaved = signal(false)
    
      private streamTimer: ReturnType<typeof setInterval> | null = null
      private readonly timeouts = new Set<ReturnType<typeof setTimeout>>()
    
      constructor() {
        inject(DestroyRef).onDestroy(() => {
          if (this.streamTimer) clearInterval(this.streamTimer)
          for (const t of this.timeouts) clearTimeout(t)
        })
      }
    
      readonly activeModel = computed(() => models.find((m) => m.id === this.selectedModel()) ?? models[0]!)
    
      readonly estimatedPromptTokens = computed(() => {
        const combined = (this.systemPrompt() || '') + (this.userMessage() || '')
        return Math.max(1, Math.round(combined.length / 3.8))
      })
    
      readonly estimatedTotalTokens = computed(() => {
        const outputEstimate = this.isGenerating() ? Math.round(this.maxTokens() / 4) : 428
        return this.estimatedPromptTokens() + outputEstimate
      })
    
      readonly estimatedCost = computed(() => {
        const model = this.activeModel()
        const inputCost = (this.estimatedPromptTokens() / 1_000_000) * model.inputCostPer1M
        const outputCost = (428 / 1_000_000) * model.outputCostPer1M
        return (inputCost + outputCost).toFixed(4)
      })
    
      readonly rawJsonPayload = computed(() =>
        JSON.stringify(
          {
            id: `chatcmpl-${Math.random().toString(36).substring(2, 10)}`,
            object: 'chat.completion',
            created: 1787313600,
            model: this.selectedModel(),
            system_fingerprint: 'fp_uipkge_49a',
            choices: [
              {
                index: 0,
                message: {
                  role: 'assistant',
                  content: this.displayedResponse(),
                },
                finish_reason: 'stop',
              },
            ],
            usage: {
              prompt_tokens: this.estimatedPromptTokens(),
              completion_tokens: 428,
              total_tokens: this.estimatedPromptTokens() + 428,
            },
            configuration: {
              temperature: this.temperature(),
              max_tokens: this.maxTokens(),
              top_p: this.topP(),
              response_format: this.jsonMode() ? { type: 'json_object' } : { type: 'text' },
              stream: this.streamResponses(),
            },
          },
          null,
          2,
        ),
      )
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      // px-2.5 idle: React gets it from size="sm" has-[>svg] (the Zap icon); the spinner is a span (px-3).
      readonly runButtonClass = computed(() =>
        cn(
          'bg-primary text-primary-foreground h-9 gap-1.5 text-xs font-medium shadow-xs',
          !this.isGenerating() && 'px-2.5',
        ),
      )
    
      readonly rawToggleClass = computed(() =>
        cn('h-7 px-2.5 text-xs', this.showRawJson() ? 'text-primary bg-primary/10' : 'text-muted-foreground'),
      )
    
      readonly statusBadgeClass = computed(() =>
        cn(
          'font-mono text-xs',
          this.isGenerating() ? 'border-primary/40 text-primary animate-pulse' : 'border-success/30 text-success',
        ),
      )
    
      readonly statusDotClass = computed(() =>
        cn('mr-1.5 inline-block size-1.5 rounded-full', this.isGenerating() ? 'bg-primary' : 'bg-success'),
      )
    
      /** Slider `valueChange` always emits number[]; one thumb here. */
      first(values: number[]): number {
        return values[0] ?? 0
      }
    
      private later(fn: () => void, ms: number): void {
        const t = setTimeout(() => {
          this.timeouts.delete(t)
          fn()
        }, ms)
        this.timeouts.add(t)
      }
    
      handlePresetChange(presetId: string): void {
        this.selectedPreset.set(presetId)
        const preset = presets.find((p) => p.id === presetId)
        if (!preset) return
    
        this.systemPrompt.set(preset.systemPrompt)
        this.userMessage.set(preset.userMessage)
        this.selectedModel.set(preset.model)
        this.temperature.set(preset.temperature)
        this.maxTokens.set(preset.maxTokens)
        this.topP.set(preset.topP)
        this.jsonMode.set(preset.jsonMode)
        this.streamResponses.set(preset.stream)
        this.displayedResponse.set(preset.mockResponse)
        this.responseStatus.set(`200 OK · ${preset.mockLatency} · ${preset.mockTokens} tokens`)
      }
    
      runPrompt(): void {
        if (this.isGenerating()) return
        if (this.streamTimer) clearInterval(this.streamTimer)
    
        const activePreset = presets.find((p) => p.id === this.selectedPreset()) ?? defaultPreset
        const targetResponse =
          this.jsonMode() && !activePreset.jsonMode ? presets[2]!.mockResponse : activePreset.mockResponse
        const doneStatus = `200 OK · ${activePreset.mockLatency} · ${activePreset.mockTokens} tokens`
    
        this.isGenerating.set(true)
        this.responseStatus.set('Generating...')
    
        if (this.streamResponses()) {
          this.displayedResponse.set('')
          let charIndex = 0
          const chunkSize = 8
          const totalChars = targetResponse.length
    
          this.streamTimer = setInterval(() => {
            charIndex += chunkSize
            if (charIndex >= totalChars) {
              this.displayedResponse.set(targetResponse)
              if (this.streamTimer) clearInterval(this.streamTimer)
              this.streamTimer = null
              this.isGenerating.set(false)
              this.responseStatus.set(doneStatus)
            } else {
              this.displayedResponse.set(targetResponse.slice(0, charIndex))
            }
          }, 25)
        } else {
          this.later(() => {
            this.displayedResponse.set(targetResponse)
            this.isGenerating.set(false)
            this.responseStatus.set(doneStatus)
          }, 600)
        }
      }
    
      quickInject(text: string): void {
        this.userMessage.update((prev) => prev + text)
      }
    
      toggleRawJson(): void {
        this.showRawJson.update((v) => !v)
      }
    
      handleCopyResponse(): void {
        const text = this.displayedResponse()
        if (!text) return
        void navigator.clipboard?.writeText(text)
        this.isCopied.set(true)
        this.later(() => this.isCopied.set(false), 2000)
      }
    
      handleSavePreset(): void {
        this.isPresetSaved.set(true)
        this.later(() => this.isPresetSaved.set(false), 2000)
      }
    
      handleResetDefaults(): void {
        this.temperature.set(0.7)
        this.maxTokens.set(2048)
        this.topP.set(0.9)
        this.jsonMode.set(false)
        this.streamResponses.set(true)
      }
    
      onWindowKeydown(e: KeyboardEvent): void {
        if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
          e.preventDefault()
          this.runPrompt()
        }
      }
    }
    
  • src/app/components/blocks/prompt-playground/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/prompt-playground.json