Skip to content

@templatical/ai-kit ​

bash
npm install @templatical/ai-kit
bash
pnpm add @templatical/ai-kit
bash
yarn add @templatical/ai-kit
bash
bun add @templatical/ai-kit

MIT, ohne LLM-SDK und ohne reine Node-API: Web Streams und fetch, sodass derselbe Code in Node, Deno, Bun, Cloudflare Workers und Browsern läuft.

Anfrage-, Ergebnis- und Verlaufsfelder ​

Die vollständigen Provider-Deklarationen aus @templatical/types enthalten Anfragen, Ergebnisse, Handler und Verlauf einschließlich AiChatAttachment und AiAltTextRequest. conversation.list liefert Nachrichten vom ältesten zum neuesten Eintrag; createdAt ist ISO 8601. batches ist für Benutzernachrichten leer; revision ist für editorseitige Batches null, für serverseitige Batches eine Zahl. Nur Benutzernachrichten haben optionale Anhang-Metadaten, keine Dateien.

ts
import type {
  SyntaxPresetName,
  Operation,
  OperationErrorCode,
  TemplateContent,
} from "@templatical/types";

export type AiMergeTagSyntax = SyntaxPresetName | "custom";

export interface AiMergeTag {
  value: string;
  label: string;
  group?: string;
  description?: string;
}

export interface AiMergeTags {
  syntax: AiMergeTagSyntax;
  tags: AiMergeTag[];
}

export interface AiRequestBase {
  templateId: string;
  /**
   * The template as the editor has it, after it sent its pending edits. A
   * backend that holds the template itself (Templatical Cloud) ignores it.
   */
  content: TemplateContent;
  /** The collaboration revision `content` is at, or `null` without collaboration. */
  revision: number | null;
  /** The editor session (`X-Templatical-Session` on the wire). */
  sessionId: string;
  /** The editor syntax and declared tags; `tags` is empty when none are declared. */
  mergeTags: AiMergeTags;
}

export interface AiChatAttachment {
  file: Blob;
  kind: "image" | "pdf";
  name: string;
}

export interface AiChatRequest extends AiRequestBase {
  /** 1–4,000 characters. */
  message: string;
  /** The selected block, when the backend has it; `null` otherwise. */
  selectedBlockId: string | null;
  /** Always present; at most 4 files, each at most 5 MB, at most 10 MB together. */
  attachments: AiChatAttachment[];
}

export interface AiFixFinding {
  /** Non-empty lint rule id, at most 200 characters. */
  ruleId: string;
  /** The problem, at most 1,000 code points. */
  message: string;
  /** The linter's suggested action, at most 1,000 code points. */
  suggestion: string;
  /** Rule docs URL: http(s), at most 2,048 characters; never fetched by the model. */
  reference: string;
  /** Block id (at most 200 characters), or null for a template-level finding. */
  blockId: string | null;
  /** Diagnostic excerpt when supplied by the rule, at most 1,000 code points. */
  excerpt?: string;
}

export interface AiFixRequest extends AiRequestBase {
  finding: AiFixFinding;
}

export type AiRewritePreset =
  "shorten" | "expand" | "friendlier" | "formal" | "fixGrammar" | "translate";

export interface AiRewriteRequest extends AiRequestBase {
  /**
   * The paragraph's HTML (1–20,000 characters), merge tags as the editor's
   * tags (`<span data-merge-tag="{{first_name}}" …>`).
   */
  html: string;
  preset: AiRewritePreset | null;
  /** A BCP 47 tag; set when `preset` is `translate`. */
  language: string | null;
  /** ≤ 500 characters. At least one of `preset` and `instruction` is set. */
  instruction: string | null;
}

export interface AiAltTextRequest {
  templateId: string;
  sessionId: string;
  blockId: string;
  image: { url: string };
  currentAlt: string | null;
  linkHref: string | null;
  /** Plain text, at most 2,000 code points. */
  nearbyText: string;
  locale: string;
  /** Effective lint altMaxLength: integer 1–1,000, counted as string.length. */
  maxLength: number;
}

export interface AiAppliedBatch {
  batchId: string;
  revision: number;
  operationCount: number;
}

export type AiOperationsResult =
  | { applied: true; inverse: Operation[] }
  | {
      applied: false;
      code: OperationErrorCode;
      opIndex: number;
      message: string;
    };

export interface AiTurnHandlers {
  onText(text: string): void;
  onOperations(ops: Operation[]): Promise<AiOperationsResult>;
  onApplied(batch: AiAppliedBatch): void;
}

export interface AiRewriteHandlers {
  /** A chunk of the rewritten text, for a live preview. */
  onText(text: string): void;
}

export interface AiTurnResult {
  /** The assistant message's id in `conversation`, or `null` without history. */
  messageId: string | null;
  /** The whole reply. */
  text: string;
}

export type AiReplay =
  | { operations: Operation[]; replayId: string }
  | { applied: { batchId: string; revision: number } };

export type AiMessageKind = "chat" | "fix";

export interface AiMessageBatch {
  batchId: string;
  revision: number | null;
  operationCount: number;
}

export interface AiChatMessage {
  id: string;
  role: "user" | "assistant";
  kind: AiMessageKind;
  /** The author's message, a fix's "Fix: {message}", or the assistant's reply. */
  text: string;
  /** Reference metadata on user messages only; file retention is the backend's choice. */
  attachments?: { name: string; kind: "image" | "pdf" }[];
  /** What the assistant's turn applied; empty for user messages. */
  batches: AiMessageBatch[];
  /** `true` after Undo, `false` after Redo. */
  undone: boolean;
  /** ISO 8601. */
  createdAt: string;
}

export interface AiConversationProvider {
  /** Settle an editor-applied replay, idempotently, after applying or refusing it. */
  settleReplay(
    templateId: string,
    messageId: string,
    outcome: AiReplayOutcome,
  ): Promise<void>;
  /** Oldest first. */
  list(templateId: string): Promise<AiChatMessage[]>;
  /** New chat; `false` hides the button. */
  clear: false | ((templateId: string) => Promise<void>);
  undo:
    | false
    | ((
        templateId: string,
        messageId: string,
        context: AiReplayContext,
      ) => Promise<AiReplay>);
  redo:
    | false
    | ((
        templateId: string,
        messageId: string,
        context: AiReplayContext,
      ) => Promise<AiReplay>);
}

export interface AiReplayOutcome {
  replayId: string;
  direction: "undo" | "redo";
  applied: boolean;
  sessionId: string;
}

export interface AiReplayContext {
  sessionId: string;
}

export type AiErrorCode =
  | "feature_unavailable"
  | "limit_reached"
  | "rate_limited"
  | "ai_turn_running"
  | "content_too_large"
  | "ai_output_invalid"
  | "ai_provider_error";

export interface AiLimitDetails extends Readonly<Record<string, unknown>> {
  /** Which allowance ran out, in the backend's words (`aiMonthly`). */
  limit: string;
  /** ISO 8601: when it renews. */
  resetsAt: string;
}

export interface AiProvider {
  chat:
    | false
    | ((
        request: AiChatRequest,
        on: AiTurnHandlers,
        signal: AbortSignal,
      ) => Promise<AiTurnResult>);
  fixFinding:
    | false
    | ((
        request: AiFixRequest,
        on: AiTurnHandlers,
        signal: AbortSignal,
      ) => Promise<AiTurnResult>);
  /** True means chat accepts attachments; has no effect while chat is false. */
  chatAttachments: false | true;
  rewrite:
    | false
    | ((
        request: AiRewriteRequest,
        on: AiRewriteHandlers,
        signal: AbortSignal,
      ) => Promise<{ html: string }>);
  altText:
    | false
    | ((
        request: AiAltTextRequest,
        signal: AbortSignal,
      ) => Promise<{ alt: string }>);
  conversation?: AiConversationProvider;
}

export type AiFeatureName = Exclude<keyof AiProvider, "conversation">;

Anfragelimits ​

FeldGrenze und Darstellung
contentSerialisiertes JSON mit höchstens 200.000 UTF-8-Bytes; kein Beleg für Schema-Validität
revisionNicht negative Ganzzahl oder null ohne Zusammenarbeit
message1–4.000 Codepoints, nicht nur Leerraum
selectedBlockId, sessionIdHöchstens 200 Codepoints; Auswahl kann null sein
mergeTagsErforderliches { syntax, tags }; tags: [], wenn keine deklariert sind; höchstens 500 Tags; Syntax liquid, handlebars, mailchimp, ampscript oder custom
Merge-Tag-Feldervalue ≤ 200, label ≤ 100, optional group ≤ 100 und description ≤ 200 Codepoints; keine Samples oder Callbacks
Rewritehtml 1–20.000 Codepoints; instruction ≤ 500; mindestens preset oder instruction; language bei translate erforderlich passend zu /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/
Fix-BefundLint-Fixes nennt alle Feldlimits und ausgeschlossenen Felder
Alt-TextAlt-Text nennt URL-, Kontext-, Locale- und Ausgabelimits
AnhängeChat-Anhänge nennt MIME-Typen, Dateinamen- und Bytegrenzen
Operationsbatch1–100 Operationen in Reihenfolge; die Batches eines Durchlaufs bilden einen Rückgängig-Schritt

mergeTags enthält immer die Syntax des Editors (standardmäßig liquid), auch bei leerer Tag-Liste. readAiRequest lehnt fehlenden oder null-Kontext mit Status 422 und der Meldung mergeTags must be { syntax, tags } with at most 500 tags. ab. Die Editing-Session und checkRewrite lehnen erfundene Tags auch bei leerer deklarierter Liste ab; bereits in der Eingabe vorhandene Tags bleiben erlaubt. Die Syntax custom wird nicht geprüft, da ihre Muster nicht an das Backend gesendet werden.

Client ​

ts
import { createHttpAiProvider } from '@templatical/ai-kit/client';

const ai = createHttpAiProvider({
  baseUrl: '/api/ai',
  credentials: 'include',
  features: { chatAttachments: false, conversation: false },
});
Option
baseUrlWo die Routen des Protokolls liegen
headersIhre Auth-Header oder eine Funktion (für eine Token-Erneuerung auch asynchron), die pro Anfrage aufgerufen wird
credentialsDie credentials von fetch für Cookie-Authentifizierung
featuresfalse schaltet ein Mitglied aus, auch conversation

Der Provider liest den Event-Stream und lehnt mit SdkError ab (ThrottledError bei 429, CollaborationConflict bei einer verweigerten Operation). Da ein Stream nur in eine Richtung läuft, beendet er einen Durchlauf, dessen Operationen der Editor verweigert hat, durch Abbruch der Anfrage: Der Durchlauf lehnt mit dem Code dieser Operation und „The template changed while the AI was working.“ ab.

Server ​

ts
import {
  aiErrorResponse,
  aiEventStream,
  applyOperationsTool,
  buildChatRequest,
  checkRewrite,
  createEditingSession,
  editingInstructions,
  readAiRequest,
  runEditingTurn,
} from '@templatical/ai-kit/server';
Export
editingInstructionsDer System-Prompt zum Bearbeiten: die Regeln, die Operationen und das Template-Schema
rewriteInstructionsDie anderen Prompts
buildEditingPrompt, buildChatRequest, buildFixRequest, buildRewritePrompt, buildAltTextRequestPrompts pro Anfrage
applyOperationsToolDas einzige Tool: apply_operations, dessen ops ein JSON-String ist
createEditingSessionDie Arbeitskopie eines Durchlaufs: prüft jeden Aufruf (Form, die Grenze von 100 Operationen, keine neuen Custom-, Slot- oder Wrapper-Blöcke, deklarierte Merge-Tags, die Engine, eine Größenbegrenzung) und antwortet mit einem Satz, auf den das Modell reagieren kann
runEditingTurnDie Referenzschleife über einen AiModelAdapter, höchstens drei Modellaufrufe
aiEventStreamDie Event-Stream-Response für jeden Fetch-API-Server
aiErrorResponse, aiJsonResponseFehler vor dem Stream und JSON-Antworten, mit dem Header des Protokolls
readAiRequestEine Anfrage des Protokolls, eingelesen und geprüft
checkRewrite, buildRewriteRepairPromptPrüfungen der Ausgabe
checkAltTextAnswer, buildAltTextRepairRequest, checkAltTextAlt-Text-Prüfung und eine Reparaturanfrage

Das Kit enthält keine HTML-Bereinigung. Der Editor bereinigt die Vorschau einer Umformulierung und das mit „Replace“ übernommene HTML. HTML-Blöcke, die die KI schreibt, behalten ihr Markup unverändert; befolgen Sie die Hinweise zur serverseitigen Bereinigung vor dem Speichern oder Versenden.

Lint-Fixes ​

AI_FIXABLE_RULES aus @templatical/editor enthält genau die unten aufgeführten Regeln. AI_ALT_TEXT_RULES enthält genau a11y.img-missing-alt, a11y.img-alt-is-filename, a11y.img-alt-too-long und a11y.img-linked-no-context. Beide Aktionen benötigen @templatical/quality, aktiviertes Lint und ein geladenes Template. Die kanonischen Kategorien, Kataloge und Optionen stehen unter Barrierefreiheit, Accessibility-Regelkatalog, Zustellbarkeit, Zustellbarkeits-Regelkatalog und Optionen.

fixFinding erhält die JSON-serialisierbare Lint-Projektion finding: { ruleId, message, suggestion, reference, blockId, excerpt? }; severity, clients und die lokale fix-Funktion gehören nicht dazu. Die Regeln und ihre Befunde sind unter Qualität dokumentiert. Der Editor bietet Fixes für diese Regel-IDs an:

  • a11y.link-vague-text
  • a11y.button-vague-label
  • a11y.link-empty
  • a11y.text-all-caps
  • a11y.missing-preheader
  • deliverability.link-text-domain-mismatch
  • deliverability.low-text

buildFixRequest(finding) füllt prompts/fix.md mit Diagnosedaten und der Anleitung aus prompts/fix-rules.json, exportiert als FIX_RULE_GUIDANCE aus @templatical/ai-kit/server. Unbekannte Regeln erhalten Change only what the finding names.. Alle Befundfelder sind Daten, keine Anweisungen; das Modell darf reference nicht abrufen. Bei Domain-Abweichungen bleibt das href unverändert, und bei zu wenig Text werden nur vorhandene Fakten als Text im E-Mail-Inhalt ergänzt.

readAiRequest lehnt ungültige Felder mit SdkError (Status 422, kein code) ab und kürzt nie: ruleId ist nicht leer und höchstens 200 Zeichen lang; message, suggestion und das optionale excerpt haben höchstens 1.000 Unicode-Codepoints; reference ist eine http:- oder https:-URL mit höchstens 2.048 Zeichen; blockId ist null oder eine Zeichenkette mit höchstens 200 Zeichen. Die Limits für IDs und URLs zählen UTF-16-Einheiten (string.length).

Prüfen Sie Ihren Fix-Endpunkt mit runAiWireConformance({ baseUrl }) aus dem folgenden Beispiel; auch runAiProviderConformance(provider) sendet einen Lint-Befund an fixFinding.

Alt-Text ​

buildAltTextRequest(request: AiAltTextRequest) liefert { system: string; messages: AiModelMessage[] } mit dem Bild als image-url-Anhang der Nutzernachricht. prompts/alt-text.md verlangt den Zweck des Bildes in dieser E-Mail, Ziel oder Aktion verlinkter Bilder, sichtbaren Bildtext, die Sprache des Templates, Klartext und das maxLength der Anfrage. Umgebender Text und vorhandener Alt-Text sind Daten, keine Anweisungen.

checkAltTextAnswer prüft eine Antwort ohne Ausnahme und liefert { ok: true, alt } oder { ok: false, reason }. checkAltText ist die abschließende Prüfung und wirft bei einer ungültigen Antwort SdkError(AI_ERROR_MESSAGES.ai_output_invalid, 502, "ai_output_invalid").

ts
import type { AiModelMessage } from "@templatical/ai-kit/server";

export declare function checkAltTextAnswer(
  answer: string,
  { maxLength }: { maxLength: number },
): { ok: true; alt: string } | { ok: false; reason: string };

export declare function buildAltTextRepairRequest(
  built: { system: string; messages: AiModelMessage[] },
  answer: string,
  reason: string,
  maxLength: number,
): { system: string; messages: AiModelMessage[] };

export declare function checkAltText(
  answer: string,
  { maxLength }: { maxLength: number },
): string;

Die Normalisierung entfernt äußere Leerzeichen, ein umschließendes Paar einfacher oder doppelter Anführungszeichen und ein führendes „Image of“ oder „Picture of“ unabhängig von Groß-/Kleinschreibung, dann erneut äußere Leerzeichen. Nach dem Entfernen äußerer Leerzeichen sind Zeilenumbrüche unzulässig, auch wenn der Text kurz ist oder die Präfix-Normalisierung den Umbruch entfernen würde. Die Länge der normalisierten Zeichenkette zählt UTF-16-Einheiten (alt.length). Die Prüfung liefert den ersten zutreffenden Grund in dieser Reihenfolge:

Bedingungreason (unverändert)
Nach der Normalisierung leerYour answer was empty.
Enthält einen Zeilenumbruch (CR, LF, U+2028 oder U+2029)Your answer had several lines.
Enthält HTML (/<[a-z]/i)Your answer contained HTML.
Länger als maxLength`Your answer was ${alt.length} characters; the limit is ${maxLength}.`

buildAltTextRepairRequest(built, answer, reason, maxLength) übernimmt denselben System-Prompt und die ursprünglichen Nachrichten einschließlich des Bildanhangs. Danach folgen { role: "assistant", content: answer } und eine Nutzernachricht mit dem Inhalt `${reason} Reply again with only the alt text: one line of plain text, at most ${maxLength} characters.`. Übergeben Sie maxLength aus der ursprünglichen Anfrage als vierten Parameter. Die Funktion verändert built nicht und ruft kein Modell auf.

Der Server im Leitfaden und evalAltTextWith prüfen die erste Antwort mit checkAltTextAnswer. Bei Ablehnung senden sie genau eine Reparaturanfrage mit buildAltTextRepairRequest und prüfen danach mit checkAltText. Eine zweite ungültige Antwort löst ai_output_invalid aus; es gibt keinen dritten Modellaufruf. Prüfen Sie Ihren Endpunkt mit runAiWireConformance({ baseUrl }) aus dem folgenden Beispiel.

readAiRequest validiert die Route altText ohne Kürzung: blockId ist nicht leer und höchstens 200 Zeichen lang; image.url ist eine mit new URL() geparste http:- oder https:-URL mit höchstens 2.048 Zeichen; currentAlt und linkHref sind Zeichenketten mit höchstens 2.048 Zeichen oder null; nearbyText hat höchstens 2.000 Codepoints; locale hat höchstens 35 Zeichen und entspricht /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/; maxLength ist eine Ganzzahl von 1 bis 1.000. Ungültige Felder lösen SdkError(message, 422) ohne Code aus. Alle Zeichenlimits außer nearbyText zählen UTF-16-Einheiten.

Der Editor liefert seinen wirksamen Lint-Wert altMaxLength, Standard 125. Prüfen Sie die Antwort immer mit dem Anfragewert. Der Modellanbieter ruft image.url ab; Ihr Backend autorisiert Template und Block und löst private oder signierte Medien auf. Alt-Text im Editor beschreibt die Prüfung und das Flag decorative.

Die Union der Modellanhänge lautet:

ts
export type AiModelAttachment =
  | { kind: "image" | "pdf"; mediaType: string; data: Blob }
  | { kind: "image-url"; url: string };

Modell-Adapter ​

runEditingTurn ruft Ihr Modell über eine Methode auf:

ts
import type { AiToolDefinition } from "@templatical/ai-kit/server";

/** A design file or an image URL attached to a user message. */
export type AiModelAttachment =
  | { kind: "image" | "pdf"; mediaType: string; data: Blob }
  | { kind: "image-url"; url: string };

export interface AiModelToolCall {
  id: string;
  name: string;
  /** The tool's arguments: a JSON string of the arguments object, or the object. */
  input: string | Record<string, unknown>;
}

export type AiModelMessage =
  | { role: "user"; content: string; attachments?: AiModelAttachment[] }
  | { role: "assistant"; content: string; toolCalls?: AiModelToolCall[] }
  | { role: "tool"; toolCallId: string; name: string; content: string };

export type AiModelPart =
  | { type: "text"; text: string }
  | ({ type: "tool-call" } & AiModelToolCall)
  | { type: "usage"; inputTokens: number; outputTokens: number };

export interface AiUsage {
  inputTokens: number;
  outputTokens: number;
}

/**
 * Your model, behind one method: stream a reply to `messages` with `system` as
 * the system prompt and `tools` offered. About twenty lines for the Vercel AI
 * SDK, OpenAI or Anthropic (see the guide). Honour `signal`.
 */
export interface AiModelAdapter {
  stream(input: {
    system: string;
    messages: AiModelMessage[];
    tools: AiToolDefinition[];
    signal?: AbortSignal;
  }): AsyncIterable<AiModelPart>;
}

Eigenes KI-Backend anbinden enthält Adapter für das Vercel AI SDK, OpenAI und Anthropic. Alle Beispiele unterstützen Chat, Fix, Umformulieren und Alt-Text sowie Bild-URLs für Alt-Text. Nur das Anthropic-Beispiel akzeptiert Bild- und PDF-Blobs im Chat. Die Client-Konfigurationen für OpenAI und Vercel setzen chatAttachments: false und schalten den gespeicherten Gesprächsverlauf aus.

Chat-Anhänge ​

AiChatRequest.attachments ist immer vorhanden: [] ohne Dateien, sonst { file: Blob, kind: "image" | "pdf", name: string }[]. AiProvider.chatAttachments: false | true ist ein Flag: true bedeutet, dass die chat-Funktion Anhänge akzeptiert; bei chat: false hat es keine Wirkung. Der HTTP-Provider aktiviert es standardmäßig bei aktivem Chat; features: { chatAttachments: false } deaktiviert es.

Ohne Anhänge bleibt der JSON-Body unverändert. Mit Anhängen benennt AI_GENERATE_FIELDS die Felder message, selectedBlockId, mergeTags, content, revision und wiederholte attachment-Dateiteile. message und selectedBlockId sind Text; mergeTags und content sind JSON-Strings; revision ist eine Zahl als Text. Fehlende selectedBlockId und revision werden null. Multipart ohne Dateiteile ist gültig. JSON-Bodys ergeben immer attachments: [].

readAiRequest akzeptiert die MIME-Typen image/png, image/jpeg, image/webp, image/gif, application/pdf. Es lehnt ohne Kürzung mit Status 422 und ohne Code in dieser Reihenfolge ab:

BedingungNachricht
Formular nicht lesbarSend the message as multipart form data.
Mehr als 4 DateienAttach at most 4 files.
Kein Dateiteil oder nicht unterstützter MIME-TypAttach PNG, JPEG, WebP or GIF images, or PDFs.
Eine Datei über 5 * 1024 * 1024 ByteEach attachment must be 5 MB or smaller.
Zusammen über 10 * 1024 * 1024 ByteAttachments must be 10 MB or smaller together.
Dateiname über 255 UTF-16-EinheitenAn attachment's file name must be at most 255 characters.

buildChatRequest(message: string, options?: { hasAttachments?: boolean }) lässt die reine Textanfrage standardmäßig unverändert; mit hasAttachments: true hängt es eine Leerzeile und prompts/chat-attachments.md an. Übergeben Sie die eingelesenen Dateien als Modellanhänge { kind, mediaType: file.type, data: file }, wie im Leitfaden. Der Prompt nutzt Referenzen für die angeforderte Änderung und ersetzt das ganze Template nur bei leerem Inhalt oder ausdrücklicher Aufforderung; Bilder werden Platzhalter.

Die Konformitätssuiten senden bei aktiven Chat-Anhängen ein 1×1-PNG; die Protokollsuite erwartet außerdem 422 bei fünf Anhängen. Der Verlauf speichert nur { name, kind } im optionalen attachments der Benutzernachricht; die Dateiaufbewahrung bestimmt das Backend.

Testen ​

Beide Signaturen, alle Optionen und das gemeinsame Ergebnis:

ts
import type {
  AiFeatureName,
  AiMergeTags,
  AiProvider,
  TemplateContent,
} from "@templatical/types";
export type HttpAiFeatures = Partial<
  Record<AiFeatureName | "conversation", boolean>
>;

export interface AiConformanceCheck {
  name: string;
  ok: boolean;
  detail: string;
}

export interface AiConformanceReport {
  ok: boolean;
  checks: AiConformanceCheck[];
}

export interface AiConformanceOptions {
  /** The template the suite edits; the test set's welcome email by default. */
  content?: TemplateContent;
  templateId?: string;
  /** Public probe image URL; defaults to AI_CONFORMANCE_ALT_TEXT_IMAGE_URL. */
  altTextImageUrl?: string;
  mergeTags?: AiMergeTags;
  /** How long a stopped call may take to reject (D35). */
  timeoutMs?: number;
  /** An optional shorter call limit; every call is also bounded by `timeoutMs`. */
  callTimeoutMs?: number;
}

export interface AiWireConformanceOptions {
  baseUrl: string;
  features?: HttpAiFeatures;
  headers?:
    | Record<string, string>
    | (() => Record<string, string> | Promise<Record<string, string>>);
  templateId?: string;
  /** Defaults to AI_CONFORMANCE_ALT_TEXT_IMAGE_URL. */
  altTextImageUrl?: string;
  timeoutMs?: number;
  callTimeoutMs?: number;
}

export declare const AI_CONFORMANCE_ALT_TEXT_IMAGE_URL: string;

export declare function runAiProviderConformance(
  provider: AiProvider,
  options?: AiConformanceOptions,
): Promise<AiConformanceReport>;

export declare function runAiWireConformance(
  options: AiWireConformanceOptions,
): Promise<AiConformanceReport>;

Die Provider-Prüfung verwendet die aktivierten Provider-Funktionen. headers und features gehören zur Wire-Prüfung; fehlende Flags aktivieren alle Funktionen. Standardwerte: templateId: "conformance", content: AI_EVAL_TEMPLATES.welcome, mergeTags: AI_EVAL_MERGE_TAGS, timeoutMs: 5000 ms. callTimeoutMs kann timeoutMs nicht überschreiten.

altTextImageUrl verwendet standardmäßig AI_CONFORMANCE_ALT_TEXT_IMAGE_URL aus @templatical/ai-kit/testing: https://raw.githubusercontent.com/templatical/sdk/v${version}/packages/ai-kit/eval/images/probe.png (version ist die installierte Kit-Version). Das Bild zeigt eine petrolfarbene Tasse ohne Text. Übergeben Sie bei unveröffentlichten Builds mit altTextImageUrl ein öffentliches Bild, das Ihr Modellanbieter abrufen kann.

Für Node.js 24 speichern Sie das folgende Beispiel als check-ai-backend.ts. Ihr authentifizierter POST /api/templates nimmt { name, content } an und gibt { id } zurück. Setzen Sie AI_TEST_TOKEN; AI_BASE_URL und AI_TEMPLATES_URL überschreiben die lokalen URLs. Führen Sie node check-ai-backend.ts aus. Das Beispiel erzeugt eine Wegwerf-Vorlage und deaktiviert Verlauf und Chat-Anhänge passend zum zustandslosen Server im Backend-Leitfaden.

ts
import { createHttpAiProvider } from "@templatical/ai-kit/client";
import {
  AI_CONFORMANCE_ALT_TEXT_IMAGE_URL,
  AI_EVAL_TEMPLATES,
  runAiProviderConformance,
  runAiWireConformance,
} from "@templatical/ai-kit/testing";

const token = process.env.AI_TEST_TOKEN;
if (!token) throw new Error("Set AI_TEST_TOKEN for a disposable test account.");
const baseUrl = process.env.AI_BASE_URL ?? "http://localhost:8787/ai";
const templatesUrl =
  process.env.AI_TEMPLATES_URL ?? "http://localhost:8787/api/templates";
const headers = { Authorization: `Bearer ${token}` };
const content = structuredClone(AI_EVAL_TEMPLATES.welcome);
const logo = content.blocks.find((block) => block.type === "image");
if (!logo) throw new Error("The welcome fixture must contain an image.");
// Both conformance suites authorize this synthetic alt-text probe by block id.
content.blocks.push({
  ...logo,
  id: "conformance-image",
  src: AI_CONFORMANCE_ALT_TEXT_IMAGE_URL,
  alt: "",
});
const created = await fetch(templatesUrl, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    name: "Disposable AI conformance",
    content,
  }),
});
if (!created.ok)
  throw new Error(`Test template creation: HTTP ${created.status}`);
const template = (await created.json()) as { id: string };
if (!template.id) throw new Error("The templates endpoint must return { id }.");

// Match the guide's stateless server and a text-only chat adapter.
const features = { conversation: false, chatAttachments: false } as const;
const timeoutMs = 60_000;
const provider = createHttpAiProvider({ baseUrl, headers, features });
const providerReport = await runAiProviderConformance(provider, {
  templateId: template.id,
  content,
  timeoutMs,
});
const wireReport = await runAiWireConformance({
  baseUrl,
  headers,
  features,
  templateId: template.id,
  timeoutMs,
});
for (const [suite, report] of [
  ["provider", providerReport],
  ["wire", wireReport],
] as const) {
  for (const check of report.checks) {
    console.log(suite, check.ok ? "ok" : "FAIL", check.name, check.detail);
  }
  if (!report.ok) process.exitCode = 1;
}

Das Beispiel fügt das Standardprüfbild als blockId: "conformance-image" ein, damit das Backend es autorisieren kann. Halten Sie diese Wegwerf-Vorlage während beider Suiten verfügbar.

Beide Suiten rufen aktivierte Modellfunktionen auf und verbrauchen dabei Tokens. Turn- und Fix-Prüfungen können angewendete Batches persistieren. Mit Verlauf prüfen sie list, Undo/Redo und settleReplay und leeren ihn über clear, sofern verfügbar. Die Wire-Suite sendet außerdem ungültige Anfragen, darunter mehr als vier Anhänge bei aktiven Chat-Anhängen. Verwenden Sie kein produktives Template. Entfernen Sie die Testvorlage anschließend über Ihren Speicher. Jede fehlgeschlagene Prüfung wird mit Name und Detail ausgegeben; Fehler setzen den Exit-Code auf 1.

  • createMockAiProvider(script) spielt aufgezeichnete Streams für Ihre Oberflächentests ab; AI_RECORDINGS enthält die des Playgrounds.
  • runAiEvalSet({ runTurn, runAltText?, lintCheck?, loadFile?, model, cases?, minPassRate? }) führt AI_EVAL_CASES aus. cases ersetzt den Testsatz; minPassRate ist standardmäßig 90.
  • evalTurnWith(model, options?: { maxSteps?: number }) erstellt runTurn. Bei gesetztem finding verwendet es buildFixRequest(finding), sonst buildChatRequest(message, { hasAttachments }) und die Blob-Anhänge.
  • evalAltTextWith(model, options?: { imageUrl?: (file: string) => string }) erstellt runAltText: (input: AiEvalAltTextInput) => Promise<string>. Es verwendet buildAltTextRequest, locale: "en" und checkAltText(text, { maxLength }). Eine ungültige erste Antwort erhält genau einen Reparaturversuch vor der abschließenden Prüfung mit checkAltText.

Ein fehlendes kind bedeutet edit. Dateipfade sind relativ zu eval/. Die exportierten Typen und ihre Hilfstypen lauten:

ts
import type { AiChatAttachment, AiFixFinding, AiMergeTags, TemplateContent } from "@templatical/types";

export type AiEvalCheck =
  | { type: "applied" | "notApplied" }
  | { type: "present" | "missing"; block: string }
  | { type: "contains" | "notContains"; block?: string; text: string }
  | { type: "lintGone"; ruleId: string }
  | { type: "field" | "fieldNot"; block: string; path: string; matches: string }
  | { type: "setting"; path: string; matches: string }
  | { type: "greater"; block: string; path: string; than: number }
  | { type: "maxText"; block: string; chars: number }
  | { type: "countType"; blockType: string; min: number }
  | { type: "order"; first: string; then: string }
  | { type: "firstContains"; text: string }
  | { type: "reply"; matches: string };

export type AiEvalAltCheck =
  | { type: "nonEmpty" | "withinLimit" }
  | { type: "containsAll" | "containsAny"; texts: string[] }
  | { type: "notMatches"; matches: string }
  | { type: "shortOrMatches"; chars: number; matches: string };

type EvalTemplate =
  "welcome" | "promo" | "injection" | "empty" | "domain-mismatch" | "low-text";

interface EvalTurnCase {
  name: string;
  template: EvalTemplate;
  /** Block ids the case may delete; every other original id must survive. */
  removed?: string[];
  checks: AiEvalCheck[];
}

export interface AiEvalAltTextInput {
  /** Path relative to the package's eval/ folder. */
  image: string;
  linkHref: string | null;
  nearbyText: string;
  maxLength: number;
}

export type AiEvalCase =
  | (EvalTurnCase & {
      kind?: "edit";
      message: string;
      selectedBlockId?: string;
      attachments?: { file: string; kind: "image" | "pdf" }[];
    })
  | (EvalTurnCase & { kind: "fix"; finding: AiFixFinding })
  | (AiEvalAltTextInput & {
      kind: "altText";
      name: string;
      checks: AiEvalAltCheck[];
    });

export interface AiEvalTurnInput {
  content: TemplateContent;
  mergeTags: AiMergeTags;
  message: string;
  selectedBlockId: string | null;
  finding: AiFixFinding | null;
  attachments: AiChatAttachment[];
}

export interface AiEvalTurnOutput {
  /** The template after the turn. */
  content: TemplateContent;
  reply: string;
  /** Whether the turn changed the template. */
  applied: boolean;
}

export interface AiEvalReport {
  model: string;
  passed: number;
  /** Cases that ran; skipped cases are excluded. */
  total: number;
  skipped: number;
  /** Whole percent, rounded down. */
  rate: number;
  ok: boolean;
  results: {
    name: string;
    ok: boolean;
    reason: string | null;
    skipped?: true;
  }[];
}

lintCheck?: (content: TemplateContent) => string[] liefert Regel-IDs nach dem Durchlauf. Die Prüfung { type: "lintGone"; ruleId: string } erwartet, dass die ID fehlt. { type: "fieldNot"; block: string; path: string; matches: string } negiert field; der Block muss existieren. Der Domain-Fall prüft den unveränderten href und sichtbaren Text ohne www. oder ://.

Textvergleiche in AiEvalAltCheck ignorieren Groß- und Kleinschreibung. Längen verwenden string.length; withinLimit prüft das maxLength des Falls.

Ohne runAltText werden Alt-Text-Fälle übersprungen; ohne lintCheck werden Fälle mit lintGone übersprungen. AiEvalReport.skipped zählt sie, und ihr Ergebnis hat skipped?: true. passed, total und rate schließen sie aus. rate ist abgerundet und bei keinem ausgeführten Fall 0. Ein geworfener Fehler lässt einen ausgeführten Fall fehlschlagen.

loadFile?: (file: string) => Promise<Blob> lädt Anhänge. Standardmäßig ruft es https://raw.githubusercontent.com/templatical/sdk/v${version}/packages/ai-kit/eval/${file} ab. evalAltTextWith verwendet dieselbe URL für Bilder und liest version beim Build aus dem Paket. Für unveröffentlichte Builds setzen Sie imageUrl; für lokale Node-Läufe liest loadFile das installierte eval/-Verzeichnis. Das vollständige Node-Beispiel verbindet beide Runner mit @templatical/quality und Dateizugriff.

Die Konformitätsprüfungen prüfen nicht leeren Alt-Text innerhalb von maxLength, Abbruchverhalten und altText: false.

Dateien ​

@templatical/ai-kit/prompts/*.md, @templatical/ai-kit/prompts/*.json, @templatical/ai-kit/schema/*.json und @templatical/ai-kit/eval/* (mit MANIFEST.sha256) sind Prompts, Schemas und Testsatz als Dateien für ein Backend in einer anderen Sprache. Der System-Prompt zum Bearbeiten besteht aus editing.md, dann "\n\n# Operations\n\n" und operations.md, dann "\n\n# Template content schema\n\n" und template-content.schema.json.

Das Protokoll ​

Jede Anfrage und Antwort enthält Templatical-AI-Wire: 1; Durchläufe, Alt-Text, Rückgängig, Wiederholen und deren Bestätigung enthalten X-Templatical-Session. Ein Cross-Origin-Backend erlaubt beim Preflight Templatical-AI-Wire, X-Templatical-Session und den Authentifizierungs-Header in Access-Control-Allow-Headers und gibt Templatical-AI-Wire über Access-Control-Expose-Headers frei. Die Routen sind relativ zu Ihrer Basis-URL; {t} ist die Template-ID.

RouteRequestResponse
POST /templates/{t}/ai/generateJSON { message, selectedBlockId, mergeTags, content, revision }, oder Multipart mit wiederholten attachment-DateiteilenSSE
POST /templates/{t}/ai/fix-findingJSON { finding, mergeTags, content, revision }SSE
POST /templates/{t}/ai/rewrite-textJSON { html, preset, language, instruction, mergeTags }SSE: text*, done { html }
POST /templates/{t}/ai/alt-textJSON { blockId, image: { url }, currentAlt, linkHref, nearbyText, locale, maxLength }SSE: done { alt }
GET /templates/{t}/ai/conversation-messages—JSON { data: AiChatMessage[] }
POST /templates/{t}/ai/messages/{m}/undo, …/redo—JSON { data: { operations, replayId } } or { data: { batchId, revision } }
POST /templates/{t}/ai/messages/{m}/replays/{replayId}JSON { direction, applied }, X-Templatical-Session204
DELETE /templates/{t}/ai/conversation—204

Events sind data: {json}-Zeilen mit jeweils einem type; : ping-Kommentare halten die Verbindung offen. text { text }; operations { ops } (der Editor wendet sie an); applied { batchId, revision, operationCount }; done { messageId } (Durchläufe: String oder null ohne Verlauf), done { html } (Umformulieren), done { alt } (Alt-Text); error { code, message }. done oder error ist das letzte Event.

Fehler vor dem Stream sind JSON { message, code, …details } mit dem Status des Codes: feature_unavailable und limit_reached 403, rate_limited 429 mit Retry-After, ai_turn_running 409, content_too_large 413, ai_output_invalid 422, ai_provider_error 502, not_found 404, ein Operationscode 409 (invalid_operation 422). Ein Validierungsfehler ist ein 422 { message }.

Die batches einer Nachricht sind { batchId, revision, operationCount }; ein Batch, den der Editor selbst angewendet hat, hat keine Backend-Revision. Speichern Sie dafür revision: null.

AI_LIMITS.contentMaxBytes begrenzt den JSON-serialisierten Template-Inhalt auf 200.000 UTF-8-Bytes; readAiRequest lehnt größere Inhalte mit content_too_large (413) ab. selectedBlockId und die Sitzungs-ID sind auf 200 Zeichen begrenzt. Diese Zeichenlimits zählen Unicode-Codepunkte; die Feldlimits für Lint-Befunde stehen unter Lint-Fixes.