@templatical/ai-kit 0.52.1 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/anthropic.js +1 -1
- package/dist/adapters/openai.js +1 -1
- package/dist/adapters/vercel.js +1 -1
- package/dist/checks-BM_JnvRo.js +683 -0
- package/dist/checks-BM_JnvRo.js.map +1 -0
- package/dist/client/index.d.ts +8 -2
- package/dist/client/index.js +43 -3
- package/dist/client/index.js.map +1 -0
- package/dist/{errors-BtORdtK2.js → errors-BOrlo5G2.js} +2 -2
- package/dist/{errors-BtORdtK2.js.map → errors-BOrlo5G2.js.map} +1 -1
- package/dist/{http-provider-DLJYM4l8.js → http-provider-BWVLNpZn.js} +7 -3
- package/dist/http-provider-BWVLNpZn.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/server/index.d.ts +20 -6
- package/dist/server/index.js +120 -4
- package/dist/server/index.js.map +1 -1
- package/dist/{shared-DOEVSpBX.js → shared-Dzp1W1tL.js} +3 -3
- package/dist/{shared-DOEVSpBX.js.map → shared-Dzp1W1tL.js.map} +1 -1
- package/dist/testing/index.d.ts +19 -2
- package/dist/testing/index.js +142 -9
- package/dist/testing/index.js.map +1 -1
- package/dist/{wire-cYO4W7GH.js → wire-CPO4E3P9.js} +11 -2
- package/dist/wire-CPO4E3P9.js.map +1 -0
- package/dist/{wire-BJ4-EWvR.d.ts → wire-JnPcAbtN.d.ts} +8 -2
- package/package.json +4 -4
- package/prompts/operations.md +3 -1
- package/prompts/translate.md +4 -0
- package/schema/template-content.schema.json +185 -0
- package/dist/checks-B-TtaywP.js +0 -646
- package/dist/checks-B-TtaywP.js.map +0 -1
- package/dist/http-provider-DLJYM4l8.js.map +0 -1
- package/dist/wire-cYO4W7GH.js.map +0 -1
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { i as AI_LIMITS, n as AI_ERROR_STATUS, o as AI_SESSION_HEADER, r as AI_GENERATE_FIELDS, s as AI_WIRE_HEADER, t as AI_ERROR_MESSAGES, u as matchAiRoute } from "../wire-
|
|
2
|
-
import { C as
|
|
3
|
-
import { CollaborationConflict, SdkError, SdkError as SdkError$1, ThrottledError, ThrottledError as ThrottledError$1 } from "@templatical/types";
|
|
1
|
+
import { i as AI_LIMITS, n as AI_ERROR_STATUS, o as AI_SESSION_HEADER, r as AI_GENERATE_FIELDS, s as AI_WIRE_HEADER, t as AI_ERROR_MESSAGES, u as matchAiRoute } from "../wire-CPO4E3P9.js";
|
|
2
|
+
import { A as TRANSLATE_INSTRUCTIONS, C as rewriteInstructions, D as unknownMergeTags, E as mergeTagTokensIn, O as fill, S as languageName, T as mergeTagTokens, _ as buildChatRequest, a as checkRewrite, b as buildRewritePrompt, c as EDITING_SESSION_LIMITS, d as describeOp, f as opRefusal, g as buildAltTextRequest, h as REWRITE_PRESET_INSTRUCTIONS, i as checkAltTextAnswer, k as FIX_RULE_GUIDANCE, l as createEditingSession, m as walkBlocks, n as buildRewriteRepairPrompt, o as stripFences, p as contentRuleRefusal, r as checkAltText, s as runEditingTurn, t as buildAltTextRepairRequest, u as applyOperationsTool, v as buildEditingPrompt, w as flattenMergeTags, x as editingInstructions, y as buildFixRequest } from "../checks-BM_JnvRo.js";
|
|
3
|
+
import { CollaborationConflict, SdkError, SdkError as SdkError$1, ThrottledError, ThrottledError as ThrottledError$1, isLocaleTag, unmaskTags } from "@templatical/types";
|
|
4
4
|
// src/server/errors.ts
|
|
5
5
|
const PROVIDER_ERROR = {
|
|
6
6
|
code: "ai_provider_error",
|
|
@@ -212,6 +212,11 @@ async function readAiRequest(request) {
|
|
|
212
212
|
};
|
|
213
213
|
if (base.sessionId !== null && !isBoundedText(base.sessionId, 200)) invalid("The session id must be at most 200 characters.");
|
|
214
214
|
switch (match.route) {
|
|
215
|
+
case "translate": return {
|
|
216
|
+
...base,
|
|
217
|
+
route: "translate",
|
|
218
|
+
body: await readTranslationRequest(request)
|
|
219
|
+
};
|
|
215
220
|
case "conversationMessages":
|
|
216
221
|
case "clearConversation": return {
|
|
217
222
|
...base,
|
|
@@ -250,6 +255,7 @@ async function readAiRequest(request) {
|
|
|
250
255
|
}
|
|
251
256
|
};
|
|
252
257
|
body = {
|
|
258
|
+
locale: json(AI_GENERATE_FIELDS.locale),
|
|
253
259
|
message: form.get(AI_GENERATE_FIELDS.message),
|
|
254
260
|
selectedBlockId: form.get(AI_GENERATE_FIELDS.selectedBlockId),
|
|
255
261
|
revision: form.get(AI_GENERATE_FIELDS.revision),
|
|
@@ -304,6 +310,7 @@ async function readAiRequest(request) {
|
|
|
304
310
|
...base,
|
|
305
311
|
route: "generate",
|
|
306
312
|
body: {
|
|
313
|
+
locale: locale(body.locale),
|
|
307
314
|
message,
|
|
308
315
|
attachments,
|
|
309
316
|
selectedBlockId: typeof body.selectedBlockId === "string" ? body.selectedBlockId : null,
|
|
@@ -317,6 +324,7 @@ async function readAiRequest(request) {
|
|
|
317
324
|
...base,
|
|
318
325
|
route: "fixFinding",
|
|
319
326
|
body: {
|
|
327
|
+
locale: locale(body.locale),
|
|
320
328
|
finding: finding(body.finding),
|
|
321
329
|
mergeTags: mergeTags(body.mergeTags),
|
|
322
330
|
content: content(body.content),
|
|
@@ -337,6 +345,7 @@ async function readAiRequest(request) {
|
|
|
337
345
|
...base,
|
|
338
346
|
route: "rewriteText",
|
|
339
347
|
body: {
|
|
348
|
+
locale: locale(body.locale),
|
|
340
349
|
html,
|
|
341
350
|
preset,
|
|
342
351
|
language,
|
|
@@ -347,6 +356,113 @@ async function readAiRequest(request) {
|
|
|
347
356
|
}
|
|
348
357
|
}
|
|
349
358
|
}
|
|
350
|
-
|
|
359
|
+
function locale(value) {
|
|
360
|
+
if (value === void 0 || value === null) return null;
|
|
361
|
+
if (!isLocaleTag(value)) invalid("locale must be a BCP 47 tag.");
|
|
362
|
+
return value;
|
|
363
|
+
}
|
|
364
|
+
/** Validate a translation body, including direct calls to `translateFields`. */
|
|
365
|
+
function checkTranslationRequest(value) {
|
|
366
|
+
if (!isRecord(value)) invalid("Send a JSON object.");
|
|
367
|
+
if (value.templateId !== null && (typeof value.templateId !== "string" || value.templateId === "")) invalid("templateId must be a non-empty string or null.");
|
|
368
|
+
for (const key of ["sourceLocale", "targetLocale"]) if (locale(value[key]) === null) invalid(`${key} must be a BCP 47 tag.`);
|
|
369
|
+
if (!Array.isArray(value.fields) || value.fields.length === 0) invalid("fields must be a non-empty array.");
|
|
370
|
+
if (value.fields.length > AI_LIMITS.translationMaxFields) throw new SdkError$1(AI_ERROR_MESSAGES.content_too_large, 413, "content_too_large");
|
|
371
|
+
const ids = /* @__PURE__ */ new Set();
|
|
372
|
+
let chars = 0;
|
|
373
|
+
for (const field of value.fields) {
|
|
374
|
+
if (!isRecord(field) || typeof field.id !== "string" || field.id === "" || ids.has(field.id) || field.format !== "html" && field.format !== "text" || typeof field.text !== "string") invalid("Each field must have a unique non-empty id, html or text format, and string text.");
|
|
375
|
+
ids.add(field.id);
|
|
376
|
+
chars += field.text.length;
|
|
377
|
+
}
|
|
378
|
+
if (chars > AI_LIMITS.translationMaxChars) throw new SdkError$1(AI_ERROR_MESSAGES.content_too_large, 413, "content_too_large");
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
/** Read the JSON translation body and enforce the translation call limits. */
|
|
382
|
+
async function readTranslationRequest(request) {
|
|
383
|
+
return checkTranslationRequest(await request.json().catch(() => invalid("Send a JSON body.")));
|
|
384
|
+
}
|
|
385
|
+
// src/server/translate.ts
|
|
386
|
+
function passingFields(answer, request) {
|
|
387
|
+
let parsed;
|
|
388
|
+
try {
|
|
389
|
+
parsed = JSON.parse(stripFences(answer).trim());
|
|
390
|
+
} catch {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.fields)) return [];
|
|
394
|
+
const fields = parsed.fields;
|
|
395
|
+
return request.fields.flatMap((source) => {
|
|
396
|
+
const matches = fields.filter((field) => typeof field === "object" && field !== null && "id" in field && field.id === source.id);
|
|
397
|
+
if (matches.length !== 1 || typeof matches[0].text !== "string") return [];
|
|
398
|
+
const text = matches[0].text;
|
|
399
|
+
const tokens = [...source.text.matchAll(/<x id="(\d+)"\s*\/>|<x id="(\d+)"><\/x>|__x(\d+)__/g)].map(() => "");
|
|
400
|
+
if (unmaskTags(source.text, tokens) === null || unmaskTags(text, tokens) === null) return [];
|
|
401
|
+
return [{
|
|
402
|
+
id: source.id,
|
|
403
|
+
text
|
|
404
|
+
}];
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/** Translate masked fields, with one repair and a partial result for fields that pass. */
|
|
408
|
+
async function translateFields(options) {
|
|
409
|
+
const { model, signal } = options;
|
|
410
|
+
signal?.throwIfAborted();
|
|
411
|
+
const request = checkTranslationRequest(options.request);
|
|
412
|
+
const system = fill(TRANSLATE_INSTRUCTIONS, {
|
|
413
|
+
source: `${languageName(request.sourceLocale)} (${request.sourceLocale})`,
|
|
414
|
+
target: `${languageName(request.targetLocale)} (${request.targetLocale})`
|
|
415
|
+
});
|
|
416
|
+
const messages = [{
|
|
417
|
+
role: "user",
|
|
418
|
+
content: `Translate from ${languageName(request.sourceLocale)} (${request.sourceLocale}) to ${languageName(request.targetLocale)} (${request.targetLocale}).\n\nFields:\n${JSON.stringify(request.fields)}`
|
|
419
|
+
}];
|
|
420
|
+
const accepted = /* @__PURE__ */ new Map();
|
|
421
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
422
|
+
signal?.throwIfAborted();
|
|
423
|
+
let answer = "";
|
|
424
|
+
try {
|
|
425
|
+
for await (const part of model.stream({
|
|
426
|
+
system,
|
|
427
|
+
messages,
|
|
428
|
+
tools: [],
|
|
429
|
+
signal
|
|
430
|
+
})) {
|
|
431
|
+
signal?.throwIfAborted();
|
|
432
|
+
if (part.type === "text") answer += part.text;
|
|
433
|
+
}
|
|
434
|
+
} catch (error) {
|
|
435
|
+
signal?.throwIfAborted();
|
|
436
|
+
if (error instanceof Error && error.name === "AbortError" || typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" && [
|
|
437
|
+
"rate_limited",
|
|
438
|
+
"limit_reached",
|
|
439
|
+
"content_too_large",
|
|
440
|
+
"translation_failed"
|
|
441
|
+
].includes(error.code)) throw error;
|
|
442
|
+
if (!(error instanceof SdkError$1) || error.code !== "ai_provider_error" && error.code !== "ai_output_invalid") throw new SdkError$1("The translation couldn't produce a valid result. Try again.", 502, "translation_failed");
|
|
443
|
+
}
|
|
444
|
+
signal?.throwIfAborted();
|
|
445
|
+
const pending = {
|
|
446
|
+
...request,
|
|
447
|
+
fields: request.fields.filter((field) => !accepted.has(field.id))
|
|
448
|
+
};
|
|
449
|
+
for (const field of passingFields(answer, pending)) accepted.set(field.id, field.text);
|
|
450
|
+
const failed = request.fields.filter((field) => !accepted.has(field.id)).map((field) => field.id);
|
|
451
|
+
if (failed.length === 0) break;
|
|
452
|
+
messages.push({
|
|
453
|
+
role: "assistant",
|
|
454
|
+
content: answer
|
|
455
|
+
}, {
|
|
456
|
+
role: "user",
|
|
457
|
+
content: `Return complete JSON {"fields":[{"id":"…","text":"…"}]} for these failed ids only: ${JSON.stringify(failed)}. Preserve every placeholder exactly once.`
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (accepted.size === 0) throw new SdkError$1("The translation couldn't produce a valid result. Try again.", 502, "translation_failed");
|
|
461
|
+
return { fields: request.fields.flatMap(({ id }) => accepted.has(id) ? [{
|
|
462
|
+
id,
|
|
463
|
+
text: accepted.get(id)
|
|
464
|
+
}] : []) };
|
|
465
|
+
}
|
|
466
|
+
export { EDITING_SESSION_LIMITS, FIX_RULE_GUIDANCE, REWRITE_PRESET_INSTRUCTIONS, SdkError, ThrottledError, aiErrorEvent, aiErrorResponse, aiEventStream, aiJsonResponse, applyOperationsTool, buildAltTextRepairRequest, buildAltTextRequest, buildChatRequest, buildEditingPrompt, buildFixRequest, buildRewritePrompt, buildRewriteRepairPrompt, checkAltText, checkAltTextAnswer, checkRewrite, contentRuleRefusal, createEditingSession, describeOp, editingInstructions, flattenMergeTags, mergeTagTokens, mergeTagTokensIn, opRefusal, readAiRequest, readTranslationRequest, rewriteInstructions, runEditingTurn, translateFields, unknownMergeTags, walkBlocks };
|
|
351
467
|
|
|
352
468
|
//# sourceMappingURL=index.js.map
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["SdkError","ThrottledError","SdkError"],"sources":["../../src/server/errors.ts","../../src/server/event-stream.ts","../../src/server/read-request.ts"],"sourcesContent":["import {\n CollaborationConflict,\n SdkError,\n ThrottledError,\n} from \"@templatical/types\";\nimport {\n AI_ERROR_MESSAGES,\n AI_ERROR_STATUS,\n AI_WIRE_HEADER,\n AI_WIRE_VERSION,\n type AiErrorEvent,\n} from \"../wire\";\n\nconst PROVIDER_ERROR = {\n code: \"ai_provider_error\",\n message: AI_ERROR_MESSAGES.ai_provider_error,\n} as const;\n\nfunction known(error: unknown): error is SdkError & { code: string } {\n return error instanceof SdkError && typeof error.code === \"string\";\n}\n\nfunction conflictDetails(error: unknown): Record<string, unknown> {\n if (!(error instanceof CollaborationConflict)) return {};\n return {\n ...(error.opIndex !== undefined ? { opIndex: error.opIndex } : {}),\n ...(error.holder !== undefined ? { holder: error.holder } : {}),\n ...(error.holders !== undefined ? { holders: error.holders } : {}),\n };\n}\n\n/** An error as the stream's last event. Anything but a coded `SdkError` hides behind the provider error. */\nexport function aiErrorEvent(error: unknown): AiErrorEvent {\n if (!known(error)) return { type: \"error\", ...PROVIDER_ERROR };\n return {\n ...error.details,\n ...conflictDetails(error),\n type: \"error\",\n code: error.code,\n message: error.message,\n };\n}\n\n/** A JSON response with the wire's header; `204` has no body. */\nexport function aiJsonResponse(body: unknown, status = 200): Response {\n const headers = new Headers({ [AI_WIRE_HEADER]: AI_WIRE_VERSION });\n if (status === 204) return new Response(null, { status, headers });\n headers.set(\"Content-Type\", \"application/json\");\n return new Response(JSON.stringify(body), { status, headers });\n}\n\n/**\n * An error before the stream starts: `{ message, code, …details }` with the\n * code's status, `Retry-After` (seconds) for a `ThrottledError`.\n */\nexport function aiErrorResponse(error: unknown): Response {\n if (!(error instanceof SdkError))\n return aiJsonResponse({ ...PROVIDER_ERROR }, 502);\n // A validation failure (`readAiRequest`'s 422) has no code, as Laravel's has none.\n const code = typeof error.code === \"string\" ? error.code : undefined;\n const status =\n error.statusCode ??\n (code !== undefined ? AI_ERROR_STATUS[code] : undefined) ??\n 400;\n const response = aiJsonResponse(\n {\n ...error.details,\n ...conflictDetails(error),\n message: error.message,\n ...(code !== undefined ? { code } : {}),\n },\n status,\n );\n if (error instanceof ThrottledError) {\n response.headers.set(\n \"Retry-After\",\n String(Math.ceil(error.retryAfterMs / 1000)),\n );\n }\n return response;\n}\n\nexport { PROVIDER_ERROR };\n","import { AI_WIRE_HEADER, AI_WIRE_VERSION, type AiWireEvent } from \"../wire\";\nimport { aiErrorEvent, PROVIDER_ERROR } from \"./errors\";\n\nexport interface AiEventStreamController {\n /** Write an event. Ignored once `done` or `error` was written. */\n send(event: AiWireEvent): void;\n /** Aborted when the client goes away or `options.signal` aborts. */\n readonly signal: AbortSignal;\n}\n\nexport interface AiEventStreamOptions {\n /** Write `: ping` after this long without a write (15 s). */\n pingMs?: number;\n signal?: AbortSignal;\n headers?: HeadersInit;\n}\n\n/**\n * The SSE response for any Fetch-API server (Next, Hono, Workers, Deno, Bun;\n * Express through an adapter). The producer writes events; a producer that\n * throws ends the stream with an `error` event, and one that returns without\n * `done` or `error` ends it with the provider error.\n */\nexport function aiEventStream(\n producer: (stream: AiEventStreamController) => Promise<void>,\n options: AiEventStreamOptions = {},\n): Response {\n const pingMs = options.pingMs ?? 15_000;\n const encoder = new TextEncoder();\n const abort = new AbortController();\n function forwardAbort(): void {\n abort.abort(options.signal?.reason);\n }\n if (options.signal) {\n if (options.signal.aborted) abort.abort(options.signal.reason);\n else options.signal.addEventListener(\"abort\", forwardAbort, { once: true });\n }\n let timer: ReturnType<typeof setInterval> | null = null;\n let closed = false;\n\n function cleanup(): void {\n if (timer) clearInterval(timer);\n options.signal?.removeEventListener(\"abort\", forwardAbort);\n }\n\n const body = new ReadableStream<Uint8Array>({\n start(controller) {\n let finished = false;\n let lastWrite = Date.now();\n const write = (chunk: string): void => {\n if (closed) return;\n controller.enqueue(encoder.encode(chunk));\n lastWrite = Date.now();\n };\n const send = (event: AiWireEvent): void => {\n if (finished) return;\n write(`data: ${JSON.stringify(event)}\\n\\n`);\n if (event.type === \"done\" || event.type === \"error\") finished = true;\n };\n timer = setInterval(\n () => {\n if (Date.now() - lastWrite >= pingMs) write(\": ping\\n\\n\");\n },\n Math.max(1, Math.floor(pingMs / 3)),\n );\n void (async () => {\n try {\n await producer({ send, signal: abort.signal });\n if (!abort.signal.aborted) send({ type: \"error\", ...PROVIDER_ERROR });\n } catch (error) {\n if (!abort.signal.aborted) send(aiErrorEvent(error));\n } finally {\n cleanup();\n if (!closed) {\n closed = true;\n controller.close();\n }\n }\n })();\n },\n cancel(reason) {\n closed = true;\n cleanup();\n abort.abort(reason);\n },\n });\n\n const headers = new Headers(options.headers);\n headers.set(\"Content-Type\", \"text/event-stream\");\n headers.set(\"Cache-Control\", \"no-cache\");\n headers.set(\"X-Accel-Buffering\", \"no\");\n headers.set(AI_WIRE_HEADER, AI_WIRE_VERSION);\n return new Response(body, { headers });\n}\n","import type {\n AiFixFinding,\n AiChatAttachment,\n AiMergeTags,\n AiRewritePreset,\n TemplateContent,\n} from \"@templatical/types\";\nimport { SdkError } from \"@templatical/types\";\nimport {\n AI_GENERATE_FIELDS,\n AI_ERROR_MESSAGES,\n AI_LIMITS,\n AI_SESSION_HEADER,\n matchAiRoute,\n type AiFixFindingBody,\n type AiAltTextBody,\n type AiGenerateBody,\n type AiRewriteBody,\n} from \"../wire\";\n\ninterface Base {\n template: string;\n /** `X-Templatical-Session`, when sent. */\n sessionId: string | null;\n}\n\nexport type AiWireRequest = Base &\n (\n | { route: \"generate\"; body: AiGenerateBody }\n | { route: \"fixFinding\"; body: AiFixFindingBody }\n | { route: \"rewriteText\"; body: AiRewriteBody }\n | { route: \"altText\"; body: AiAltTextBody }\n | { route: \"conversationMessages\" | \"clearConversation\" }\n | { route: \"undoMessage\" | \"redoMessage\"; message: string }\n | {\n route: \"settleReplay\";\n message: string;\n replayId: string;\n body: { direction: \"undo\" | \"redo\"; applied: boolean };\n }\n );\n\nconst PRESETS: readonly AiRewritePreset[] = [\n \"shorten\",\n \"expand\",\n \"friendlier\",\n \"formal\",\n \"fixGrammar\",\n \"translate\",\n];\nconst IMAGE_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\"];\nconst MERGE_TAG_SYNTAXES = [\n \"liquid\",\n \"handlebars\",\n \"mailchimp\",\n \"ampscript\",\n \"custom\",\n];\n\nfunction invalid(message: string): never {\n throw new SdkError(message, 422);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isBoundedText = (value: unknown, max: number): value is string =>\n typeof value === \"string\" && Array.from(value).length <= max;\n\nfunction content(value: unknown): TemplateContent {\n if (\n !isRecord(value) ||\n !Array.isArray(value.blocks) ||\n !isRecord(value.settings)\n ) {\n invalid(\"content must be the template's content.\");\n }\n if (\n new TextEncoder().encode(JSON.stringify(value)).length >\n AI_LIMITS.contentMaxBytes\n )\n throw new SdkError(\n AI_ERROR_MESSAGES.content_too_large,\n 413,\n \"content_too_large\",\n );\n return value as unknown as TemplateContent;\n}\n\nfunction mergeTags(value: unknown): AiMergeTags {\n if (\n !isRecord(value) ||\n typeof value.syntax !== \"string\" ||\n !MERGE_TAG_SYNTAXES.includes(value.syntax) ||\n !Array.isArray(value.tags) ||\n value.tags.length > AI_LIMITS.mergeTagsMax\n ) {\n invalid(\"mergeTags must be { syntax, tags } with at most 500 tags.\");\n }\n for (const tag of value.tags) {\n if (\n !isRecord(tag) ||\n !isBoundedText(tag.value, 200) ||\n !isBoundedText(tag.label, 100) ||\n (tag.group !== undefined && !isBoundedText(tag.group, 100)) ||\n (tag.description !== undefined && !isBoundedText(tag.description, 200))\n ) {\n invalid(\n \"Each merge tag must have string value (at most 200 characters) and label (at most 100), with optional string group (at most 100) and description (at most 200).\",\n );\n }\n }\n return value as unknown as AiMergeTags;\n}\n\nfunction revision(value: unknown): number | null {\n if (value === null || value === undefined || value === \"\") return null;\n const number = typeof value === \"string\" ? Number(value) : value;\n if (typeof number !== \"number\" || !Number.isInteger(number) || number < 0) {\n invalid(\"revision must be a whole number or null.\");\n }\n return number;\n}\n\nfunction optionalText(\n value: unknown,\n max: number,\n message: string,\n): string | null {\n if (value === null || value === undefined || value === \"\") return null;\n if (typeof value !== \"string\" || Array.from(value).length > max)\n invalid(message);\n return value;\n}\n\nfunction finding(value: unknown): AiFixFinding {\n if (!isRecord(value))\n invalid(\n \"finding must be { ruleId, message, suggestion, reference, blockId, excerpt? }.\",\n );\n if (\n typeof value.ruleId !== \"string\" ||\n value.ruleId.length === 0 ||\n value.ruleId.length > 200\n )\n invalid(\"finding.ruleId must be 1 to 200 characters.\");\n if (!isBoundedText(value.message, 1000))\n invalid(\"finding.message must be at most 1,000 code points.\");\n if (!isBoundedText(value.suggestion, 1000))\n invalid(\"finding.suggestion must be at most 1,000 code points.\");\n let reference: URL | null = null;\n if (typeof value.reference === \"string\" && value.reference.length <= 2048) {\n try {\n reference = new URL(value.reference);\n } catch {\n /* Invalid URL. */\n }\n }\n if (\n !reference ||\n (reference.protocol !== \"http:\" && reference.protocol !== \"https:\")\n )\n invalid(\n \"finding.reference must be an http: or https: URL of at most 2,048 characters.\",\n );\n if (\n value.blockId !== null &&\n (typeof value.blockId !== \"string\" || value.blockId.length > 200)\n )\n invalid(\n \"finding.blockId must be a string of at most 200 characters or null.\",\n );\n if (value.excerpt !== undefined && !isBoundedText(value.excerpt, 1000))\n invalid(\"finding.excerpt must be at most 1,000 code points when present.\");\n return value as unknown as AiFixFinding;\n}\n\n/**\n * The wire's request, parsed and checked against `AI_LIMITS`; `null` when the\n * request is no route of the wire. Field validation throws `SdkError(message, 422)`;\n * oversized content throws `content_too_large` (413).\n */\nexport async function readAiRequest(\n request: Request,\n): Promise<AiWireRequest | null> {\n const match = matchAiRoute(request.method, new URL(request.url).pathname);\n if (!match) return null;\n const base: Base = {\n template: match.template,\n sessionId: request.headers.get(AI_SESSION_HEADER),\n };\n\n if (base.sessionId !== null && !isBoundedText(base.sessionId, 200))\n invalid(\"The session id must be at most 200 characters.\");\n\n switch (match.route) {\n case \"conversationMessages\":\n case \"clearConversation\":\n return { ...base, route: match.route };\n case \"undoMessage\":\n case \"redoMessage\":\n return { ...base, route: match.route, message: match.message ?? \"\" };\n default:\n break;\n }\n\n let attachments: AiChatAttachment[] = [];\n let body: unknown;\n if (\n match.route === \"generate\" &&\n request.headers.get(\"Content-Type\")?.startsWith(\"multipart/form-data\")\n ) {\n const form = await request\n .formData()\n .catch(() => invalid(\"Send the message as multipart form data.\"));\n const files = form.getAll(AI_GENERATE_FIELDS.attachment);\n if (files.length > AI_LIMITS.attachmentsMax)\n invalid(\"Attach at most 4 files.\");\n if (\n files.some(\n (file) =>\n !(file instanceof Blob) ||\n (!IMAGE_TYPES.includes(file.type) && file.type !== \"application/pdf\"),\n )\n )\n invalid(\"Attach PNG, JPEG, WebP or GIF images, or PDFs.\");\n const blobs = files as File[];\n if (blobs.some((file) => file.size > AI_LIMITS.attachmentMaxBytes))\n invalid(\"Each attachment must be 5 MB or smaller.\");\n if (\n blobs.reduce((total, file) => total + file.size, 0) >\n AI_LIMITS.attachmentsTotalMaxBytes\n )\n invalid(\"Attachments must be 10 MB or smaller together.\");\n if (blobs.some((file) => file.name.length > 255))\n invalid(\"An attachment's file name must be at most 255 characters.\");\n attachments = blobs.map((file) => ({\n file,\n kind: file.type === \"application/pdf\" ? \"pdf\" : \"image\",\n name: file.name,\n }));\n const json = (field: string): unknown => {\n const raw = form.get(field);\n if (raw === null) return null;\n try {\n return JSON.parse(String(raw));\n } catch {\n return invalid(`${field} must be JSON.`);\n }\n };\n body = {\n message: form.get(AI_GENERATE_FIELDS.message),\n selectedBlockId: form.get(AI_GENERATE_FIELDS.selectedBlockId),\n revision: form.get(AI_GENERATE_FIELDS.revision),\n mergeTags: json(AI_GENERATE_FIELDS.mergeTags),\n content: json(AI_GENERATE_FIELDS.content),\n };\n } else {\n body = await request.json().catch(() => invalid(\"Send a JSON body.\"));\n }\n if (!isRecord(body)) invalid(\"Send a JSON object.\");\n\n switch (match.route) {\n case \"altText\": {\n if (\n typeof body.blockId !== \"string\" ||\n body.blockId.length === 0 ||\n body.blockId.length > 200\n )\n invalid(\"blockId must be 1 to 200 characters.\");\n let url: URL | null = null;\n if (\n isRecord(body.image) &&\n typeof body.image.url === \"string\" &&\n body.image.url.length <= 2048\n ) {\n try {\n url = new URL(body.image.url);\n } catch {\n /* Invalid URL. */\n }\n }\n if (!url || (url.protocol !== \"http:\" && url.protocol !== \"https:\"))\n invalid(\n \"image.url must be an http: or https: URL of at most 2,048 characters.\",\n );\n for (const key of [\"currentAlt\", \"linkHref\"] as const) {\n if (\n body[key] !== null &&\n (typeof body[key] !== \"string\" || body[key].length > 2048)\n )\n invalid(\n `${key} must be a string of at most 2,048 characters or null.`,\n );\n }\n if (!isBoundedText(body.nearbyText, 2000))\n invalid(\"nearbyText must be at most 2,000 code points.\");\n if (\n typeof body.locale !== \"string\" ||\n body.locale.length > 35 ||\n !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/.test(body.locale)\n )\n invalid(\"locale must be a BCP 47 tag of at most 35 characters.\");\n if (\n typeof body.maxLength !== \"number\" ||\n !Number.isInteger(body.maxLength) ||\n body.maxLength < 1 ||\n body.maxLength > 1000\n )\n invalid(\"maxLength must be an integer from 1 to 1,000.\");\n return {\n ...base,\n route: \"altText\",\n body: {\n blockId: body.blockId,\n image: { url: (body.image as { url: string }).url },\n currentAlt: body.currentAlt as string | null,\n linkHref: body.linkHref as string | null,\n nearbyText: body.nearbyText,\n locale: body.locale,\n maxLength: body.maxLength,\n },\n };\n }\n case \"settleReplay\": {\n if (\n (body.direction !== \"undo\" && body.direction !== \"redo\") ||\n typeof body.applied !== \"boolean\"\n )\n invalid(\n \"A replay outcome must have direction undo or redo and boolean applied.\",\n );\n return {\n ...base,\n route: \"settleReplay\",\n message: match.message!,\n replayId: match.replayId!,\n body: { direction: body.direction, applied: body.applied },\n };\n }\n case \"generate\": {\n const message = body.message;\n if (\n typeof message !== \"string\" ||\n message.trim() === \"\" ||\n Array.from(message).length > AI_LIMITS.messageMaxChars\n ) {\n invalid(\"The message must be 1 to 4,000 characters.\");\n }\n if (\n typeof body.selectedBlockId === \"string\" &&\n !isBoundedText(body.selectedBlockId, 200)\n )\n invalid(\"selectedBlockId must be at most 200 characters.\");\n return {\n ...base,\n route: \"generate\",\n body: {\n message,\n attachments,\n selectedBlockId:\n typeof body.selectedBlockId === \"string\"\n ? body.selectedBlockId\n : null,\n mergeTags: mergeTags(body.mergeTags),\n content: content(body.content),\n revision: revision(body.revision),\n },\n };\n }\n case \"fixFinding\":\n return {\n ...base,\n route: \"fixFinding\",\n body: {\n finding: finding(body.finding),\n mergeTags: mergeTags(body.mergeTags),\n content: content(body.content),\n revision: revision(body.revision),\n },\n };\n case \"rewriteText\": {\n const html = body.html;\n if (\n typeof html !== \"string\" ||\n html === \"\" ||\n Array.from(html).length > AI_LIMITS.rewriteHtmlMaxChars\n ) {\n invalid(\"html must be 1 to 20,000 characters.\");\n }\n const preset = body.preset ?? null;\n if (preset !== null && !PRESETS.includes(preset as AiRewritePreset))\n invalid(\"Unknown preset.\");\n const instruction = optionalText(\n body.instruction,\n AI_LIMITS.instructionMaxChars,\n \"The instruction must be at most 500 characters.\",\n );\n if (preset === null && instruction === null)\n invalid(\"Choose a preset or write an instruction.\");\n const language = body.language === \"\" ? null : (body.language ?? null);\n if (\n language !== null &&\n (typeof language !== \"string\" ||\n !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(language))\n ) {\n invalid(\"language must be a BCP 47 tag.\");\n }\n if (preset === \"translate\" && language === null)\n invalid(\"Choose a language to translate into.\");\n return {\n ...base,\n route: \"rewriteText\",\n body: {\n html,\n preset: preset as AiRewritePreset | null,\n language,\n instruction,\n mergeTags: mergeTags(body.mergeTags),\n },\n };\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["SdkError","ThrottledError","SdkError","SdkError"],"sources":["../../src/server/errors.ts","../../src/server/event-stream.ts","../../src/server/read-request.ts","../../src/server/translate.ts"],"sourcesContent":["import {\n CollaborationConflict,\n SdkError,\n ThrottledError,\n} from \"@templatical/types\";\nimport {\n AI_ERROR_MESSAGES,\n AI_ERROR_STATUS,\n AI_WIRE_HEADER,\n AI_WIRE_VERSION,\n type AiErrorEvent,\n} from \"../wire\";\n\nconst PROVIDER_ERROR = {\n code: \"ai_provider_error\",\n message: AI_ERROR_MESSAGES.ai_provider_error,\n} as const;\n\nfunction known(error: unknown): error is SdkError & { code: string } {\n return error instanceof SdkError && typeof error.code === \"string\";\n}\n\nfunction conflictDetails(error: unknown): Record<string, unknown> {\n if (!(error instanceof CollaborationConflict)) return {};\n return {\n ...(error.opIndex !== undefined ? { opIndex: error.opIndex } : {}),\n ...(error.holder !== undefined ? { holder: error.holder } : {}),\n ...(error.holders !== undefined ? { holders: error.holders } : {}),\n };\n}\n\n/** An error as the stream's last event. Anything but a coded `SdkError` hides behind the provider error. */\nexport function aiErrorEvent(error: unknown): AiErrorEvent {\n if (!known(error)) return { type: \"error\", ...PROVIDER_ERROR };\n return {\n ...error.details,\n ...conflictDetails(error),\n type: \"error\",\n code: error.code,\n message: error.message,\n };\n}\n\n/** A JSON response with the wire's header; `204` has no body. */\nexport function aiJsonResponse(body: unknown, status = 200): Response {\n const headers = new Headers({ [AI_WIRE_HEADER]: AI_WIRE_VERSION });\n if (status === 204) return new Response(null, { status, headers });\n headers.set(\"Content-Type\", \"application/json\");\n return new Response(JSON.stringify(body), { status, headers });\n}\n\n/**\n * An error before the stream starts: `{ message, code, …details }` with the\n * code's status, `Retry-After` (seconds) for a `ThrottledError`.\n */\nexport function aiErrorResponse(error: unknown): Response {\n if (!(error instanceof SdkError))\n return aiJsonResponse({ ...PROVIDER_ERROR }, 502);\n // A validation failure (`readAiRequest`'s 422) has no code, as Laravel's has none.\n const code = typeof error.code === \"string\" ? error.code : undefined;\n const status =\n error.statusCode ??\n (code !== undefined ? AI_ERROR_STATUS[code] : undefined) ??\n 400;\n const response = aiJsonResponse(\n {\n ...error.details,\n ...conflictDetails(error),\n message: error.message,\n ...(code !== undefined ? { code } : {}),\n },\n status,\n );\n if (error instanceof ThrottledError) {\n response.headers.set(\n \"Retry-After\",\n String(Math.ceil(error.retryAfterMs / 1000)),\n );\n }\n return response;\n}\n\nexport { PROVIDER_ERROR };\n","import { AI_WIRE_HEADER, AI_WIRE_VERSION, type AiWireEvent } from \"../wire\";\nimport { aiErrorEvent, PROVIDER_ERROR } from \"./errors\";\n\nexport interface AiEventStreamController {\n /** Write an event. Ignored once `done` or `error` was written. */\n send(event: AiWireEvent): void;\n /** Aborted when the client goes away or `options.signal` aborts. */\n readonly signal: AbortSignal;\n}\n\nexport interface AiEventStreamOptions {\n /** Write `: ping` after this long without a write (15 s). */\n pingMs?: number;\n signal?: AbortSignal;\n headers?: HeadersInit;\n}\n\n/**\n * The SSE response for any Fetch-API server (Next, Hono, Workers, Deno, Bun;\n * Express through an adapter). The producer writes events; a producer that\n * throws ends the stream with an `error` event, and one that returns without\n * `done` or `error` ends it with the provider error.\n */\nexport function aiEventStream(\n producer: (stream: AiEventStreamController) => Promise<void>,\n options: AiEventStreamOptions = {},\n): Response {\n const pingMs = options.pingMs ?? 15_000;\n const encoder = new TextEncoder();\n const abort = new AbortController();\n function forwardAbort(): void {\n abort.abort(options.signal?.reason);\n }\n if (options.signal) {\n if (options.signal.aborted) abort.abort(options.signal.reason);\n else options.signal.addEventListener(\"abort\", forwardAbort, { once: true });\n }\n let timer: ReturnType<typeof setInterval> | null = null;\n let closed = false;\n\n function cleanup(): void {\n if (timer) clearInterval(timer);\n options.signal?.removeEventListener(\"abort\", forwardAbort);\n }\n\n const body = new ReadableStream<Uint8Array>({\n start(controller) {\n let finished = false;\n let lastWrite = Date.now();\n const write = (chunk: string): void => {\n if (closed) return;\n controller.enqueue(encoder.encode(chunk));\n lastWrite = Date.now();\n };\n const send = (event: AiWireEvent): void => {\n if (finished) return;\n write(`data: ${JSON.stringify(event)}\\n\\n`);\n if (event.type === \"done\" || event.type === \"error\") finished = true;\n };\n timer = setInterval(\n () => {\n if (Date.now() - lastWrite >= pingMs) write(\": ping\\n\\n\");\n },\n Math.max(1, Math.floor(pingMs / 3)),\n );\n void (async () => {\n try {\n await producer({ send, signal: abort.signal });\n if (!abort.signal.aborted) send({ type: \"error\", ...PROVIDER_ERROR });\n } catch (error) {\n if (!abort.signal.aborted) send(aiErrorEvent(error));\n } finally {\n cleanup();\n if (!closed) {\n closed = true;\n controller.close();\n }\n }\n })();\n },\n cancel(reason) {\n closed = true;\n cleanup();\n abort.abort(reason);\n },\n });\n\n const headers = new Headers(options.headers);\n headers.set(\"Content-Type\", \"text/event-stream\");\n headers.set(\"Cache-Control\", \"no-cache\");\n headers.set(\"X-Accel-Buffering\", \"no\");\n headers.set(AI_WIRE_HEADER, AI_WIRE_VERSION);\n return new Response(body, { headers });\n}\n","import type {\n AiFixFinding,\n AiChatAttachment,\n AiMergeTags,\n AiRewritePreset,\n TemplateContent,\n TranslationRequest,\n} from \"@templatical/types\";\nimport { isLocaleTag, SdkError } from \"@templatical/types\";\nimport {\n AI_GENERATE_FIELDS,\n AI_ERROR_MESSAGES,\n AI_LIMITS,\n AI_SESSION_HEADER,\n matchAiRoute,\n type AiFixFindingBody,\n type AiAltTextBody,\n type AiGenerateBody,\n type AiRewriteBody,\n} from \"../wire\";\n\ninterface Base {\n template: string;\n /** `X-Templatical-Session`, when sent. */\n sessionId: string | null;\n}\n\nexport type AiWireRequest = Base &\n (\n | { route: \"translate\"; body: TranslationRequest }\n | { route: \"generate\"; body: AiGenerateBody }\n | { route: \"fixFinding\"; body: AiFixFindingBody }\n | { route: \"rewriteText\"; body: AiRewriteBody }\n | { route: \"altText\"; body: AiAltTextBody }\n | { route: \"conversationMessages\" | \"clearConversation\" }\n | { route: \"undoMessage\" | \"redoMessage\"; message: string }\n | {\n route: \"settleReplay\";\n message: string;\n replayId: string;\n body: { direction: \"undo\" | \"redo\"; applied: boolean };\n }\n );\n\nconst PRESETS: readonly AiRewritePreset[] = [\n \"shorten\",\n \"expand\",\n \"friendlier\",\n \"formal\",\n \"fixGrammar\",\n \"translate\",\n];\nconst IMAGE_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\"];\nconst MERGE_TAG_SYNTAXES = [\n \"liquid\",\n \"handlebars\",\n \"mailchimp\",\n \"ampscript\",\n \"custom\",\n];\n\nfunction invalid(message: string): never {\n throw new SdkError(message, 422);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isBoundedText = (value: unknown, max: number): value is string =>\n typeof value === \"string\" && Array.from(value).length <= max;\n\nfunction content(value: unknown): TemplateContent {\n if (\n !isRecord(value) ||\n !Array.isArray(value.blocks) ||\n !isRecord(value.settings)\n ) {\n invalid(\"content must be the template's content.\");\n }\n if (\n new TextEncoder().encode(JSON.stringify(value)).length >\n AI_LIMITS.contentMaxBytes\n )\n throw new SdkError(\n AI_ERROR_MESSAGES.content_too_large,\n 413,\n \"content_too_large\",\n );\n return value as unknown as TemplateContent;\n}\n\nfunction mergeTags(value: unknown): AiMergeTags {\n if (\n !isRecord(value) ||\n typeof value.syntax !== \"string\" ||\n !MERGE_TAG_SYNTAXES.includes(value.syntax) ||\n !Array.isArray(value.tags) ||\n value.tags.length > AI_LIMITS.mergeTagsMax\n ) {\n invalid(\"mergeTags must be { syntax, tags } with at most 500 tags.\");\n }\n for (const tag of value.tags) {\n if (\n !isRecord(tag) ||\n !isBoundedText(tag.value, 200) ||\n !isBoundedText(tag.label, 100) ||\n (tag.group !== undefined && !isBoundedText(tag.group, 100)) ||\n (tag.description !== undefined && !isBoundedText(tag.description, 200))\n ) {\n invalid(\n \"Each merge tag must have string value (at most 200 characters) and label (at most 100), with optional string group (at most 100) and description (at most 200).\",\n );\n }\n }\n return value as unknown as AiMergeTags;\n}\n\nfunction revision(value: unknown): number | null {\n if (value === null || value === undefined || value === \"\") return null;\n const number = typeof value === \"string\" ? Number(value) : value;\n if (typeof number !== \"number\" || !Number.isInteger(number) || number < 0) {\n invalid(\"revision must be a whole number or null.\");\n }\n return number;\n}\n\nfunction optionalText(\n value: unknown,\n max: number,\n message: string,\n): string | null {\n if (value === null || value === undefined || value === \"\") return null;\n if (typeof value !== \"string\" || Array.from(value).length > max)\n invalid(message);\n return value;\n}\n\nfunction finding(value: unknown): AiFixFinding {\n if (!isRecord(value))\n invalid(\n \"finding must be { ruleId, message, suggestion, reference, blockId, excerpt? }.\",\n );\n if (\n typeof value.ruleId !== \"string\" ||\n value.ruleId.length === 0 ||\n value.ruleId.length > 200\n )\n invalid(\"finding.ruleId must be 1 to 200 characters.\");\n if (!isBoundedText(value.message, 1000))\n invalid(\"finding.message must be at most 1,000 code points.\");\n if (!isBoundedText(value.suggestion, 1000))\n invalid(\"finding.suggestion must be at most 1,000 code points.\");\n let reference: URL | null = null;\n if (typeof value.reference === \"string\" && value.reference.length <= 2048) {\n try {\n reference = new URL(value.reference);\n } catch {\n /* Invalid URL. */\n }\n }\n if (\n !reference ||\n (reference.protocol !== \"http:\" && reference.protocol !== \"https:\")\n )\n invalid(\n \"finding.reference must be an http: or https: URL of at most 2,048 characters.\",\n );\n if (\n value.blockId !== null &&\n (typeof value.blockId !== \"string\" || value.blockId.length > 200)\n )\n invalid(\n \"finding.blockId must be a string of at most 200 characters or null.\",\n );\n if (value.excerpt !== undefined && !isBoundedText(value.excerpt, 1000))\n invalid(\"finding.excerpt must be at most 1,000 code points when present.\");\n return value as unknown as AiFixFinding;\n}\n\n/**\n * The wire's request, parsed and checked against `AI_LIMITS`; `null` when the\n * request is no route of the wire. Field validation throws `SdkError(message, 422)`;\n * oversized content throws `content_too_large` (413).\n */\nexport async function readAiRequest(\n request: Request,\n): Promise<AiWireRequest | null> {\n const match = matchAiRoute(request.method, new URL(request.url).pathname);\n if (!match) return null;\n const base: Base = {\n template: match.template,\n sessionId: request.headers.get(AI_SESSION_HEADER),\n };\n\n if (base.sessionId !== null && !isBoundedText(base.sessionId, 200))\n invalid(\"The session id must be at most 200 characters.\");\n\n switch (match.route) {\n case \"translate\":\n return {\n ...base,\n route: \"translate\",\n body: await readTranslationRequest(request),\n };\n case \"conversationMessages\":\n case \"clearConversation\":\n return { ...base, route: match.route };\n case \"undoMessage\":\n case \"redoMessage\":\n return { ...base, route: match.route, message: match.message ?? \"\" };\n default:\n break;\n }\n\n let attachments: AiChatAttachment[] = [];\n let body: unknown;\n if (\n match.route === \"generate\" &&\n request.headers.get(\"Content-Type\")?.startsWith(\"multipart/form-data\")\n ) {\n const form = await request\n .formData()\n .catch(() => invalid(\"Send the message as multipart form data.\"));\n const files = form.getAll(AI_GENERATE_FIELDS.attachment);\n if (files.length > AI_LIMITS.attachmentsMax)\n invalid(\"Attach at most 4 files.\");\n if (\n files.some(\n (file) =>\n !(file instanceof Blob) ||\n (!IMAGE_TYPES.includes(file.type) && file.type !== \"application/pdf\"),\n )\n )\n invalid(\"Attach PNG, JPEG, WebP or GIF images, or PDFs.\");\n const blobs = files as File[];\n if (blobs.some((file) => file.size > AI_LIMITS.attachmentMaxBytes))\n invalid(\"Each attachment must be 5 MB or smaller.\");\n if (\n blobs.reduce((total, file) => total + file.size, 0) >\n AI_LIMITS.attachmentsTotalMaxBytes\n )\n invalid(\"Attachments must be 10 MB or smaller together.\");\n if (blobs.some((file) => file.name.length > 255))\n invalid(\"An attachment's file name must be at most 255 characters.\");\n attachments = blobs.map((file) => ({\n file,\n kind: file.type === \"application/pdf\" ? \"pdf\" : \"image\",\n name: file.name,\n }));\n const json = (field: string): unknown => {\n const raw = form.get(field);\n if (raw === null) return null;\n try {\n return JSON.parse(String(raw));\n } catch {\n return invalid(`${field} must be JSON.`);\n }\n };\n body = {\n locale: json(AI_GENERATE_FIELDS.locale),\n message: form.get(AI_GENERATE_FIELDS.message),\n selectedBlockId: form.get(AI_GENERATE_FIELDS.selectedBlockId),\n revision: form.get(AI_GENERATE_FIELDS.revision),\n mergeTags: json(AI_GENERATE_FIELDS.mergeTags),\n content: json(AI_GENERATE_FIELDS.content),\n };\n } else {\n body = await request.json().catch(() => invalid(\"Send a JSON body.\"));\n }\n if (!isRecord(body)) invalid(\"Send a JSON object.\");\n\n switch (match.route) {\n case \"altText\": {\n if (\n typeof body.blockId !== \"string\" ||\n body.blockId.length === 0 ||\n body.blockId.length > 200\n )\n invalid(\"blockId must be 1 to 200 characters.\");\n let url: URL | null = null;\n if (\n isRecord(body.image) &&\n typeof body.image.url === \"string\" &&\n body.image.url.length <= 2048\n ) {\n try {\n url = new URL(body.image.url);\n } catch {\n /* Invalid URL. */\n }\n }\n if (!url || (url.protocol !== \"http:\" && url.protocol !== \"https:\"))\n invalid(\n \"image.url must be an http: or https: URL of at most 2,048 characters.\",\n );\n for (const key of [\"currentAlt\", \"linkHref\"] as const) {\n if (\n body[key] !== null &&\n (typeof body[key] !== \"string\" || body[key].length > 2048)\n )\n invalid(\n `${key} must be a string of at most 2,048 characters or null.`,\n );\n }\n if (!isBoundedText(body.nearbyText, 2000))\n invalid(\"nearbyText must be at most 2,000 code points.\");\n if (\n typeof body.locale !== \"string\" ||\n body.locale.length > 35 ||\n !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/.test(body.locale)\n )\n invalid(\"locale must be a BCP 47 tag of at most 35 characters.\");\n if (\n typeof body.maxLength !== \"number\" ||\n !Number.isInteger(body.maxLength) ||\n body.maxLength < 1 ||\n body.maxLength > 1000\n )\n invalid(\"maxLength must be an integer from 1 to 1,000.\");\n return {\n ...base,\n route: \"altText\",\n body: {\n blockId: body.blockId,\n image: { url: (body.image as { url: string }).url },\n currentAlt: body.currentAlt as string | null,\n linkHref: body.linkHref as string | null,\n nearbyText: body.nearbyText,\n locale: body.locale,\n maxLength: body.maxLength,\n },\n };\n }\n case \"settleReplay\": {\n if (\n (body.direction !== \"undo\" && body.direction !== \"redo\") ||\n typeof body.applied !== \"boolean\"\n )\n invalid(\n \"A replay outcome must have direction undo or redo and boolean applied.\",\n );\n return {\n ...base,\n route: \"settleReplay\",\n message: match.message!,\n replayId: match.replayId!,\n body: { direction: body.direction, applied: body.applied },\n };\n }\n case \"generate\": {\n const message = body.message;\n if (\n typeof message !== \"string\" ||\n message.trim() === \"\" ||\n Array.from(message).length > AI_LIMITS.messageMaxChars\n ) {\n invalid(\"The message must be 1 to 4,000 characters.\");\n }\n if (\n typeof body.selectedBlockId === \"string\" &&\n !isBoundedText(body.selectedBlockId, 200)\n )\n invalid(\"selectedBlockId must be at most 200 characters.\");\n return {\n ...base,\n route: \"generate\",\n body: {\n locale: locale(body.locale),\n message,\n attachments,\n selectedBlockId:\n typeof body.selectedBlockId === \"string\"\n ? body.selectedBlockId\n : null,\n mergeTags: mergeTags(body.mergeTags),\n content: content(body.content),\n revision: revision(body.revision),\n },\n };\n }\n case \"fixFinding\":\n return {\n ...base,\n route: \"fixFinding\",\n body: {\n locale: locale(body.locale),\n finding: finding(body.finding),\n mergeTags: mergeTags(body.mergeTags),\n content: content(body.content),\n revision: revision(body.revision),\n },\n };\n case \"rewriteText\": {\n const html = body.html;\n if (\n typeof html !== \"string\" ||\n html === \"\" ||\n Array.from(html).length > AI_LIMITS.rewriteHtmlMaxChars\n ) {\n invalid(\"html must be 1 to 20,000 characters.\");\n }\n const preset = body.preset ?? null;\n if (preset !== null && !PRESETS.includes(preset as AiRewritePreset))\n invalid(\"Unknown preset.\");\n const instruction = optionalText(\n body.instruction,\n AI_LIMITS.instructionMaxChars,\n \"The instruction must be at most 500 characters.\",\n );\n if (preset === null && instruction === null)\n invalid(\"Choose a preset or write an instruction.\");\n const language = body.language === \"\" ? null : (body.language ?? null);\n if (\n language !== null &&\n (typeof language !== \"string\" ||\n !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(language))\n ) {\n invalid(\"language must be a BCP 47 tag.\");\n }\n if (preset === \"translate\" && language === null)\n invalid(\"Choose a language to translate into.\");\n return {\n ...base,\n route: \"rewriteText\",\n body: {\n locale: locale(body.locale),\n html,\n preset: preset as AiRewritePreset | null,\n language,\n instruction,\n mergeTags: mergeTags(body.mergeTags),\n },\n };\n }\n }\n}\n\nfunction locale(value: unknown): string | null {\n if (value === undefined || value === null) return null;\n if (!isLocaleTag(value)) invalid(\"locale must be a BCP 47 tag.\");\n return value;\n}\n\n/** Validate a translation body, including direct calls to `translateFields`. */\nexport function checkTranslationRequest(value: unknown): TranslationRequest {\n if (!isRecord(value)) invalid(\"Send a JSON object.\");\n if (\n value.templateId !== null &&\n (typeof value.templateId !== \"string\" || value.templateId === \"\")\n )\n invalid(\"templateId must be a non-empty string or null.\");\n for (const key of [\"sourceLocale\", \"targetLocale\"] as const) {\n if (locale(value[key]) === null) invalid(`${key} must be a BCP 47 tag.`);\n }\n if (!Array.isArray(value.fields) || value.fields.length === 0)\n invalid(\"fields must be a non-empty array.\");\n if (value.fields.length > AI_LIMITS.translationMaxFields)\n throw new SdkError(\n AI_ERROR_MESSAGES.content_too_large,\n 413,\n \"content_too_large\",\n );\n const ids = new Set<string>();\n let chars = 0;\n for (const field of value.fields) {\n if (\n !isRecord(field) ||\n typeof field.id !== \"string\" ||\n field.id === \"\" ||\n ids.has(field.id) ||\n (field.format !== \"html\" && field.format !== \"text\") ||\n typeof field.text !== \"string\"\n )\n invalid(\n \"Each field must have a unique non-empty id, html or text format, and string text.\",\n );\n ids.add(field.id);\n chars += field.text.length;\n }\n if (chars > AI_LIMITS.translationMaxChars)\n throw new SdkError(\n AI_ERROR_MESSAGES.content_too_large,\n 413,\n \"content_too_large\",\n );\n return value as unknown as TranslationRequest;\n}\n\n/** Read the JSON translation body and enforce the translation call limits. */\nexport async function readTranslationRequest(\n request: Request,\n): Promise<TranslationRequest> {\n const body: unknown = await request\n .json()\n .catch(() => invalid(\"Send a JSON body.\"));\n return checkTranslationRequest(body);\n}\n","import type { TranslationRequest, TranslationResult } from \"@templatical/types\";\nimport { SdkError, unmaskTags } from \"@templatical/types\";\nimport { TRANSLATE_INSTRUCTIONS } from \"../generated/assets\";\nimport { stripFences } from \"./checks\";\nimport type { AiModelAdapter, AiModelMessage } from \"./model\";\nimport { fill } from \"./fill\";\nimport { languageName } from \"./prompts\";\nimport { checkTranslationRequest } from \"./read-request\";\n\nfunction passingFields(\n answer: string,\n request: TranslationRequest,\n): TranslationResult[\"fields\"] {\n let parsed: unknown;\n try {\n parsed = JSON.parse(stripFences(answer).trim());\n } catch {\n return [];\n }\n if (\n typeof parsed !== \"object\" ||\n parsed === null ||\n !Array.isArray((parsed as TranslationResult).fields)\n )\n return [];\n const fields = (parsed as { fields: unknown[] }).fields;\n return request.fields.flatMap((source) => {\n const matches = fields.filter(\n (field): field is { id: string; text?: unknown } =>\n typeof field === \"object\" &&\n field !== null &&\n \"id\" in field &&\n field.id === source.id,\n );\n if (matches.length !== 1 || typeof matches[0].text !== \"string\") return [];\n const text = matches[0].text;\n const tokens = [\n ...source.text.matchAll(\n /<x id=\"(\\d+)\"\\s*\\/>|<x id=\"(\\d+)\"><\\/x>|__x(\\d+)__/g,\n ),\n ].map(() => \"\");\n if (\n unmaskTags(source.text, tokens) === null ||\n unmaskTags(text, tokens) === null\n )\n return [];\n return [{ id: source.id, text }];\n });\n}\n\n/** Translate masked fields, with one repair and a partial result for fields that pass. */\nexport async function translateFields(options: {\n request: TranslationRequest;\n model: AiModelAdapter;\n signal?: AbortSignal;\n}): Promise<TranslationResult> {\n const { model, signal } = options;\n signal?.throwIfAborted();\n const request = checkTranslationRequest(options.request);\n const system = fill(TRANSLATE_INSTRUCTIONS, {\n source: `${languageName(request.sourceLocale)} (${request.sourceLocale})`,\n target: `${languageName(request.targetLocale)} (${request.targetLocale})`,\n });\n const messages: AiModelMessage[] = [\n {\n role: \"user\",\n content: `Translate from ${languageName(request.sourceLocale)} (${request.sourceLocale}) to ${languageName(request.targetLocale)} (${request.targetLocale}).\\n\\nFields:\\n${JSON.stringify(request.fields)}`,\n },\n ];\n const accepted = new Map<string, string>();\n for (let attempt = 0; attempt < 2; attempt++) {\n signal?.throwIfAborted();\n let answer = \"\";\n try {\n for await (const part of model.stream({\n system,\n messages,\n tools: [],\n signal,\n })) {\n signal?.throwIfAborted();\n if (part.type === \"text\") answer += part.text;\n }\n } catch (error) {\n signal?.throwIfAborted();\n if (\n (error instanceof Error && error.name === \"AbortError\") ||\n (typeof error === \"object\" &&\n error !== null &&\n \"code\" in error &&\n typeof error.code === \"string\" &&\n [\n \"rate_limited\",\n \"limit_reached\",\n \"content_too_large\",\n \"translation_failed\",\n ].includes(error.code))\n )\n throw error;\n if (\n !(error instanceof SdkError) ||\n (error.code !== \"ai_provider_error\" &&\n error.code !== \"ai_output_invalid\")\n )\n throw new SdkError(\n \"The translation couldn't produce a valid result. Try again.\",\n 502,\n \"translation_failed\",\n );\n }\n signal?.throwIfAborted();\n const pending = {\n ...request,\n fields: request.fields.filter((field) => !accepted.has(field.id)),\n };\n for (const field of passingFields(answer, pending))\n accepted.set(field.id, field.text);\n const failed = request.fields\n .filter((field) => !accepted.has(field.id))\n .map((field) => field.id);\n if (failed.length === 0) break;\n messages.push(\n { role: \"assistant\", content: answer },\n {\n role: \"user\",\n content: `Return complete JSON {\"fields\":[{\"id\":\"…\",\"text\":\"…\"}]} for these failed ids only: ${JSON.stringify(failed)}. Preserve every placeholder exactly once.`,\n },\n );\n }\n if (accepted.size === 0)\n throw new SdkError(\n \"The translation couldn't produce a valid result. Try again.\",\n 502,\n \"translation_failed\",\n );\n return {\n fields: request.fields.flatMap(({ id }) =>\n accepted.has(id) ? [{ id, text: accepted.get(id)! }] : [],\n ),\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as AI_ERROR_MESSAGES } from "./wire-
|
|
2
|
-
import { i as parseRetryAfter } from "./errors-
|
|
1
|
+
import { t as AI_ERROR_MESSAGES } from "./wire-CPO4E3P9.js";
|
|
2
|
+
import { i as parseRetryAfter } from "./errors-BOrlo5G2.js";
|
|
3
3
|
import { SdkError, ThrottledError } from "@templatical/types";
|
|
4
4
|
// src/adapters/shared.ts
|
|
5
5
|
/** The content an adapter stored as `{ adapter, content }` state, if it's this adapter's. */
|
|
@@ -57,4 +57,4 @@ function providerError(error) {
|
|
|
57
57
|
}
|
|
58
58
|
export { refused as a, providerError as i, cutOff as n, toolArguments as o, ownState as r, toolInput as s, blobBase64 as t };
|
|
59
59
|
|
|
60
|
-
//# sourceMappingURL=shared-
|
|
60
|
+
//# sourceMappingURL=shared-Dzp1W1tL.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared-
|
|
1
|
+
{"version":3,"file":"shared-Dzp1W1tL.js","names":[],"sources":["../src/adapters/shared.ts"],"sourcesContent":["import { SdkError, ThrottledError } from \"@templatical/types\";\nimport { parseRetryAfter } from \"../client/errors\";\nimport type { AiModelToolCall } from \"../server/model\";\nimport { AI_ERROR_MESSAGES } from \"../wire\";\n\n/** The content an adapter stored as `{ adapter, content }` state, if it's this adapter's. */\nexport function ownState<T>(state: unknown, adapter: string): T[] | undefined {\n if (typeof state !== \"object\" || state === null) return undefined;\n const tagged = state as { adapter?: unknown; content?: unknown };\n return tagged.adapter === adapter && Array.isArray(tagged.content)\n ? (tagged.content as T[])\n : undefined;\n}\n\n/** A tool call's arguments as an object (`{}` when the string isn't a JSON object). */\nexport function toolInput(\n input: AiModelToolCall[\"input\"],\n): Record<string, unknown> {\n if (typeof input !== \"string\") return input;\n try {\n const parsed: unknown = JSON.parse(input);\n return typeof parsed === \"object\" &&\n parsed !== null &&\n !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {};\n } catch {\n return {};\n }\n}\n\n/** A tool call's arguments as a JSON string. */\nexport function toolArguments(input: AiModelToolCall[\"input\"]): string {\n return typeof input === \"string\" ? input : JSON.stringify(input);\n}\n\n/** A Blob's bytes as base64, with web APIs only (the kit is isomorphic). */\nexport async function blobBase64(blob: Blob): Promise<string> {\n const bytes = new Uint8Array(await blob.arrayBuffer());\n let binary = \"\";\n for (let start = 0; start < bytes.length; start += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));\n }\n return btoa(binary);\n}\n\n/** The model refused or a content filter stopped it. */\nexport function refused(): SdkError {\n return new SdkError(\n AI_ERROR_MESSAGES.ai_provider_error,\n 502,\n \"ai_provider_error\",\n );\n}\n\n/** The output hit the token limit while a tool call was being written. */\nexport function cutOff(): SdkError {\n return new SdkError(\n AI_ERROR_MESSAGES.ai_output_invalid,\n 422,\n \"ai_output_invalid\",\n );\n}\n\nfunction retryAfterHeader(headers: unknown): string | null {\n if (typeof Headers !== \"undefined\" && headers instanceof Headers)\n return headers.get(\"retry-after\");\n if (typeof headers !== \"object\" || headers === null) return null;\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() === \"retry-after\" && typeof value === \"string\")\n return value;\n }\n return null;\n}\n\n/**\n * A provider's 429 (after the client's own retries) as the kit's\n * `ThrottledError`; anything else unchanged, so it keeps its own details.\n * Reads the provider SDKs' `status`/`headers` and the AI SDK's\n * `statusCode`/`responseHeaders`, unwrapping a retry error's `lastError`.\n */\nexport function providerError(error: unknown): unknown {\n if (typeof error !== \"object\" || error === null) return error;\n const failure = ((error as { lastError?: unknown }).lastError ?? error) as {\n status?: unknown;\n statusCode?: unknown;\n headers?: unknown;\n responseHeaders?: unknown;\n };\n if ((failure.status ?? failure.statusCode) !== 429) return error;\n return new ThrottledError(\n AI_ERROR_MESSAGES.rate_limited,\n parseRetryAfter(\n retryAfterHeader(failure.headers ?? failure.responseHeaders),\n ),\n );\n}\n"],"mappings":";;;;;AAMA,SAAgB,SAAY,OAAgB,SAAkC;CAC5E,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO,KAAA;CACxD,MAAM,SAAS;CACf,OAAO,OAAO,YAAY,WAAW,MAAM,QAAQ,OAAO,OAAO,IAC5D,OAAO,UACR,KAAA;AACN;;AAGA,SAAgB,UACd,OACyB;CACzB,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,IAAI;EACF,MAAM,SAAkB,KAAK,MAAM,KAAK;EACxC,OAAO,OAAO,WAAW,YACvB,WAAW,QACX,CAAC,MAAM,QAAQ,MAAM,IAClB,SACD,CAAC;CACP,QAAQ;EACN,OAAO,CAAC;CACV;AACF;;AAGA,SAAgB,cAAc,OAAyC;CACrE,OAAO,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;AACjE;;AAGA,eAAsB,WAAW,MAA6B;CAC5D,MAAM,QAAQ,IAAI,WAAW,MAAM,KAAK,YAAY,CAAC;CACrD,IAAI,SAAS;CACb,KAAK,IAAI,QAAQ,GAAG,QAAQ,MAAM,QAAQ,SAAS,OACjD,UAAU,OAAO,aAAa,GAAG,MAAM,SAAS,OAAO,QAAQ,KAAM,CAAC;CAExE,OAAO,KAAK,MAAM;AACpB;;AAGA,SAAgB,UAAoB;CAClC,OAAO,IAAI,SACT,kBAAkB,mBAClB,KACA,mBACF;AACF;;AAGA,SAAgB,SAAmB;CACjC,OAAO,IAAI,SACT,kBAAkB,mBAClB,KACA,mBACF;AACF;AAEA,SAAS,iBAAiB,SAAiC;CACzD,IAAI,OAAO,YAAY,eAAe,mBAAmB,SACvD,OAAO,QAAQ,IAAI,aAAa;CAClC,IAAI,OAAO,YAAY,YAAY,YAAY,MAAM,OAAO;CAC5D,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,OAAO,GAChD,IAAI,KAAK,YAAY,MAAM,iBAAiB,OAAO,UAAU,UAC3D,OAAO;CAEX,OAAO;AACT;;;;;;;AAQA,SAAgB,cAAc,OAAyB;CACrD,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CACxD,MAAM,UAAY,MAAkC,aAAa;CAMjE,KAAK,QAAQ,UAAU,QAAQ,gBAAgB,KAAK,OAAO;CAC3D,OAAO,IAAI,eACT,kBAAkB,cAClB,gBACE,iBAAiB,QAAQ,WAAW,QAAQ,eAAe,CAC7D,CACF;AACF"}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as AiModelAdapter } from "../model-VqqOlLS2.js";
|
|
2
2
|
import { t as HttpAiFeatures } from "../http-provider-B5riig95.js";
|
|
3
|
-
import { C as AiWireEvent } from "../wire-
|
|
4
|
-
import { AiAltTextRequest, AiAppliedBatch, AiChatAttachment, AiChatRequest, AiFixFinding, AiFixRequest, AiMergeTags, AiOperationsResult, AiProvider, AiRewriteRequest, AiTurnHandlers, Operation, TemplateContent } from "@templatical/types";
|
|
3
|
+
import { C as AiWireEvent } from "../wire-JnPcAbtN.js";
|
|
4
|
+
import { AiAltTextRequest, AiAppliedBatch, AiChatAttachment, AiChatRequest, AiFixFinding, AiFixRequest, AiMergeTags, AiOperationsResult, AiProvider, AiRewriteRequest, AiTurnHandlers, Operation, TemplateContent, TranslationProvider } from "@templatical/types";
|
|
5
5
|
interface AiConformanceCheck {
|
|
6
6
|
name: string;
|
|
7
7
|
ok: boolean;
|
|
@@ -12,6 +12,8 @@ interface AiConformanceReport {
|
|
|
12
12
|
checks: AiConformanceCheck[];
|
|
13
13
|
}
|
|
14
14
|
interface AiConformanceOptions {
|
|
15
|
+
/** Active language for the probes; null for the default view. */
|
|
16
|
+
locale?: string | null;
|
|
15
17
|
/** The template the suite edits; the test set's welcome email by default. */
|
|
16
18
|
content?: TemplateContent;
|
|
17
19
|
templateId?: string;
|
|
@@ -30,6 +32,8 @@ interface AiConformanceOptions {
|
|
|
30
32
|
*/
|
|
31
33
|
export declare function runAiProviderConformance(provider: AiProvider, options?: AiConformanceOptions): Promise<AiConformanceReport>;
|
|
32
34
|
interface AiWireConformanceOptions {
|
|
35
|
+
/** Active language for the probes; de by default, null for the default view. */
|
|
36
|
+
locale?: string | null;
|
|
33
37
|
baseUrl: string;
|
|
34
38
|
features?: HttpAiFeatures;
|
|
35
39
|
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
|
|
@@ -261,5 +265,18 @@ interface StandInEditor {
|
|
|
261
265
|
};
|
|
262
266
|
}
|
|
263
267
|
export declare function createStandInEditor(content: TemplateContent, mergeTags?: AiMergeTags): StandInEditor;
|
|
268
|
+
/** Deterministic recorded translations, or a language prefix with placeholders intact. */
|
|
269
|
+
export declare function createMockTranslationProvider(options?: {
|
|
270
|
+
translations?: Record<string, Record<string, string>>;
|
|
271
|
+
delayMs?: number;
|
|
272
|
+
}): TranslationProvider;
|
|
273
|
+
/** Probe the translation contract without a DOM or a model-specific dependency. */
|
|
274
|
+
export declare function runTranslationConformance(provider: TranslationProvider, options?: {
|
|
275
|
+
locale?: string;
|
|
276
|
+
}): Promise<{
|
|
277
|
+
name: string;
|
|
278
|
+
ok: boolean;
|
|
279
|
+
detail?: string;
|
|
280
|
+
}[]>;
|
|
264
281
|
export type { AiConformanceCheck, AiConformanceOptions, AiConformanceReport, AiEvalAltCheck, AiEvalAltTextInput, AiEvalCase, AiEvalCheck, AiEvalReport, AiEvalTurnInput, AiEvalTurnOutput, AiMockScript, AiRecordedEvent, AiRecordedTurn, AiRecording, AiWireConformanceOptions, StandInEditor };
|
|
265
282
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/testing/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { l as aiRoutePath, o as AI_SESSION_HEADER, s as AI_WIRE_HEADER, t as AI_ERROR_MESSAGES, u as matchAiRoute } from "../wire-
|
|
2
|
-
import { n as errorFromEvent } from "../errors-
|
|
3
|
-
import { n as createHttpAiProviderWithFetch } from "../http-provider-
|
|
4
|
-
import {
|
|
5
|
-
import { CollaborationConflict, OperationError, SdkError, applyOperations, createButtonBlock, createParagraphBlock, createTitleBlock, invertOperations, safeClone } from "@templatical/types";
|
|
1
|
+
import { l as aiRoutePath, o as AI_SESSION_HEADER, s as AI_WIRE_HEADER, t as AI_ERROR_MESSAGES, u as matchAiRoute } from "../wire-CPO4E3P9.js";
|
|
2
|
+
import { n as errorFromEvent } from "../errors-BOrlo5G2.js";
|
|
3
|
+
import { n as createHttpAiProviderWithFetch } from "../http-provider-BWVLNpZn.js";
|
|
4
|
+
import { _ as buildChatRequest, g as buildAltTextRequest, i as checkAltTextAnswer, m as walkBlocks, r as checkAltText, s as runEditingTurn, t as buildAltTextRepairRequest, y as buildFixRequest } from "../checks-BM_JnvRo.js";
|
|
5
|
+
import { CollaborationConflict, OperationError, SYNTAX_PRESETS, SdkError, applyOperations, createButtonBlock, createParagraphBlock, createTitleBlock, findOpenTagEnd, invertOperations, maskTags, resolveLocale, safeClone, unmaskTags } from "@templatical/types";
|
|
6
6
|
// package.json
|
|
7
|
-
var version = "0.
|
|
7
|
+
var version = "0.54.0";
|
|
8
8
|
// eval/cases.json
|
|
9
9
|
var cases_default = [
|
|
10
10
|
{
|
|
@@ -1576,11 +1576,15 @@ async function runAiProviderConformance(provider, options = {}) {
|
|
|
1576
1576
|
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
1577
1577
|
const callTimeoutMs = Math.min(options.callTimeoutMs ?? timeoutMs, timeoutMs);
|
|
1578
1578
|
const templateId = options.templateId ?? "conformance";
|
|
1579
|
-
const
|
|
1579
|
+
const locale = options.locale ?? null;
|
|
1580
|
+
const content = safeClone(options.content ?? AI_EVAL_TEMPLATES.welcome);
|
|
1581
|
+
if (locale !== null && locale !== (content.settings.locale ?? "en") && !content.settings.locales?.includes(locale)) content.settings.locales = [...content.settings.locales ?? [], locale];
|
|
1582
|
+
const editor = createStandInEditor(locale === null ? content : resolveLocale(content, locale), options.mergeTags ?? AI_EVAL_MERGE_TAGS);
|
|
1580
1583
|
const errors = [];
|
|
1581
1584
|
const probes = {};
|
|
1582
1585
|
const base = () => ({
|
|
1583
1586
|
templateId,
|
|
1587
|
+
locale,
|
|
1584
1588
|
content: safeClone(editor.content),
|
|
1585
1589
|
revision: null,
|
|
1586
1590
|
sessionId: editor.sessionId,
|
|
@@ -1817,7 +1821,7 @@ async function runAiProviderConformance(provider, options = {}) {
|
|
|
1817
1821
|
currentAlt: null,
|
|
1818
1822
|
linkHref: null,
|
|
1819
1823
|
nearbyText: "Explore our email editor.",
|
|
1820
|
-
locale: editor.content.settings.locale ?? "en",
|
|
1824
|
+
locale: options.locale ?? editor.content.settings.locale ?? "en",
|
|
1821
1825
|
maxLength: 125
|
|
1822
1826
|
};
|
|
1823
1827
|
probes.altText = (signal) => altText(request, signal);
|
|
@@ -2039,6 +2043,7 @@ async function runAiWireConformance(options) {
|
|
|
2039
2043
|
features: options.features
|
|
2040
2044
|
}, observedFetch), {
|
|
2041
2045
|
templateId,
|
|
2046
|
+
locale: options.locale === void 0 ? "de" : options.locale,
|
|
2042
2047
|
altTextImageUrl: options.altTextImageUrl ?? AI_CONFORMANCE_ALT_TEXT_IMAGE_URL,
|
|
2043
2048
|
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
|
|
2044
2049
|
...options.callTimeoutMs !== void 0 ? { callTimeoutMs: options.callTimeoutMs } : {}
|
|
@@ -2425,6 +2430,134 @@ const AI_RECORDINGS = {
|
|
|
2425
2430
|
];
|
|
2426
2431
|
}
|
|
2427
2432
|
};
|
|
2428
|
-
|
|
2433
|
+
// src/testing/mock-translation-provider.ts
|
|
2434
|
+
function prefixHtml(text, prefix) {
|
|
2435
|
+
let cursor = 0;
|
|
2436
|
+
while (cursor < text.length) if (text[cursor] === "<") {
|
|
2437
|
+
const end = findOpenTagEnd(text, cursor + 1);
|
|
2438
|
+
if (end < 0) break;
|
|
2439
|
+
cursor = end + 1;
|
|
2440
|
+
} else {
|
|
2441
|
+
const end = text.indexOf("<", cursor);
|
|
2442
|
+
if (text.slice(cursor, end < 0 ? text.length : end).trim()) return text.slice(0, cursor) + prefix + text.slice(cursor);
|
|
2443
|
+
cursor = end < 0 ? text.length : end;
|
|
2444
|
+
}
|
|
2445
|
+
return text + prefix;
|
|
2446
|
+
}
|
|
2447
|
+
/** Deterministic recorded translations, or a language prefix with placeholders intact. */
|
|
2448
|
+
function createMockTranslationProvider(options = {}) {
|
|
2449
|
+
return { async translate(request, signal) {
|
|
2450
|
+
signal.throwIfAborted();
|
|
2451
|
+
if ((options.delayMs ?? 0) > 0) await new Promise((resolve, reject) => {
|
|
2452
|
+
const stop = () => {
|
|
2453
|
+
clearTimeout(timer);
|
|
2454
|
+
reject(new DOMException("The translation was stopped.", "AbortError"));
|
|
2455
|
+
};
|
|
2456
|
+
const timer = setTimeout(() => {
|
|
2457
|
+
signal.removeEventListener("abort", stop);
|
|
2458
|
+
resolve();
|
|
2459
|
+
}, options.delayMs);
|
|
2460
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
2461
|
+
});
|
|
2462
|
+
signal.throwIfAborted();
|
|
2463
|
+
return { fields: request.fields.map(({ id, text, format }) => ({
|
|
2464
|
+
id,
|
|
2465
|
+
text: options.translations?.[request.targetLocale]?.[id] ?? (format === "html" ? prefixHtml(text, `[${request.targetLocale}] `) : `[${request.targetLocale}] ${text}`)
|
|
2466
|
+
})) };
|
|
2467
|
+
} };
|
|
2468
|
+
}
|
|
2469
|
+
// src/testing/translation-conformance.ts
|
|
2470
|
+
/** Probe the translation contract without a DOM or a model-specific dependency. */
|
|
2471
|
+
async function runTranslationConformance(provider, options = {}) {
|
|
2472
|
+
const checks = [];
|
|
2473
|
+
const source = [{
|
|
2474
|
+
id: "html",
|
|
2475
|
+
format: "html",
|
|
2476
|
+
text: "<p>Hello <span data-merge-tag=\"{{name}}\">Name</span> <a href=\"{{url}}\">Shop</a></p>"
|
|
2477
|
+
}, {
|
|
2478
|
+
id: "text",
|
|
2479
|
+
format: "text",
|
|
2480
|
+
text: "Hello {{name}}"
|
|
2481
|
+
}];
|
|
2482
|
+
const masked = source.map((field) => maskTags(field.text, SYNTAX_PRESETS.liquid, field.format));
|
|
2483
|
+
const fields = source.map((field, i) => ({
|
|
2484
|
+
...field,
|
|
2485
|
+
text: masked[i].text
|
|
2486
|
+
}));
|
|
2487
|
+
const request = (fields) => ({
|
|
2488
|
+
templateId: null,
|
|
2489
|
+
sourceLocale: "en",
|
|
2490
|
+
targetLocale: options.locale ?? "de",
|
|
2491
|
+
fields
|
|
2492
|
+
});
|
|
2493
|
+
const call = (fields) => withConformanceDeadline("translation", 5e3, (signal) => provider.translate(request(fields), signal));
|
|
2494
|
+
const shape = (result, input) => result !== null && typeof result === "object" && Array.isArray(result.fields) && result.fields.every((field) => field !== null && typeof field === "object" && typeof field.id === "string" && typeof field.text === "string" && input.some(({ id }) => id === field.id)) && new Set(result.fields.map(({ id }) => id)).size === result.fields.length;
|
|
2495
|
+
const add = async (name, evaluate) => {
|
|
2496
|
+
checks.push(await evaluateConformanceCheck(name, evaluate));
|
|
2497
|
+
};
|
|
2498
|
+
await add("placeholders round-trip", async () => {
|
|
2499
|
+
const result = await call(fields);
|
|
2500
|
+
return { ok: shape(result, fields) && result.fields.length === fields.length && fields.every((field, i) => {
|
|
2501
|
+
const text = result.fields.find(({ id }) => id === field.id)?.text;
|
|
2502
|
+
return typeof text === "string" && unmaskTags(text, masked[i].tokens) !== null;
|
|
2503
|
+
}) };
|
|
2504
|
+
});
|
|
2505
|
+
await add("HTML stays within the allow-list", async () => {
|
|
2506
|
+
const result = await call([fields[0]]);
|
|
2507
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
2508
|
+
"p",
|
|
2509
|
+
"strong",
|
|
2510
|
+
"b",
|
|
2511
|
+
"em",
|
|
2512
|
+
"i",
|
|
2513
|
+
"u",
|
|
2514
|
+
"s",
|
|
2515
|
+
"a",
|
|
2516
|
+
"br",
|
|
2517
|
+
"span"
|
|
2518
|
+
]);
|
|
2519
|
+
const text = result?.fields?.[0]?.text;
|
|
2520
|
+
if (!shape(result, [fields[0]]) || typeof text !== "string") return { ok: false };
|
|
2521
|
+
const clean = unmaskTags(text, masked[0].tokens.map(() => ""));
|
|
2522
|
+
return { ok: clean !== null && [...clean.matchAll(/<\/?([a-z][\w:-]*)\b/gi)].every((match) => allowed.has(match[1].toLowerCase())) };
|
|
2523
|
+
});
|
|
2524
|
+
await add("a partial result is accepted", async () => {
|
|
2525
|
+
const input = [{
|
|
2526
|
+
id: "partial-a",
|
|
2527
|
+
format: "text",
|
|
2528
|
+
text: "Hello"
|
|
2529
|
+
}, {
|
|
2530
|
+
id: "partial-b",
|
|
2531
|
+
format: "text",
|
|
2532
|
+
text: "Shop"
|
|
2533
|
+
}];
|
|
2534
|
+
return { ok: shape(await call(input), input) };
|
|
2535
|
+
});
|
|
2536
|
+
await add("51 fields are rejected or translated in full", async () => {
|
|
2537
|
+
const input = Array.from({ length: 51 }, (_, i) => ({
|
|
2538
|
+
id: `limit-${i}`,
|
|
2539
|
+
format: "text",
|
|
2540
|
+
text: "Hello"
|
|
2541
|
+
}));
|
|
2542
|
+
try {
|
|
2543
|
+
const result = await call(input);
|
|
2544
|
+
return { ok: shape(result, input) && result.fields.length === input.length };
|
|
2545
|
+
} catch (error) {
|
|
2546
|
+
return { ok: typeof error === "object" && error !== null && "code" in error && error.code === "content_too_large" };
|
|
2547
|
+
}
|
|
2548
|
+
});
|
|
2549
|
+
await add("an aborted signal rejects with AbortError", async () => {
|
|
2550
|
+
const controller = new AbortController();
|
|
2551
|
+
controller.abort();
|
|
2552
|
+
try {
|
|
2553
|
+
await withConformanceDeadline("translation abort", 5e3, (signal) => provider.translate(request(fields), signal), controller);
|
|
2554
|
+
return { ok: false };
|
|
2555
|
+
} catch (error) {
|
|
2556
|
+
return { ok: error instanceof Error && error.name === "AbortError" };
|
|
2557
|
+
}
|
|
2558
|
+
});
|
|
2559
|
+
return checks;
|
|
2560
|
+
}
|
|
2561
|
+
export { AI_CONFORMANCE_ALT_TEXT_IMAGE_URL, AI_EVAL_CASES, AI_EVAL_MERGE_TAGS, AI_EVAL_TEMPLATES, AI_RECORDINGS, checkEvalCase, createMockAiProvider, createMockTranslationProvider, createStandInEditor, evalAltTextWith, evalTurnWith, runAiEvalSet, runAiProviderConformance, runAiWireConformance, runTranslationConformance };
|
|
2429
2562
|
|
|
2430
2563
|
//# sourceMappingURL=index.js.map
|