ai
import { runAi, runAiText, generateAltText, rewriteText, suggestSeo } from "louise-toolkit/ai";Optional Workers AI editorial assists and semantic search. Every helper
degrades gracefully: with no env.AI binding, or on any model error, it
returns null / []—a save, upload, or publish is never blocked or broken by
AI. The binding is passed in and the model id is a plain string, so the module is
catalog-agnostic. Binding: AI (+ VECTORIZE for search). No required peers.
See the AI assists guide.
Every generation helper here returns plain text, never HTML, so none of their
output needs an HTML sanitizer. If your own code asks a model for HTML, run it
through sanitizeModelHtml before
you store it.
aiRunner(env)—turning generation off
Section titled “aiRunner(env)—turning generation off”function aiRunner(env: unknown): AiRunner | undefined;function aiGenerationDisabled(env: unknown): boolean;function aiUnavailableReason(env: unknown): "disabled" | "unconfigured";Binding presence is a real switch, and for “this site never uses AI” it is arguably the right one—nothing to configure, nothing to drift. It stops working the moment you want to keep the binding and still disable generation: an embeddings-backed search that must keep running while alt-text and SEO suggestions go quiet, a client whose contract forbids generated copy, or a temporary kill after a bad model swap.
Set LOUISE_AI in your vars and redeploy:
{ "vars": { "LOUISE_AI": "off" } }Then wire the accessor through aiRunner rather than reading the binding
directly:
aiRoute({ resolveEditor, ai: aiRunner });seoFixRoute({ table: pages, resolveEditor, ai: aiRunner });mediaRoute({ /* … */ altText: aiRunner });Astroid-generated workers do this for you.
All three take unknown rather than a typed env. They are passed as a route’s
accessor, whose parameter is that route’s own Env—and a parameter typed
{ AI?, LOUISE_AI? } shares no properties with an EditorRouteEnv, so
TypeScript rejects the assignment outright. Describing the env precisely would
make the helper unusable in the only position it exists for.
Three things worth knowing.
It cannot turn AI on. LOUISE_AI only ever subtracts—with no binding there
is nothing to enable. That keeps the env var a ceiling rather than a second
source of truth about whether AI works.
Embeddings are deliberately not gated by it. Semantic search generates no
content, and folding it in would mean disabling “AI content” silently breaks
search—a consequence nobody predicts from the flag’s name, and one that
surfaces as “search returns nothing” long after the flag was flipped. Keep
vector.ai on the binding:
vector: { index: (env) => env.VECTORIZE, ai: (env) => env.AI }A site that genuinely wants everything off can still unprovision the binding.
off, false, 0, no, and disabled all mean off, case- and
space-insensitively. There is no matching leniency in the other direction: every
other value (including unset) means on, so no typo can accidentally disable AI—only spell “off” correctly in more than one way. The failure this avoids is a
kill switch that silently doesn’t engage.
Both “off by choice” and “never configured” answer 503, which the client reads
as “hide the control”. The response carries reason so the two can be told
apart—right for an unprovisioned binding, where there is nothing to tell the
editor about, and honest for a deliberate opt-out, where the answer is “AI
assists are turned off for this site”.
runAi(runner, model, inputs, options?)
Section titled “runAi(runner, model, inputs, options?)”function runAi( runner: AiRunner | undefined, model: string, inputs: Record<string, unknown>, options?: Record<string, unknown>,): Promise<unknown | null>;The low-level call: runs a model best-effort and returns its raw output, or
null when runner is absent or the call throws (never throws—it reports
the cause with reportDegraded
as ai.run, so it shows in wrangler tail). env.AI satisfies AiRunner structurally—pass it directly. AiGatewayOptions ({ id, cacheKey?, cacheTtl?, skipCache? })
routes a call through AI Gateway
for response caching, cost caps, fallbacks, and logging.
runAiText(runner, model, inputs, options?)
Section titled “runAiText(runner, model, inputs, options?)”function runAiText( runner: AiRunner | undefined, model: string, inputs: Record<string, unknown>, options?: Record<string, unknown>,): Promise<AiTextResult | null>;
interface AiTextResult { output: unknown; // the raw model output text: string | null; truncated: boolean; finishReason: string | null; usage: AiUsage | null;}
interface AiUsage { promptTokens: number | null; completionTokens: number | null; totalTokens: number | null;}runAi for text generation: the same null on a missing runner or a thrown
error, plus whether the output token cap cut the answer off. The helpers below
use it, and it’s the call to reach for when you generate text yourself.
An answer is truncated when either is true:
- The model reports a finish reason of
lengthormax_tokens, asfinish_reasonorstop_reason, at the top level or on the first ofchoices. - The model generated at least the
max_tokensininputs, read fromusage.completion_tokensorusage.output_tokens.
Workers AI doesn’t always report a finish reason, so the token count is the
check that works on every model that reports usage. A model that reports
neither can’t be checked, and reads as not truncated. A truncated answer is
reported with reportDegraded
as ai.truncated, with the model ID, its finish reason, and the tokens it
generated against the cap, so it shows in wrangler tail and an
onDegraded listener hears it.
Check truncated before you store or show the text:
const out = await runAiText(env.AI, model, { messages, max_tokens: 256 });if (!out?.text || out.truncated) return null; // keep what you hadgenerateAltText(runner, image, opts?)
Section titled “generateAltText(runner, image, opts?)”function generateAltText( runner, image: ArrayBuffer | Uint8Array | number[], opts?: AltTextOptions,): Promise<string | null>;Generate concise alt text for an image via a vision model
(DEFAULT_ALT_TEXT_MODEL). The result is tidied—whitespace-collapsed,
“an image of…” lead-ins stripped, sentence-cased, and capped at
MAX_ALT_TEXT_LENGTH (240) chars. null when the runner is absent, the model
errors, it yields no text, or the output cap cut its answer off; the caller keeps
its empty-alt fallback. A cut-off caption is never returned, because tidying it
would make half a sentence look finished.
rewriteText(runner, text, opts?)
Section titled “rewriteText(runner, text, opts?)”function rewriteText(runner, text: string, opts?: RewriteOptions): Promise<string | null>;
type RewriteMode = "tighten" | "rephrase" | "simplify" | "fix";Rewrite a passage via an instruct model (DEFAULT_TEXT_MODEL), transforming it
per opts.mode (default "tighten"). The reply is stripped of wrapping quotes
and any “Here is the rewrite:” preamble. REWRITE_MODES lists the four modes in
menu order (for a toolbar). null when the runner is absent, the input is blank,
the model returns nothing, or the output cap cut its answer off—the caller keeps
the original text rather than swapping in a fragment.
The output cap defaults to REWRITE_MAX_TOKENS (512), and a passage longer than
REWRITE_MAX_CHARS (1,536 characters, three per token of the cap) is likely to
come back cut off. aiRoute refuses a longer selection with a 413; if you call
rewriteText yourself, bound the input the same way, or raise maxTokens with
it.
const tighter = await rewriteText(env.AI, draft, { mode: "tighten" });suggestSeo(runner, content, opts?)
Section titled “suggestSeo(runner, content, opts?)”function suggestSeo(runner, content: string, opts?: SeoOptions): Promise<SeoSuggestion | null>;
interface SeoSuggestion { title: string | null; description: string | null;}Suggest an SEO title + meta description from page content, using Workers AI JSON
mode to force a { title, description } object. Fields are length-capped
(SEO_TITLE_MAX 60, SEO_DESCRIPTION_MAX 155, the same limits
louise-toolkit/seo uses); a missing field becomes null,
and a result with neither is null overall. null when the runner is absent,
the content is blank, the output cap cut the reply off, or the reply can’t be
parsed.
Semantic search (embeddings)
Section titled “Semantic search (embeddings)”import { embed, indexContent, semanticSearch, removeContentVector } from "louise-toolkit/ai";
function embed(runner, text, opts?): Promise<number[] | null>;function indexContent(index, runner, namespace, id, text, opts?): Promise<boolean>;function semanticSearch( index, runner, namespace, query, opts?,): Promise<{ id: number; score: number }[]>;function removeContentVector(index, namespace, id): Promise<void>;Embeddings + a Cloudflare Vectorize index, sitting alongside the keyword
(D1 FTS5) layer: keywords find tokens, embeddings find intent. embed turns text
into a dense vector (DEFAULT_EMBEDDING_MODEL—768-dim bge-base-en-v1.5;
create the index with matching --dimensions=768 --metric=cosine).
indexContent embeds and upserts a content row under its namespace:id;
semanticSearch embeds a query and returns the nearest row ids + scores;
removeContentVector drops a row’s vector on unpublish/delete. All best-effort:
no binding → embed/search return null/[] so the FTS path carries the
query unchanged, never on a write’s critical path.
Many texts at once
Section titled “Many texts at once”import { embedMany, indexContents, EMBED_MANY_BATCH } from "louise-toolkit/ai";
function embedMany(runner, texts, opts?): Promise<(number[] | null)[]>;function indexContents( index, runner, namespace, items: { id: number; text: string; metadata?: Record<string, VectorMetadataValue> }[], opts?,): Promise<number[]>;embedMany returns the vector embed would give each text, one entry per
input, in order: the vector, or null for a blank text or a failed call, so you
can retry just the null entries. indexContents is the batch form of
indexContent, for a backfill: it embeds with embedMany, upserts the vectors,
and returns the row IDs it stored. Use embed and indexContent for one row at
a time, such as on publish.
Batching needs CLS pooling. Workers AI’s BGE models pool token vectors by
their mean unless you ask otherwise, and under mean pooling a batch changes
each text’s vector: a short text batched with a longer one comes back
measurably different, and matches its queries less well. So embedMany sends
one text per call by default. Pass pooling: "cls", the pooling BGE was
trained with, and it sends batchSize texts per call (default
EMBED_MANY_BATCH, 100, the most the BGE models take per request), with the
same vectors as one at a time.
Mean and CLS vectors don’t mix, so pass the same pooling to every call that
writes to an index and to semanticSearch on it. To move an existing index to
CLS, re-embed every row with indexContents and pooling: "cls", then switch
your searches.
semanticSearch takes an optional minScore: a match scored below it is
dropped, so a query that matches nothing returns [] rather than the topK
least-distant records. On a cosine index the score is a similarity, where
higher is closer. It has no default, because a useful floor depends on the model
and the content; measure one against real queries before you set it.
Rank fusion
Section titled “Rank fusion”import { fuseRankings, RRF_K } from "louise-toolkit/ai";
function fuseRankings<Id extends string | number>( lists: readonly { ids: readonly Id[]; weight?: number }[], options?: { k?: number; limit?: number; boost?: (id: Id) => number },): Id[];Merge ranked ID lists with Reciprocal Rank Fusion (RRF), the merge
searchRoute uses for keyword and semantic results. An
ID scores weight / (k + rank) in each list it appears in, summed, so a list’s
native scores never need to be comparable with another’s. IDs can be numbers or
strings.
weightscales one list’s contribution. The default is 1.kis the rank-damping constant,RRF_K(60) by default. A smallerkfavors top ranks more.boostmultiplies each ID’s fused score, for a signal that belongs to the document rather than a list, such as an authority weight. It runs beforelimit.limitcaps how many IDs come back.
Equal scores order by ID, so the result is deterministic. A repeated ID in one
list counts only at its first position. A negative or non-finite k, weight,
or boost result, or a limit that isn’t a non-negative integer, throws a
RangeError.
const top = fuseRankings([{ ids: keywordSlugs }, { ids: semanticSlugs, weight: 0.8 }], { boost: (slug) => authority.get(slug) ?? 1, limit: 10,});AiRunner, AiGatewayOptions, AiTextResult, AiUsage, AltTextOptions, RewriteMode, RewriteOptions,
SeoSuggestion, SeoOptions, EmbedOptions, EmbeddingPooling, VectorIndex, VectorRecord,
VectorMatch, IndexContentOptions, SemanticSearchOptions, RankedList,
FuseRankingsOptions. Constants: DEFAULT_ALT_TEXT_MODEL, MAX_ALT_TEXT_LENGTH,
DEFAULT_TEXT_MODEL, REWRITE_MODES, REWRITE_MAX_TOKENS, REWRITE_MAX_CHARS, SEO_TITLE_MAX, SEO_DESCRIPTION_MAX,
DEFAULT_EMBEDDING_MODEL, RRF_K.
contentVectorId / parseContentVectorId compose and recover the vector id.