Releases: krispyhq/krispyai
Release list
v0.2.2
Changed
- Edge:
ALLOWED_ORIGINaccepts a comma-separated origin list. The CORS header can only
carry one origin, so the list is matched against the request's ownOriginand echoed back at
a single fetch boundary (finalizeCors);Vary: Originrides along so a cache never serves
one origin's header to another. Single-origin and wildcard (*) behavior are unchanged, and
the 55json()/cors()call sites are untouched. (PR #58.)
v0.2.1
Added
-
Widget + edge: paste a screenshot into the chat. Support is the one conversation where a picture is the message — a broken layout, an error dialog, a line on a statement — and until now the visitor had to describe it in words, which is exactly what they were already failing to do when they reached for the chat.
Ctrl/Cmd+Vand drag-and-drop both work; the image lands in a tray above the composer with a thumbnail and a remove control, and is sent with the next message.New route:
POST /api/attachment(multipart/form-data:file,sessionId, optionaltenantId/siteId/caption). It forwards the image to the visitor's existing Telegram topic viasendPhoto, so the operator sees the screenshot inline in the thread they are already reading. Not silent, unlike every other topic mirror: nobody pastes a screenshot unless words have already failed them.Telegram is the store, deliberately, rather than adding R2. The Worker binds AI, Durable Objects and KV and nothing else; an object store would be a new binding every self-hoster must provision before the feature works at all. Telegram already keeps the file, already renders it in the thread, and is already required (
getTenant()returns null without both Telegram secrets). So this ships to every existing deployment with no config change. The trade, stated plainly: the image is not in the visitor's transcript across a reload — the widget shows it from a local object URL for the life of the page — and the AI cannot see it. The visitor's message carries a note that a screenshot was attached, so the model knows something visual exists it cannot read and its existing handoff logic can act.Guards, because this is a public unauthenticated endpoint that forwards bytes to a third party: rate limited per session on the same KV counter the lead form uses; a MIME allowlist checked against the file's magic bytes and not merely its declared content-type; a 5MB cap enforced server-side; and an existing topic is required, so a stranger cannot use it to open threads in someone's Telegram group. SVG is deliberately excluded — it is a document that can carry
<script>, and an image only by MIME. Seven tests inservices/edge/test/attachment-guard.test.tshold that line.Client-side the image is downscaled to a 1600px longest edge and re-encoded before upload, since none of a modern screenshot's 4–8MB survives Telegram's own recompression; the original is sent unchanged if anything in that path fails.
v0.2.0
Changed
-
Widget: the composer grows with the message. The message field was an
<input>, which can hold exactly one line no matter how long the message is — anyone reporting a real problem typed into a slot that showed them ~40 characters of what they had written. It is now a<textarea class="in">that starts at one line and animates its height as the content wraps, up to 5 rows, after which it scrolls (.in.tall). Height is animated (0.18s), and dropped underprefers-reduced-motion. Enter still sends andShift/Ctrl/Cmd+Enteropens a new line;isComposingis honoured so Enter never sends mid-IME-composition in Japanese/Chinese/Korean input. The composer isalign-items:flex-endso the send button stays on the last line rather than drifting to the middle of a paragraph. Sending resets the field to one line on the same transition.Themers: the selectors
\.ft input,\.ft input:focusand\.ft input::placeholderare now\.ft .in,\.ft .in:focusand\.ft .in::placeholder..cap input(the lead form) is unchanged. Anything overriding the composer by element name needs the class instead.
Added
- Release process — the repo now cuts semver tags + GitHub Releases via
node scripts/release.mjs <x.y.z>(promotes[Unreleased]→ a dated version section, bumps thepackage.jsonversions, tagsvX.Y.Z, publishes the GitHub Release from the changelog section).deploy.sh productionrefuses to ship a commit that isn't av*tag (break-glass:ALLOW_UNTAGGED_DEPLOY=1), so every production deploy is a real, changelog'd release. SeeRELEASING.md. - Widget:
theme.launcherStyle: "pill"— an opt-in labelled launcher, and the animated states that go with it. The pill carries the avatar, a hairline separator and a word (theme.launcherLabel, ≤24 chars, defaults to the widget title). While the panel is open it collapses to the avatar alone and sheds its frame, so the launcher and the panel stop competing for the same corner; closing builds it back on the same transition. The leading edge lights up inprimaryColorfor the duration of the move (.kmoving) — it is pinned to the pill's own right edge, so the width transition carries it with no second animation to keep in sync. Unread lights the separator itself and breathes it (kattend, 3.2s) rather than adding a badge. Everything is gated on.kpill: with no theme, or any value other than the literal"pill", the launcher is byte-identical to today (asserted by a control run). Honoursprefers-reduced-motion— the states survive, the motion does not.
Fixed
- Widget: the notification ding was unreachable for anyone whose visitors interact with the page before the widget. Audio is gated on user interaction and the unlock was armed from a single listener on the widget's own host, so a visitor who had been using the page for ten minutes but had not yet touched the launcher got silence — and an operator running a custom launcher outside that host got silence permanently. The first
pointerdownanywhere in the document now arms it (once, capture), in addition to the existing host listener. - Widget: unread no longer forgets itself on navigation.
kunreadwas a class with a one-page lifetime, so an inbound message that arrived while the visitor was reading something else vanished the moment they refreshed or moved on. It is now remembered underkrispy_unread_<tenant>, restored on boot when the panel is closed, and cleared by opening and nothing else — the same contract the class already had, made durable.localStoragefailures are swallowed (Safari private mode, blocked cookies): the fallback is exactly today's behaviour.
Added
- Edge: kbase injection —
TenantConfig.kbSources(KbSource[]:{ id, name, text, updatedAt }, total text still hard-capped at 100K chars) is now assembled into the system prompt at chat time as a## Knowledgeblock (runtime assembly, not compile-on-save), between the persona and the forms/guardrail contracts. Unset → the prompt is byte-for-byte what it is today.detectPromptLeakis narrowed to the instruction portion (the same prompt without the knowledge block) so a bot quoting its own KB verbatim is no longer false-flagged as a prompt leak and forced into a handoff. - Edge: relearning from handoffs — off by default: it runs only for a tenant already using the knowledge base (≥1
kbSource), so a tenant that never configured a KB incurs zero billable AI on handback and behaves exactly as today. When a handed-off session hands back to the AI (operator resolves it, or the silence alarm fires) and it had ≥1 operator message, theSessionDOmakes one Workers-AI call (metered underai) to extract at most one Q→A the human answered that the bot couldn't, and appends it as aKbSuggestion({ id, question, answer, createdAt }) under its own per-site KV keysuggestions:<ns(t,s)>(cap 20 FIFO; normalized-question dedup against pending suggestions and approvedkbSources). A separate key means the background write can never race a human config save. Failures are swallowed — relearning never breaks resolve. The DO now persists itstenantId+siteIdwrite-once on thePOST /contextread (the handback fires from an alarm with no request in flight and the DO can't derive them from its own name).TenantConfig.kbVersionbumps on anykbSourceswrite. - Edge: approval inbox routes (secret-authed,
?s=/siteIdscoped like the other config routes) —GET /api/tenant/kb-suggestions?t=&s=(list pending),POST /api/tenant/kb-approve { tenantId, siteId?, id }(move intokbSources+ bumpkbVersion;404on unknown id),POST /api/tenant/kb-dismiss { tenantId, siteId?, id }(drop it). Suggestions are never projected to the public widget config. Human-in-the-loop by design: the bot never self-modifies its KB. - CLI: self-host parity for the approval inbox —
krispy kb-suggestions(list pending),krispy kb-approve <id>(append tokbSources+ bumpkbVersion),krispy kb-dismiss <id>(drop it), over the secret-authed edge routes above. Each takes an optional--site <id>flag (→?s=/siteId) to scope a single site. - Multi-site (edge + widget foundation) — one account can run multiple sites, each with its own config blob (theme, persona, connectors, forms, popups, kbase) and liveness. A site is an optional
?s=<site>(query) /data-site(embed) /siteId(chat & lead body) that suffixes the tenant's KV namespace vians(t, s). An absent ordefaultsite collapses to the exact legacy key — every existing tenant is untouched and nothing migrates (asserted by test).siteIdis charset-guarded (/^[a-z0-9_-]{1,40}$/) at the trust boundary before it enters a:-delimited key; malformed →400. Config-consuming routes (/api/widget/config,/api/chat,/api/lead,/api/tenant/configGET+POST,/api/tenant/liveness) resolve it; conversations (session/thread/DO) and billing (usage/entitlement) stay keyed by tenantId alone — pooled quota per account, per-site conversations deferred. The cloud dashboard site-switcher + site table land next.
Added
- Widget liveness — the widget's boot-time
GET /api/widget/confignow doubles as a heartbeat: the edge stamps a per-tenant last-seen record (timestamp + the embedding page's origin/url fromOrigin/Referer), throttled in-isolate to stay well under KV's write budget and never blocking the boot. New secret-authedGET /api/tenant/liveness?t=<tenant>returns{ seen }so the dashboard can show "live on example.com — last seen 2m ago". Stores last-seen only for now (per-origin set + per-site keying are the multi-site upgrade path).
Added
- Edge: new
WidgetThemeknobs —glowColor,tagline,sparkle,direction,popupText, andtiming(WidgetTiming:launcherDelayMs/sparkleAfterMs/popupDelayMs/popupCooldownHrs/autoOpenMs) — projected through the publicGET /api/widget/configwhitelist. All default unset/off: a tenant that configures nothing gets today's neutral widget unchanged. - Edge: hard write caps on
POST /api/tenant/config(trust boundary; invalid configs never reach KV) —theme.avatar≤48KB + scheme check (buttr|https://|data:image/(png|webp|jpeg);base64,), connector CTA urls https-only, free-texttheme.tagline/theme.popupText≤500 chars,kbSourcestotal text ≤100K chars. Size overruns →413; malformed values →400. - Edge:
TenantConfiggainspersona(PersonaSpec:toneOfVoice+styleRules[]),script(ConversationScript:opening[]+starters[]), andpopups(PopupSpec[]: timer/section-proximity teaser engine;theme.popupTextis sugar for one timer popup). All default unset — the bot speaks/opens exactly as today until configured. - Edge: the
Connectorunion grows CTA fields (ctaopt-out,label,caption,showAfterMs) and new types (phone,facebook,tiktok,link, each with a server-builturl/tel:/wa.mehref).cta: falsekeeps an otherwise-CTA connector delivery-only. - Edge:
GET /api/widget/configprojection now also servesctas(CTA-capable connectors minuscta:falseopt-outs, with server-built hrefs + default labels),script(opening ≤5 / starters ≤4), andpopups(or thepopupTextsugar).personais structurally excluded — it is instruction text folded into the system prompt only. - Edge:
buildSystemPromptfoldspersonainto the prompt (## Voice+## Style rules) between the tenant instructions and the guardrails, so it sits inside thedetectPromptLeakscope (a bot reciting its own tone verbatim counts as a leak). - Edge: additional
POST /api/tenant/configwrite caps —popups≤8 entries, popuptext≤500 chars, popup selectors (nearselector /cancelOnClick) ≤200 chars,script.opening≤5,script.starters≤4, andpersona+scriptcombined free text ≤8K chars (all413).
...