Based on Angular.dev AI Design Patterns
This guide demonstrates how to integrate AI and Large Language Model (LLM) APIs into your Angular application using modern patterns with signals and the resource API.
- Core Concepts
- Triggering AI Requests
- Managing Response Data
- Streaming Responses
- Performance & UX
- Complete Examples
- Best Practices
AI/LLM APIs present unique challenges:
- Slow responses: Can take seconds or minutes
- Streaming data: Partial results arrive over time
- Error-prone: Network issues, rate limits, timeouts
- Expensive: Each request costs money and resources
Angular signals provide:
- ✅ Fine-grained reactivity
- ✅ Efficient change detection
- ✅ Built-in async handling with
resource - ✅ Stream support for real-time updates
Problem: You don't want to trigger an expensive AI API call on every keystroke.
Solution: Use two signals - one for live input, one for submission.
import { Component, signal, resource } from "@angular/core";
import { inject } from "@angular/core";
@Component({
selector: "app-ai-prompt",
templateUrl: "./ai-prompt.html",
styleUrl: "./ai-prompt.scss",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AIPrompt {
private aiService = inject(AIService);
// Live user input (updates on every keystroke)
userInput = signal("");
// Submitted prompt (only updates when user clicks submit)
submittedPrompt = signal("");
// Resource triggered ONLY when submittedPrompt changes
aiResponse = resource({
params: () => this.submittedPrompt(),
loader: async ({ params }) => {
if (!params) return null;
return await this.aiService.generateResponse(params);
},
});
onSubmit() {
// Update submission signal to trigger the resource
this.submittedPrompt.set(this.userInput());
}
}Template:
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">AI Assistant</h2>
<!-- Input that doesn't trigger AI -->
<textarea class="textarea textarea-bordered" placeholder="Ask me anything..." [value]="userInput()" (input)="userInput.set($any($event.target).value)"></textarea>
<!-- Submit button triggers the AI call -->
<button class="btn btn-primary" (click)="onSubmit()" [disabled]="aiResponse.isLoading() || !userInput()">
@if (aiResponse.isLoading()) {
<span class="loading loading-spinner"></span>
Generating... } @else { Send to AI }
</button>
<!-- Display response -->
@if (aiResponse.hasValue() && aiResponse.value()) {
<div class="alert alert-success">{{ aiResponse.value() }}</div>
}
<!-- Error handling -->
@if (aiResponse.error()) {
<div class="alert alert-error">
<span>{{ aiResponse.error()?.message }}</span>
<button class="btn btn-sm" (click)="aiResponse.reload()">Retry</button>
</div>
}
</div>
</div>Problem: You need to build up a chat history or accumulate streaming results.
Solution: Use linkedSignal to append new data to existing data.
import { linkedSignal } from "@angular/core";
interface ChatMessage {
role: "user" | "assistant";
content: string;
timestamp: Date;
}
@Component({
selector: "app-chat",
templateUrl: "./chat.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Chat {
private aiService = inject(AIService);
userMessage = signal("");
sessionId = signal(crypto.randomUUID());
// Resource for individual responses
chatResource = resource({
params: () => ({
message: this.userMessage(),
sessionId: this.sessionId(),
}),
loader: async ({ params }) => {
if (!params.message) return null;
return await this.aiService.chat(params);
},
});
// LinkedSignal to accumulate chat history
chatHistory = linkedSignal<ChatMessage[], ChatMessage | null>({
source: () => this.chatResource.value(),
computation: (newMessage, previous) => {
const existing = previous?.value || [];
if (!newMessage) return existing;
// Add user message and AI response
return [
...existing,
{
role: "user",
content: this.userMessage(),
timestamp: new Date(),
},
{
role: "assistant",
content: newMessage.content,
timestamp: new Date(),
},
];
},
});
sendMessage() {
if (!this.userMessage()) return;
// This will trigger chatResource, which updates chatHistory
this.userMessage.set(""); // Clear after sending
}
}Template:
<div class="chat-container">
<!-- Chat history -->
<div class="messages">
@for (msg of chatHistory(); track msg.timestamp) {
<div class="chat" [class.chat-start]="msg.role === 'assistant'" [class.chat-end]="msg.role === 'user'">
<div class="chat-bubble" [class.chat-bubble-primary]="msg.role === 'user'" [class.chat-bubble-secondary]="msg.role === 'assistant'">{{ msg.content }}</div>
</div>
}
<!-- Loading indicator -->
@if (chatResource.isLoading()) {
<div class="chat chat-start">
<div class="chat-bubble">
<span class="loading loading-dots"></span>
</div>
</div>
}
</div>
<!-- Input -->
<div class="input-group">
<input class="input input-bordered w-full" placeholder="Type a message..." [value]="userMessage()" (input)="userMessage.set($any($event.target).value)" (keyup.enter)="sendMessage()" />
<button class="btn btn-primary" (click)="sendMessage()">Send</button>
</div>
</div>Problem: LLM responses can be slow. You want to show partial results as they arrive.
Solution: Use resource.stream to update a signal as data streams in.
interface StreamedContent {
value: string;
}
@Component({
selector: "app-streaming-ai",
templateUrl: "./streaming-ai.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StreamingAI {
private aiService = inject(AIService);
prompt = signal("Tell me a story about a space explorer");
// Streaming resource
streamingResponse = resource({
params: () => this.prompt(),
stream: async ({ params }) => {
// Create a signal to hold the streaming data
const data = signal<ResourceStreamItem<string>>({ value: "" });
// Get the stream from your AI service
const stream = await this.aiService.streamGenerate(params);
// Process chunks asynchronously
(async () => {
try {
for await (const chunk of stream) {
data.update((prev) => {
if ("value" in prev) {
return { value: `${prev.value}${chunk}` };
}
return prev;
});
}
} catch (error) {
data.set({ error: error as Error });
}
})();
return data;
},
});
}AI Service with Streaming:
@Injectable({
providedIn: "root",
})
export class AIService {
private http = inject(HttpClient);
async *streamGenerate(prompt: string): AsyncGenerator<string> {
const response = await fetch("/api/ai/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (!response.body) {
throw new Error("No response body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
yield chunk;
}
}
}Template with Streaming:
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Streaming AI Response</h2>
@if (streamingResponse.isLoading()) {
<div class="prose max-w-none">
<!-- Show partial content as it streams -->
<p class="animate-pulse">{{ streamingResponse.value() }}</p>
<div class="text-sm text-base-content/50">
<span class="loading loading-dots"></span>
Generating...
</div>
</div>
} @else if (streamingResponse.hasValue()) {
<div class="prose max-w-none">
<p>{{ streamingResponse.value() }}</p>
</div>
} @else if (streamingResponse.error()) {
<div class="alert alert-error">
<span>Error: {{ streamingResponse.error()?.message }}</span>
<button class="btn btn-sm" (click)="streamingResponse.reload()">Retry</button>
</div>
}
</div>
</div>Place resources in the component that uses them to limit change detection:
// ✅ Good: Resource in component
@Component({...})
export class AIFeature {
aiResource = resource({...}); // Scoped to this component
}
// ❌ Avoid: Global resource unless shared
@Injectable({ providedIn: 'root' })
export class GlobalAIService {
// Only do this if multiple components need the same data
sharedResource = resource({...});
}Show placeholders during SSR, load AI content after hydration:
<!-- Initial SSR render -->
<div class="skeleton-placeholder">
<div class="skeleton h-4 w-full mb-2"></div>
<div class="skeleton h-4 w-3/4"></div>
</div>
<!-- After hydration, load AI content -->
@defer (on viewport) {
<app-ai-content />
}Always show clear loading states:
@if (aiResource.isLoading()) {
<div class="loading-state">
<progress class="progress progress-primary w-full"></progress>
<p class="text-sm">AI is processing your request...</p>
<p class="text-xs text-base-content/50">This may take a moment</p>
</div>
}Make it easy to retry failed requests:
@if (aiResource.error()) {
<div class="alert alert-error">
<div>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h3 class="font-bold">Error</h3>
<div class="text-xs">{{ aiResource.error()?.message }}</div>
</div>
</div>
<button class="btn btn-sm btn-ghost" (click)="aiResource.reload()">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Retry
</button>
</div>
}@Component({
selector: "app-ai-image-gen",
templateUrl: "./ai-image-gen.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AIImageGen {
private aiService = inject(AIService);
prompt = signal("");
submittedPrompt = signal("");
imageResource = resource({
params: () => this.submittedPrompt(),
loader: async ({ params }): Promise<{ url: string; metadata: any }> => {
return await this.aiService.generateImage(params);
},
});
generate() {
this.submittedPrompt.set(this.prompt());
}
}<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Input Panel -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Generate Image</h2>
<textarea class="textarea textarea-bordered h-32" placeholder="Describe the image you want..." [value]="prompt()" (input)="prompt.set($any($event.target).value)"></textarea>
<button class="btn btn-primary" (click)="generate()" [disabled]="imageResource.isLoading() || !prompt()">Generate</button>
</div>
</div>
<!-- Output Panel -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@if (imageResource.isLoading()) {
<div class="flex items-center justify-center h-64">
<span class="loading loading-spinner loading-lg"></span>
</div>
} @else if (imageResource.hasValue()) {
<figure>
<img [src]="imageResource.value().url" alt="Generated" class="rounded-lg" />
</figure>
} @else {
<div class="flex items-center justify-center h-64 text-base-content/50">Generated image will appear here</div>
}
</div>
</div>
</div>interface CodeSuggestion {
code: string;
explanation: string;
language: string;
}
@Component({
selector: "app-code-assistant",
templateUrl: "./code-assistant.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CodeAssistant {
private aiService = inject(AIService);
codeInput = signal("");
language = signal("typescript");
submittedRequest = signal<{ code: string; language: string } | null>(null);
suggestionResource = resource({
params: () => this.submittedRequest(),
loader: async ({ params }): Promise<CodeSuggestion | null> => {
if (!params) return null;
return await this.aiService.suggestCodeImprovements(params);
},
});
getSuggestions() {
this.submittedRequest.set({
code: this.codeInput(),
language: this.language(),
});
}
}- Separate input from submission to avoid triggering API on every keystroke
- Use linkedSignal for accumulating chat history or streaming responses
- Show loading indicators - AI responses can be slow
- Provide retry mechanisms - use
resource.reload() - Type your responses - define interfaces for AI output
- Scope resources to components that use them
- Handle errors gracefully - show user-friendly messages
- Use streaming for long-form content generation
- Implement session management with signals for persistent conversations
- Test with loading delays to ensure good UX
- Don't trigger AI requests on every input change
- Don't use
anytypes - define proper interfaces - Don't forget loading states - users need feedback
- Don't ignore errors - AI APIs can fail
- Don't block the UI - keep resources scoped
- Don't skip retry logic - network issues happen
- Don't hardcode API keys - use environment variables
- Don't forget rate limiting - implement debouncing/throttling
- Don't assume instant responses - plan for delays
- Don't skip SSR optimization - use defer and placeholders
import { GoogleGenerativeAI } from "@google/generative-ai";
@Injectable({ providedIn: "root" })
export class GeminiService {
private genAI = new GoogleGenerativeAI(environment.geminiApiKey);
async generateContent(prompt: string) {
const model = this.genAI.getGenerativeModel({ model: "gemini-pro" });
const result = await model.generateContent(prompt);
return result.response.text();
}
async *streamContent(prompt: string) {
const model = this.genAI.getGenerativeModel({ model: "gemini-pro" });
const result = await model.generateContentStream(prompt);
for await (const chunk of result.stream) {
yield chunk.text();
}
}
}import { genkit } from "genkit";
import { googleAI } from "@genkit-ai/googleai";
const ai = genkit({ plugins: [googleAI()] });
export const chatFlow = ai.defineFlow(
{
name: "chat",
inputSchema: z.object({ message: z.string() }),
outputSchema: z.string(),
},
async (input) => {
const result = await ai.generate({
model: "googleai/gemini-pro",
prompt: input.message,
});
return result.text;
}
);- Angular AI Design Patterns
- Angular Signals Guide
- Resource API Documentation
- Google Gemini API
- Firebase Genkit
Happy AI Integration! 🤖✨