Tantangan Streaming Payload LLM ke UI Agent

Mengintegrasikan keluaran Large Language Model (LLM) langsung ke antarmuka aplikasi interaktif mengharuskan sistem mengurai data terstruktur di tengah aliran teks (streaming). Arsitektur seperti CopilotKit OpenTag menyematkan tag khusus (misalnya <action name="...">{...}</action>) langsung di dalam aliran teks respons. Namun, antarmuka front-end rentan rusak akibat tiga masalah teknis mendasar:

  • Payload Parsial: UI mencoba mengurai JSON yang belum selesai ditransmisikan, memicu SyntaxError: Unexpected end of JSON input.
  • Schema Mismatch Runtime: Model menghasilkan struktur argumen yang menyimpang dari kontrak API yang diharapkan oleh komponen front-end.
  • Eksekusi Ganda: Aliran teks yang memicu re-render React berulang kali dapat mengeksekusi efek samping (seperti mutasi data atau pemanggilan API eksternal) lebih dari sekali.

Solusinya adalah menerapkan state machine parsing inkremental, validasi runtime via Zod, dan manajemen cache status eksekusi berbasis ID unik.

Definisi Kontrak Schema Type-Safe dengan Zod

Front-end dan LLM harus menyepakati kontrak payload yang ketat. Gunakan Zod untuk mendefinisikan skema validasi runtime sekaligus mengekstrak tipe statis TypeScript secara otomatis.

import { z } from 'zod';

export const ShowProductModalSchema = z.object({
  actionId: z.string().uuid(),
  productId: z.string().min(1),
  viewMode: z.enum(['compact', 'detailed']).default('compact'),
  metadata: z.record(z.string()).optional(),
});

export const ActionTagPayloadSchema = z.discriminatedUnion('actionName', [
  z.object({
    actionName: z.literal('show_product_modal'),
    payload: ShowProductModalSchema,
  }),
  z.object({
    actionName: z.literal('navigate_route'),
    payload: z.object({
      actionId: z.string().uuid(),
      path: z.string().startsWith('/'),
    }),
  }),
]);

export type ActionTagPayload = z.infer<typeof ActionTagPayloadSchema>;

Parsing Streaming Tag dan Penanganan JSON Parsial

Selama streaming LLM berlangsung, buffer teks perlu dipindai untuk mendeteksi batas tag pembuka dan penutup. Jika tag penutup belum diterima atau JSON di dalamnya belum lengkap, parser harus menahan eksekusi atau merender status skeleton.

interface ParsedOpenTag {
  actionName: string;
  rawJson: string;
  isComplete: boolean;
}

export function extractOpenTags(streamBuffer: string): ParsedOpenTag[] {
  const tagRegex = /<action\s+name=["']([^"']+)["']>([\s\S]*?)(<\/action>|$)/g;
  const results: ParsedOpenTag[] = [];
  let match: RegExpExecArray | null;

  while ((match = tagRegex.exec(streamBuffer)) !== null) {
    const [, actionName, innerContent, closingTag] = match;
    results.push({
      actionName,
      rawJson: innerContent.trim(),
      isComplete: closingTag === '</action>',
    });
  }

  return results;
}

Runtime Validator dan Idempotensi Eksekusi Side-Effect

Setelah tag berstatus isComplete: true, lakukan parsing JSON yang aman dan validasi skema runtime. Untuk mencegah eksekusi ganda pada setiap chunk token baru, lacak actionId menggunakan Set atau state persistensi.

const executedActions = new Set<string>();

export function processActionStream(rawTag: ParsedOpenTag): void {
  if (!rawTag.isComplete) {
    return; // Tunggu stream tag selesai sepenuhnya
  }

  let parsedJson: unknown;
  try {
    parsedJson = JSON.parse(rawTag.rawJson);
  } catch {
    console.error(`Malformed JSON inside tag <action name="${rawTag.actionName}">`);
    return;
  }

  const validationResult = ActionTagPayloadSchema.safeParse({
    actionName: rawTag.actionName,
    payload: parsedJson,
  });

  if (!validationResult.success) {
    console.warn('Payload contract mismatch:', validationResult.error.flatten());
    return;
  }

  const { actionName, payload } = validationResult.data;

  // Cek Idempotensi
  if (executedActions.has(payload.actionId)) {
    return;
  }

  executedActions.add(payload.actionId);
  dispatchActionToUI(actionName, payload);
}

function dispatchActionToUI(name: string, payload: unknown) {
  window.dispatchEvent(new CustomEvent('agent-action', { detail: { name, payload } }));
}

Fallback Handling Saat Tag Rusak di Tengah Stream

LLM terkadang berhenti menghasilkan token sebelum tag tertutup secara valid akibat max_tokens terlampaui atau gangguan koneksi HTTP chunked transfer. Penanganan kesalahan harus mencakup tiga lapisan proteksi:

  1. Stream Abort Detection: Jika stream selesai (EOF) tetapi tag tetap tidak memiliki penutup, parser harus mengabaikan tag tersebut atau memindahkan teks ke dalam alur pesan teks biasa agar UI tidak hang.
  2. Partial JSON Recovery: Gunakan library deserialisasi toleran (seperti dirty-json atau modul partial-json parser) hanya jika UI memerlukan tampilan spekulatif sebelum stream selesai.
  3. Schema Validation Error Boundary: Tangkap error parsing pada UI component boundary. Jika skema gagal divalidasi, tampilkan pesan fallback non-intrusif ke pengguna alih-alih merusak seluruh visual tree React.