Prompt Playground
blockaiAI 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
$pnpm dlx uipkge-ng@latest add prompt-playground$npx uipkge-ng@latest add prompt-playground$yarn dlx uipkge-ng@latest add prompt-playground$bunx uipkge-ng@latest add prompt-playgroundManifest:
https://uipkge.dev/r/angular/prompt-playground.jsonInstalls to:src/app/components/blocks/prompt-playground/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
PromptPlaygroundModelinterface PromptPlaygroundModel {
id: string
name: string
provider: string
contextWindow: string
inputCostPer1M: number
outputCostPer1M: number
}PromptPlaygroundPresetinterface 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
export { UiPromptPlaygroundComponent, type PromptPlaygroundModel, type PromptPlaygroundPreset, } from './prompt-playground.component'
Raw manifest:https://uipkge.dev/r/angular/prompt-playground.json