Skip to content

Releases: krispyhq/krispyai

v0.2.2

Choose a tag to compare

@lonormaly lonormaly released this 18 Aug 20:45

Changed

  • Edge: ALLOWED_ORIGIN accepts a comma-separated origin list. The CORS header can only
    carry one origin, so the list is matched against the request's own Origin and echoed back at
    a single fetch boundary (finalizeCors); Vary: Origin rides along so a cache never serves
    one origin's header to another. Single-origin and wildcard (*) behavior are unchanged, and
    the 55 json()/cors() call sites are untouched. (PR #58.)

v0.2.1

Choose a tag to compare

@lonormaly lonormaly released this 28 Jul 13:58

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+V and 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, optional tenantId/siteId/caption). It forwards the image to the visitor's existing Telegram topic via sendPhoto, 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 in services/edge/test/attachment-guard.test.ts hold 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

Choose a tag to compare

@lonormaly lonormaly released this 28 Jul 13:11

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 under prefers-reduced-motion. Enter still sends and Shift/Ctrl/Cmd+Enter opens a new line; isComposing is honoured so Enter never sends mid-IME-composition in Japanese/Chinese/Korean input. The composer is align-items:flex-end so 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:focus and \.ft input::placeholder are now \.ft .in, \.ft .in:focus and \.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 the package.json versions, tags vX.Y.Z, publishes the GitHub Release from the changelog section). deploy.sh production refuses to ship a commit that isn't a v* tag (break-glass: ALLOW_UNTAGGED_DEPLOY=1), so every production deploy is a real, changelog'd release. See RELEASING.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 in primaryColor for 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). Honours prefers-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 pointerdown anywhere in the document now arms it (once, capture), in addition to the existing host listener.
  • Widget: unread no longer forgets itself on navigation. kunread was 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 under krispy_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. localStorage failures are swallowed (Safari private mode, blocked cookies): the fallback is exactly today's behaviour.

Added

  • Edge: kbase injectionTenantConfig.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 ## Knowledge block (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. detectPromptLeak is 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 handoffsoff 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, the SessionDO makes one Workers-AI call (metered under ai) to extract at most one Q→A the human answered that the bot couldn't, and appends it as a KbSuggestion ({ id, question, answer, createdAt }) under its own per-site KV key suggestions:<ns(t,s)> (cap 20 FIFO; normalized-question dedup against pending suggestions and approved kbSources). 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 its tenantId+siteId write-once on the POST /context read (the handback fires from an alarm with no request in flight and the DO can't derive them from its own name). TenantConfig.kbVersion bumps on any kbSources write.
  • Edge: approval inbox routes (secret-authed, ?s=/siteId scoped like the other config routes) — GET /api/tenant/kb-suggestions?t=&s= (list pending), POST /api/tenant/kb-approve { tenantId, siteId?, id } (move into kbSources + bump kbVersion; 404 on 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 to kbSources + bump kbVersion), 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 via ns(t, s). An absent or default site collapses to the exact legacy key — every existing tenant is untouched and nothing migrates (asserted by test). siteId is 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/config GET+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/config now doubles as a heartbeat: the edge stamps a per-tenant last-seen record (timestamp + the embedding page's origin/url from Origin/Referer), throttled in-isolate to stay well under KV's write budget and never blocking the boot. New secret-authed GET /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 WidgetTheme knobs — glowColor, tagline, sparkle, direction, popupText, and timing (WidgetTiming: launcherDelayMs/sparkleAfterMs/popupDelayMs/popupCooldownHrs/autoOpenMs) — projected through the public GET /api/widget/config whitelist. 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-text theme.tagline/theme.popupText ≤500 chars, kbSources total text ≤100K chars. Size overruns → 413; malformed values → 400.
  • Edge: TenantConfig gains persona (PersonaSpec: toneOfVoice + styleRules[]), script (ConversationScript: opening[] + starters[]), and popups (PopupSpec[]: timer/section-proximity teaser engine; theme.popupText is sugar for one timer popup). All default unset — the bot speaks/opens exactly as today until configured.
  • Edge: the Connector union grows CTA fields (cta opt-out, label, caption, showAfterMs) and new types (phone, facebook, tiktok, link, each with a server-built url/tel:/wa.me href). cta: false keeps an otherwise-CTA connector delivery-only.
  • Edge: GET /api/widget/config projection now also serves ctas (CTA-capable connectors minus cta:false opt-outs, with server-built hrefs + default labels), script (opening ≤5 / starters ≤4), and popups (or the popupText sugar). persona is structurally excluded — it is instruction text folded into the system prompt only.
  • Edge: buildSystemPrompt folds persona into the prompt (## Voice + ## Style rules) between the tenant instructions and the guardrails, so it sits inside the detectPromptLeak scope (a bot reciting its own tone verbatim counts as a leak).
  • Edge: additional POST /api/tenant/config write caps — popups ≤8 entries, popup text ≤500 chars, popup selectors (near selector / cancelOnClick) ≤200 chars, script.opening ≤5, script.starters ≤4, and persona+script combined free text ≤8K chars (all 413).
    ...
Read more