Mert Özen Her Satırda Daha İleri
Yapay Zeka Angular ile AI Arayüzleri — #06

Standart Protokollerle Bağlanmak: AG-UI, AI SDK UI ve Kendi SSE Sözleşmemiz Arasında Seçim

Mert Özen 14 Kas 2026 16 dk 1 görüntülenme
Standart Protokollerle Bağlanmak: AG-UI, AI SDK UI ve Kendi SSE Sözleşmemiz Arasında Seçim

Kendi SSE sözleşmen, AG-UI ve AI SDK UI arasındaki seçim; backend'in, istemci sayısının ve ödemeye razı olduğun bundle ile kablo maliyetinin sonucudur. Bu yazıda aynı asistanı üç protokolle Angular'a bağlıyor, AG-UI'ın varsayılan ayarlarla modelin düşünme metnini 368 KB'lık bir akışla tarayıcıya taşıdığını, iki protokol library'sinin sayfaya 500-680 kB eklediğini ve abort'un RUN_ERROR olarak geldiğini ölçüyoruz.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0, Node.js 24) ve .NET 10 ile derlenip denenmiştir; backend'de Microsoft.Extensions.AI 11.0.0, OllamaSharp 5.5.2 ve AGUI.Server 1.0.1, Angular tarafında @ag-ui/client 1.0.4 ile @ai-sdk/angular 3.0.173 (ai 7.0.173), tarayıcı denemelerinde Playwright 1.65 ile headless Chromium 158, yerel modelde Ollama 0.34.4 (qwen3:4b) kullanılmıştır. Agent Framework'ün AG-UI hosting paketi (Microsoft.Agents.AI.Hosting.AGUI.AspNetCore, preview) çalıştırılmadı; resmi dokümandan anlatılıyor.

Önce Tahmin Et

Bookstore asistanı (sitedeki serilerin ortak örnek uygulaması Bookstore, bir online kitapçı) beş yazıdır kendi SSE (Server-Sent Events; sunucudan tarayıcıya tek yönlü event akışı) sözleşmemizle konuşuyor: delta, tool, ui, approval ve diğerleri. Aynı asistanı standart bir protokole taşıdığını düşün. AG-UI'ın (Agent-User Interaction Protocol; agent ile arayüz arasındaki açık event protokolü) resmi .NET SDK'sı ile resmi TypeScript client'ını, ikisini de varsayılan ayarlarıyla bağladın. Model tek cümlelik bir kitap önerisi yazacak. Sence bu tek cümle kablo üzerinde kaç byte tutar: 3 KB mı, 30 KB mı, 300 KB mı? Ve Angular tarafında bu protokol için yüklenen JavaScript, chat ekranının kendisinden kaç kat büyük olur?

İki sorunun cevabı da beni şaşırttı ve ilki bir güvenlik sorunu çıkardı. Bu yazıda 2. yazıdaki chat'i önce AG-UI'a, sonra AI SDK UI'a taşıyor, üç sözleşmeyi aynı soruyla ölçüyor ve sonunda Bookstore için hangisini seçeceğimize karar veriyoruz.

Üç Sözleşme, Aynı Asistan

Elimizde aynı modeli, aynı system prompt'u ve aynı show_books tool'unu kullanan üç endpoint olacak:

  • /chat/sse: bu serinin kendi sözleşmesi. Event adları bizim (delta, tool, ui, approval, ping…), konuşma geçmişi sunucuda ConversationId ile tutuluyor.
  • /agui: AG-UI. İstek bir RunAgentInput (thread, run, mesaj listesi, state, istemci tool'ları), cevap type alanı taşıyan JSON event'lerinden oluşan bir SSE akışı.
  • /chat/ui-stream: AI SDK UI'ın UI message stream protokolü. İstek parts listeli UI mesajları, cevap yine type alanlı JSON parçaları ve en sonda data: [DONE].

Angular tarafında her biri için ayrı bir sayfa var: kendi store'umuz (/), HttpAgent kullanan /protocols/agui ve Chat class'ını kullanan /protocols/ai-sdk. Üçü de 4. yazıdaki app-message-body component'iyle, yani aynı sanitize pipeline'ıyla render ediyor; protokol değişince güvenli render kuralları değişmiyor.

AG-UI: Event Aileleri ve Sunucu Tarafı

AG-UI dokümanı event'leri ailelere ayırıyor. Lifecycle: RUN_STARTED, RUN_FINISHED, RUN_ERROR ve isteğe bağlı STEP_*. Metin: TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END. Tool: TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END, TOOL_CALL_RESULT. State: STATE_SNAPSHOT, STATE_DELTA (RFC 6902 JSON Patch) ve MESSAGES_SNAPSHOT. Bunlara reasoning (REASONING_*), activity ve CUSTOM event'leri ekleniyor. Kural basit: her run RUN_STARTED ile başlar, RUN_FINISHED ya da RUN_ERROR ile biter; metin ve tool akışları start, content, end sırasını izler. Onay gibi dışarıdan bir cevap bekleyen durumlar ayrı bir event değil: run, outcome.type değeri "interrupt" olan bir RUN_FINISHED ile biter, istemci cevabı yeni bir run'ın resume alanında gönderir.

.NET tarafında iki yol var. Microsoft 25 Eylül 2026'da CopilotKit ile birlikte AG-UI .NET SDK'sını yayımladı: AGUI.Abstractions, AGUI.Server, AGUI.Client gibi paketler. Agent Framework'ün hosting paketi de artık bu SDK'nın üzerinde duruyor ve aynı duyuruda API adları değişti: MapAGUI yerine MapAGUIServer, AddAGUI yerine AddAGUIServer. .NET ile Yapay Zeka serisinin "ASP.NET Core'da Agent Host Etmek: AddAIAgent, OpenAI Uyumlu Endpoint, AG-UI ve A2A" yazısı bir AIAgent'ı MapAGUIServer ile açmıştı; o paket hâlâ preview. Bu yazıda agent kurmadan, elimizdeki IChatClient'ı doğrudan AGUI.Server 1.0.1 ile açıyoruz:

// AG-UI tiplerinin (RunAgentInput, BaseEvent ve alt tipleri) JSON sözleşmesi
builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, AGUIJsonUtilities.DefaultTypeInfoResolver));
// Aynı asistan, AG-UI protokolüyle: istek RunAgentInput, cevap AG-UI event'lerinden oluşan bir SSE akışı
public static void MapAgUi(this WebApplication app, string systemPrompt)
{
    app.MapPost("/agui", IResult (RunAgentInput input, IChatClient chat, AssistantTools tools,
        IOptions<JsonOptions> json, ILogger<AgUiRun> logger, CancellationToken ct) =>
    {
        ChatRequestContext context = input.ToChatRequestContext(json.Value.SerializerOptions);
        // Geçmişi istemci gönderir; system prompt ve tool'lar yine yalnız sunucuda
        List<ChatMessage> messages = [new(ChatRole.System, systemPrompt), .. context.Messages];
        ChatOptions options = tools.CreateOptions();
        options.Tools = [.. options.Tools!.Where(t => t.Name == "show_books")];

        IAsyncEnumerable<BaseEvent> events = chat.GetStreamingResponseAsync(messages, options, ct)
            .AsAGUIEventStreamAsync(context, ct);
        return TypedResults.ServerSentEvents(events);
    }).RequireRateLimiting("chat");
}

ToChatRequestContext AG-UI mesajlarını ChatMessage listesine çeviriyor, AsAGUIEventStreamAsync de ChatResponseUpdate akışını AG-UI event'lerine. RUN_STARTED ve RUN_FINISHED'ı, açık kalan metin bloğunu kapatmayı SDK yapıyor. Onay isteyen cancel_order'ı bu endpoint'e bilerek koymadım; onay akışının AG-UI karşılığı yukarıdaki interrupt mekanizması ve bu yazıda çalıştırılmadı.

İlk Ölçüm: 368 KB'lık Bir Cümle

Endpoint'i curl ile 1. yazıdaki soruyla çağırdım:

$ curl -sN -X POST localhost:19640/agui -H 'Content-Type: application/json' \
    -d '{"threadId":"t1","runId":"r1","state":{},"messages":[{"id":"m1","role":"user",
         "content":"Recommend one classic novel for a long train trip, in one sentence."}],
         "tools":[],"context":[],"forwardedProps":{}}'
  1996 ms  data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1","protocolVersion":"1.0"}
  2009 ms  data: {"type":"REASONING_START","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ"}
  2009 ms  data: {"type":"REASONING_MESSAGE_START","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ","role":"reasoning"}
  2010 ms  data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ","delta":"Okay"}
  2010 ms  data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ","delta":","}
  2027 ms  data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ","delta":" let"}
# ...
 91505 ms  data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"1c3c26b2273943f482330bcbd95cbce4","delta":"For",
           "rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"text","text":"For", ...},
           "responseId":"1c3c26b2...","createdAt":"2026-11-14T03:30:58.744719+00:00","modelId":"qwen3:4b",...}}
# ...
 92185 ms  data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1","outcome":{"type":"success"},
           "usage":[{"model":"qwen3:4b","inputTokens":543,"outputTokens":3484,"totalTokens":4027}]}

# event sayımı (gövde 368.272 byte)
REASONING_MESSAGE_CONTENT  3456 events  354591 bytes
TEXT_MESSAGE_CONTENT         25 events   12499 bytes
other                         8 events    1182 bytes
TEXT: 'For a long train trip, *Emma* by Jane Austen is a classic comedy of manners perfect for a light read.'

Cevap 101 karakterlik tek bir cümle, gövde 368 KB. Farkın iki kaynağı var. Birincisi, SDK modelin düşünme metnini (TextReasoningContent) REASONING_MESSAGE_CONTENT event'leri olarak tarayıcıya aktarıyor: 3456 event, 15.604 karakter. İkincisi, metin event'lerinin her birinde rawEvent alanında sağlayıcının ham ChatResponseUpdate'i duruyor; 3 karakterlik bir delta 500 byte'a çıkıyor.

Boyuttan daha önemli olan, düşünme metninin içeriği. Metni birleştirince şunlar çıktı: "the instructions say to call show_books instead of listing books in text" ve katalog notlarının satır satır tekrarı. Model düşünürken system prompt'u kendi cümleleriyle anlatıyor. 1. yazıda system prompt'u ve tool tanımlarını tarayıcıdan uzak tutmak için bir backend kurmuştuk; varsayılan dönüşüm bunları kullanıcının geliştirici araçlarına geri taşıdı. Bizim sözleşmemiz düşünmeyi yalnız içeriksiz bir thinking event'iyle bildiriyordu.

Çözüm, tarayıcıya gidecek event'leri sunucuda süzmek:

// Tarayıcıya gidecek event'leri süzer ve run'ın nasıl kapandığını log'a yazar
private static async IAsyncEnumerable<BaseEvent> ForBrowser(IAsyncEnumerable<BaseEvent> events, ILogger logger,
    [EnumeratorCancellation] CancellationToken ct)
{
    int count = 0;
    try
    {
        await foreach (BaseEvent e in events.WithCancellation(ct))
        {
            // Modelin düşünme metni tarayıcıya gitmez; REASONING_START/END "düşünüyor" göstermek için yeter
            if (e is ReasoningMessageStartEvent or ReasoningMessageContentEvent or ReasoningMessageEndEvent)
            {
                continue;
            }
            // rawEvent: sağlayıcının ham update'i; debug içindir, her delta'nın boyunu katlar
            e.RawEvent = null;
            count++;
            yield return e;
        }
    }
    finally
    {
        logger.LogInformation("AG-UI run closed: events={Count}, aborted={Aborted}", count, ct.IsCancellationRequested);
    }
}

Endpoint artık TypedResults.ServerSentEvents(ForBrowser(events, logger, ct)) dönüyor. Aynı soru tekrar:

   272 ms  data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1","protocolVersion":"1.0"}
   284 ms  data: {"type":"REASONING_START","messageId":"msg_4074XgnKZnGl4EKKQcrfO4Sc"}
 96321 ms  data: {"type":"REASONING_END","messageId":"msg_4074XgnKZnGl4EKKQcrfO4Sc"}
 96322 ms  data: {"type":"TEXT_MESSAGE_START","messageId":"c5c215d9deda46028f3a5897d9eab7f0","role":"assistant"}
 96323 ms  data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"c5c215d9deda46028f3a5897d9eab7f0","delta":"For"}
# ...
 97055 ms  data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1","outcome":{"type":"success"},"usage":[...]}

# gövde 3.535 byte, 35 event
info: Bookstore.Assistant.Api.AgUiRun[0]
      AG-UI run closed: events=35, aborted=False

368 KB'tan 3,5 KB'a indi. Ama yeni bir sorun görünür oldu: REASONING_START ile REASONING_END arasında 96 saniye boyunca tek byte gelmiyor. Bizim endpoint'imiz bu sürede 5 saniyede bir ping gönderiyordu ve 3. yazıdaki 15 saniyelik sessizlik zamanlayıcısı buna dayanıyordu. AG-UI'da heartbeat event'i yok; düşünme metnini kesince akışı canlı tutan şeyi de kesmiş olduk. Agent Framework'ün Python hosting'i keepalive yorum satırlarını ayarlanabilir yapıyor; AGUI.Server ile kendin eklemen gerekiyor, ya bir SSE yorum satırı ya da istemcinin yok sayacağı bir CUSTOM event'iyle. Arada 60 ya da 100 saniyelik idle timeout'u olan bir proxy ya da load balancer varsa bu akış yarıda kesilir.

Event başına maliyet de farklı. Aynı tür bir metin parçası bizim sözleşmemizde ortalama 37 byte (event: delta ve {"text":"For"}), AG-UI'da ortalama 101 byte; her TEXT_MESSAGE_CONTENT 32 karakterlik messageId'yi tekrar taşıyor. Tek cümlede fark önemsiz, uzun bir cevapta ya da çok kullanıcılı bir sistemde bant genişliği faturasına yansır.

Angular'da HttpAgent ile Tüketmek

@ag-ui/client'ın HttpAgent class'ı mesaj listesini, event sırasını ve isteği kendisi yönetiyor; bize subscriber callback'lerinden signal'lara yazmak kalıyor:

// 2. yazıdaki chat, AG-UI'ın HttpAgent'ıyla: mesaj listesini ve event sırasını library tutar
export class AgUiChat {
  private readonly agent = new HttpAgent({ url: '/agui' });
  protected readonly messages = signal<readonly Message[]>([]);
  protected readonly status = signal<RunStatus>('idle');
  protected readonly running = signal(false);
  protected readonly error = signal<string | null>(null);

  constructor() {
    // AG-UI event'leri subscriber callback'leriyle gelir; signal'lara yazmak yeterli
    const subscription = this.agent.subscribe({
      onMessagesChanged: ({ messages }) => this.messages.set(messages),
      onReasoningStartEvent: () => this.status.set('thinking'),
      onTextMessageStartEvent: () => this.status.set('streaming'),
      onRunErrorEvent: ({ event }) => {
        // abortRun'ı istemci kendisi code: 'abort' olan bir RUN_ERROR'a çeviriyor; durdurmak bir hata değil (3. yazı)
        if (event.code === 'abort') {
          this.status.set('idle');
          return;
        }
        this.status.set('error');
        this.error.set(event.message);
      },
    });
    inject(DestroyRef).onDestroy(() => subscription.unsubscribe());
  }

  protected async submit(event: Event, input: HTMLInputElement): Promise<void> {
    event.preventDefault();
    const text = input.value.trim();
    if (text === '' || this.running()) {
      return;
    }
    input.value = '';
    this.error.set(null);
    this.agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: text });
    this.running.set(true);
    try {
      await this.agent.runAgent();
      this.status.set('idle');
    } catch (err) {
      this.status.set('error');
      this.error.set(err instanceof Error ? err.message : String(err));
    } finally {
      this.running.set(false);
    }
  }

  protected stop(): void {
    this.agent.abortRun();
  }
}

onRunErrorEvent'teki code === 'abort' kontrolü ilk sürümde yoktu. Durdur butonunu Playwright ile 3. saniyede tıkladığımda ekrana şu geldi:

$ node run06.mjs stop
[requestfailed] /agui net::ERR_ABORTED
    155 ms Stop
    175 ms Thinking… | Stop
   3168 ms signal is aborted without reason | Send

runAgent() reject etmiyor; istemci iptali kendi ürettiği bir RUN_ERROR event'ine çeviriyor: {"type":"RUN_ERROR","message":"signal is aborted without reason","code":"abort"}. 3. yazıdaki kural (abort hata değildir) burada da geçerli, sadece ayırt etme yeri değişti. Düzeltmeden sonra kullanıcı yalnız "Send" butonunu geri görüyor ve sunucu log'u iptalin modele kadar gittiğini gösteriyor: AG-UI run closed: events=2, aborted=True.

Akış ortasında model hatasını X-Chaos: fail-after-5 header'ıyla zorladım:

$ node run06.mjs chaos
    188 ms Stop
    310 ms Thinking… | Stop
   6625 ms Stop
   6736 ms An error occurred while streaming the agent response. | Send
bubbles: [ 'Which is longer, Dune or The Hobbit? Answer in one sentence.', 'Dune is longer than' ]

# kablodaki son event
data: {"type":"RUN_ERROR","message":"An error occurred while streaming the agent response.","code":"StreamingError"}

Yarım cevap ekranda kalıyor, hata metni genel. Sunucu tarafında ise bir sürpriz var: SDK exception'ı yakalayıp RUN_ERROR'a çeviriyor ama log'a hiçbir şey yazmıyor. Bizim ToSseAsync aynı durumda "Model stream failed after 5 deltas" uyarısını exception'la birlikte yazıyordu. AG-UI endpoint'inde bu bilgiyi istiyorsan AsAGUIEventStreamAsync'ten önce update akışını kendin sarmalısın ya da SDK'nın AGUIServerInstrumentation activity source'unu tracing'e eklemelisin.

İki tasarım farkı daha var. Birincisi, AG-UI'da geçmişi istemci taşıyor. İkinci mesajda HttpAgent'ın gönderdiği gövde:

$ node run06.mjs twice
2nd request messages: [
  'user: Which is longer, Dune or The Hobbit? Ans',
  'assistant: Dune is longer than The Hobbit.',
  'user: And which one is cheaper?'
]

Bizim sözleşmemizde sunucu ConversationId ile geçmişi kendisi tutuyordu (.NET ile Yapay Zeka serisinin "Sohbet Geçmişini Saklamak: Stateless API, ConversationId, EF Core ve Chat Reducer'lar" yazısı). AG-UI'da istemcinin gönderdiği "assistant" mesajı, kullanıcının elle değiştirebileceği bir girdi; model geçmişine güvenmen gerekiyorsa sunucu threadId ile kendi kaydını tutmalı. Agent Framework'ün hosting'i bunu threadId üzerinden session saklayarak yapıyor.

İkincisi, AG-UI'da bizim ui event'imizin karşılığı yok. 5. yazıdaki generative UI'ı show_books'un TOOL_CALL_RESULT'ından türettim; sonuç aynı parseUiBlock doğrulamasından ve aynı @switch registry'sinden geçiyor:

// AG-UI'da ayrı bir "ui" event'i yok: show_books'un TOOL_CALL_RESULT'ı aynı registry'den geçip component olur
protected readonly blocks = computed(() => {
  const names = new Map(
    this.messages().flatMap((m) => (m.role === 'assistant' ? (m.toolCalls ?? []) : [])).map((c) => [c.id, c.function.name]),
  );
  const blocks = new Map<string, UiBlock>();
  for (const m of this.messages()) {
    if (m.role === 'tool' && names.get(m.toolCallId) === 'show_books') {
      const block = parseUiBlock('book-list', parseJson(m.content));
      if (block) {
        blocks.set(m.toolCallId, block);
      }
    }
  }
  return blocks;
});

"Recommend crime novels under 20 TL." isteğinde üç kart 12,6. saniyede geldi; model ikinci turda 32 saniye daha düşünüp tek cümle yazdı. Tool sonucunun tamamı (TOOL_CALL_RESULT.content) tarayıcıya gidiyor, yani bir tool'un sonucunda yalnız modele gösterilmesi gereken bir alan varsa onu da süzmen gerekiyor; bizim sözleşmemizde yalnız UiComponents listesindeki tool'ların sonucu dışarı çıkıyordu.

AI SDK UI: UI Message Stream'i ASP.NET Core'dan Üretmek

AI SDK'nın Angular paketi @ai-sdk/angular, bir Chat class'ı veriyor. Resmi başlangıç rehberi backend olarak streamText kullanan bir Node.js (Express) sunucusu öneriyor; .NET için hazır bir sunucu paketi yok. Protokol ise açık: her parça bir data: satırında type alanlı bir JSON, cevapta x-vercel-ai-ui-message-stream: v1 header'ı zorunlu, akış data: [DONE] ile bitiyor. Bunu minimal API ile kendim ürettim:

// Aynı asistan, AI SDK UI message stream (v1) formatıyla
public static void MapUiMessageStream(this WebApplication app, string systemPrompt)
{
    app.MapPost("/chat/ui-stream", IResult (UiChatRequest request, HttpContext http, IChatClient chat,
        ILogger<UiChatRequest> logger, CancellationToken ct) =>
    {
        List<ChatMessage> messages = [new(ChatRole.System, systemPrompt)];
        foreach (UiMessage m in request.Messages.Where(m => m.Role is "user" or "assistant"))
        {
            string text = string.Concat(m.Parts
                .Where(p => (string?)p["type"] == "text")
                .Select(p => (string?)p["text"]));
            messages.Add(new ChatMessage(m.Role == "user" ? ChatRole.User : ChatRole.Assistant, text));
        }

        // Bu header olmadan istemci akışı UI message stream olarak okumaz
        http.Response.Headers["x-vercel-ai-ui-message-stream"] = "v1";
        return TypedResults.ServerSentEvents(ToParts(chat.GetStreamingResponseAsync(messages, cancellationToken: ct), logger, ct));
    }).RequireRateLimiting("chat");
}

ToParts, start, start-step, text-start, text-delta, text-end, finish-step, finish parçalarını sırayla üretiyor, model hatasında {"type":"error","errorText":"..."} gönderiyor; 3. yazıdaki ToSseAsync ile aynı iskelet. curl çıktısı:

    70 ms  x-vercel-ai-ui-message-stream: v1
    70 ms  data: {"type":"start","messageId":"26326d63811541cd8670883d256e28ca"}
    70 ms  data: {"type":"start-step"}
  3264 ms  data: {"type":"text-start","id":"f321a51cc8534a89862718048292fdda"}
  3264 ms  data: {"type":"text-delta","id":"f321a51cc8534a89862718048292fdda","delta":"D"}
  3289 ms  data: {"type":"text-delta","id":"f321a51cc8534a89862718048292fdda","delta":"une"}
# ...
  3621 ms  data: {"type":"finish-step"}
  3621 ms  data: {"type":"finish"}
  3621 ms  data: [DONE]

Angular tarafı kısa:

// Aynı chat, AI SDK UI'ın Chat class'ıyla: messages, status ve error içeride signal
export class AiSdkChat {
  protected readonly chat = new Chat({
    transport: new DefaultChatTransport({ api: '/chat/ui-stream' }),
  });

  // Bir mesaj "parts" listesidir; burada yalnız metin parçalarını birleştiriyoruz
  protected textOf(message: UIMessage): string {
    return message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
  }
}
@for (message of chat.messages; track message.id) {
  <article class="bubble" [class.user]="message.role === 'user'">
    @if (message.role === 'assistant') {
      <app-message-body [text]="textOf(message)" [final]="chat.status === 'ready'" />
    } @else {
      <p class="text">{{ textOf(message) }}</p>
    }
  </article>
}

Template'te chat.messages() değil chat.messages yazılıyor. Paketin kaynağına baktım: Chat, state'ini AngularChatState adlı bir class'ta tutuyor ve messages, status, error getter'ları içerideki signal'ı okuyor. Getter bir signal okuduğu için zoneless ve OnPush bir component'te de template güncelleniyor. status değerleri submitted, streaming, ready ve error; 3. yazıdaki state machine'imizin done ve aborted ayrımı yok. Durdurunca boş bir asistan balonu kalıyor ve status doğrudan ready'e dönüyor, yani "kullanıcı durdurdu" bilgisini istiyorsan kendin tutmalısın. X-Chaos ile zorlanan hatada yarım cevap kaldı ve banner'da sunucunun errorText'i göründü. AI SDK de geçmişi istemcide tutuyor; ikinci istekte önceki asistan mesajı [step-start] parçasıyla birlikte geri geldi.

Bu endpoint'in maliyeti: yalnız metin için yaklaşık 90 satır. Tool çağrıları için tool-input-start, tool-input-available, tool-output-available parçalarını, onay için tool-approval-request ve tool-approval-response'u, düşünme için reasoning-* parçalarını eşlemen gerekiyor. Protokol AI SDK'nın kendi sürüm temposuyla değişiyor ve bu eşlemeyi güncel tutmak senin işin olur.

Bundle ve Kablo Maliyeti

Üç sayfa da lazy loading ile yükleniyor, yani ilk açılış bundle'ı (main, 262 kB) neredeyse değişmedi. Production build'in lazy chunk'ları:

$ npx ng build
Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-CTghVaZi.js   | -             | 409.76 kB |                73.98 kB
chunk-Tp78nDQq.js   | agui-chat     | 266.24 kB |                52.50 kB
chunk-Czhftraw.js   | -             | 119.54 kB |                36.92 kB
chunk-BkuuAqW6.js   | ai-sdk-chat   |  85.32 kB |                20.63 kB
chunk-DJ2MUyp0.js   | assistant     |  14.03 kB |                 4.55 kB

▲ [WARNING] Module '@vercel/oidc' used by 'node_modules/@ai-sdk/gateway/dist/index.js' is not ESM

--stats-json çıktısını paket paket topladığımda 409 kB'lık isimsiz chunk'ın tamamı zod çıktı; iki protokol library'si de mesaj ve event şemalarını zod ile tanımlıyor ve iki sayfa da bu chunk'ı yüklüyor. 119 kB'lık chunk 4. yazıdaki marked, DOMPurify ve highlight.js; üç sayfanın ortak maliyeti. AG-UI chunk'ının en büyük parçası 111 kB ile @ag-ui/proto: kullanmadığımız protobuf transport'u da bundle'a giriyor. Uyarı ise ai paketinin, kullanmadığımız gateway sağlayıcısını da import etmesinden geliyor.

SözleşmeSayfaya özel JS (raw / transfer)Delta başına byteGeçmişHeartbeat
Kendi SSE sözleşmemiz18 kB / 6,6 kB~37Sunucuda (ConversationId)Var (ping)
AG-UI (@ag-ui/client 1.0.4)679 kB / 128 kB~101İstemci gönderirYok, eklemen gerekir
AI SDK UI (@ai-sdk/angular 3.0.173)495 kB / 95 kB~83İstemci gönderirYok, eklemen gerekir

AI SDK satırındaki delta boyutu text-delta parçasının id alanı dahil hesaplandı. Lazy loading sayesinde bu maliyet yalnız asistan sayfasını açan kullanıcıya düşüyor, ama 128 kB transfer yavaş bir mobil bağlantıda bir saniyeden fazla demek.

Hangisini Seçmeli?

Bookstore için kararım şu: asistanın kendisi kendi sözleşmemizde kalıyor, iki protokol sayfası örnek olarak repoda duruyor. Gerekçe ölçümlerde: bizim sözleşmemiz en küçük bundle'ı, sunucuda tutulan geçmişi, heartbeat'i ve onay akışını zaten veriyor; tek dezavantajı, her yeni yetenek için event'i iki tarafta kendimizin eklemesi. Bu kararı değiştirecek durumlar:

  • Backend bir Agent Framework agent'ı ya da workflow'u olacaksa veya aynı agent'ı web, mobil ve başka istemcilere birden açacaksan AG-UI. Shared state (STATE_SNAPSHOT/STATE_DELTA), interrupt ile onay ve CopilotKit gibi hazır UI kütüphaneleri doğrudan destekleniyor; .NET tarafında Microsoft'un resmi SDK'sı var.
  • Backend Node.js'te ve zaten AI SDK ile model çağırıyorsan AI SDK UI. streamText ile protokol sunucuda tek satıra iniyor; bizim gibi .NET backend'de ise protokolü elle üretmek gerekiyor.
  • Ekip küçük, istemci tek ve backend .NET ise kendi sözleşmen. Event sözlüğünü dokümante et ve 1. yazıdaki gibi tek bir yerde (chat-events.ts ile ChatContracts.cs) tut.

Protokol seçimi geri dönüşü zor bir karar değil: üç sayfada da model, tool'lar, system prompt ve güvenli render aynı kaldı; değişen yalnız kablo formatı ve istemci tarafındaki event'leri state'e çeviren katman. Bu katmanı ince tutarsan sonradan değiştirmek bir günlük iş.

Üretimde Dikkat

Protokol paketlerinin sürümünü sabitle. @ai-sdk/angular yazım günü 3.0.173 sürümündeydi ve npm'de son güncellemesi bir önceki geceydi; @ag-ui/client 1.0.4. ^ ile bir minor güncelleme event şemasını ya da varsayılan bir davranışı değiştirebilir. package.json'da tam sürüm ve lock dosyası, güncellemeyi de bilinçli bir pull request ile yap.

Varsayılanları ölç. AG-UI dönüşümünün düşünme metnini ve ham sağlayıcı update'lerini aktarması dokümanda geçen bir davranış değil, kabloda gördüğüm bir davranış. Bir protokol library'si eklediğinde ilk iş, gerçek bir cevabın kablodaki hâline bakmak olmalı.

Sunucu log'unu library'ye bırakma. RUN_ERROR istemciye gitti ama exception hiçbir yere yazılmadı. Akışı saran kendi katmanında hata, iptal ve event sayısını log'a yaz.

İstemcinin gönderdiği geçmişe güvenme. AG-UI ve AI SDK UI'da istemci tüm konuşmayı her istekte gönderiyor; bir saldırgan "assistant" rolüyle istediği mesajı geçmişe ekleyebilir. Geçmiş sunucuda tutulmalı ya da en azından imzalanmalı; prompt injection tarafını LLM Uygulama Güvenliği serisi ele alıyor.

Sık Düşülen Tuzaklar

Birincisi, protokol SDK'sının çıktısını süzmeden tarayıcıya vermek. Ölçümde varsayılan ayarlar düşünme metnini ve onunla birlikte system prompt'un özetini 368 KB'lık bir akışla tarayıcıya taşıdı.

İkincisi, düşünme metnini kesip heartbeat'i unutmak. Sessiz kalan 96 saniye, arada idle timeout'u olan her proxy için kopuk bir bağlantı demek.

Üçüncüsü, abort'u hata olarak göstermek. HttpAgent iptali code: 'abort' olan bir RUN_ERROR'a çeviriyor; kontrol etmezsen kullanıcı "signal is aborted without reason" okuyor.

Dördüncüsü, bundle maliyetine bakmadan protokol library'si eklemek. 18 kB'lık bir chat sayfası, iki import satırıyla 679 kB'a çıktı.

Beşincisi, bir protokol dokümanındaki örnek backend'i production mimarisi sanmak. AI SDK rehberindeki Express sunucusu bir başlangıç noktası; .NET backend'de aynı protokolü üretmek senin bakımını yapacağın bir kod.

Küçük Bir Deneme

★☆☆ /agui endpoint'ine bir heartbeat ekle: kaynak 5 saniye sessiz kalırsa CUSTOM tipinde, adı ping olan bir event gönder. HttpAgent'ın onCustomEvent callback'inde bunu gör ve 3. yazıdaki sessizlik zamanlayıcısını AG-UI sayfasına taşı.

★★☆ /chat/ui-stream'e show_books tool'unu ekle: tool-input-available ve tool-output-available parçalarını üret, Angular tarafında tool-show_books tipli parçayı parseUiBlock'tan geçirip app-generative-block ile göster.

★★★ cancel_order onay akışını AG-UI'a taşı: sunucuda ToolApprovalRequestContent'i AGUIStreamOptions.MapInterrupt ile bir interrupt'a çevir, istemcide pendingInterrupts'tan onay kartını göster ve kararı runAgent({ resume: [...] }) ile gönder. Süre dolumu (expiresAt) ve tek kullanımlık onay kuralı hangi tarafta kalıyor?

Kablo formatı ne olursa olsun, kullanıcının ekranda gördüğü aynı: bekleme, akan metin, kartlar ve hatalar. Bir sonraki yazıda bu ekranı herkes için kullanılabilir yapıyoruz: bekleme hissi, ekran okuyucuya her token'ı okutmadan cevabı duyurmak, odak ve klavye, kullanım bilgisi, "AI tarafından üretildi" etiketi ve beğen/beğenme geri bildirimi.