npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sgiant-ai-widget

v1.22.0

Published

Framework-agnostic embeddable AI chat widget. One call mounts a streaming chat bubble into any page — no React, no framework, themeable.

Readme

sgiant-ai-widget

A framework-agnostic, embeddable AI chat widget. One call mounts a floating bubble and a streaming chat panel into any page.

No React. No framework. Vanilla DOM, themeable through CSS custom properties, and one runtime dependency.

npm install sgiant-ai-widget

Live examples, all plain HTML with no bundler and no build step:

  • Quick start → — a <script src> tag and one call.
  • Beyond streaming text → — live activity steps, the assistant asking the human to decide, a confirm-gated write, suggestion chips, a form the model designed, a card with its own actions, and a renderer you register yourself. Every demo runs a real turn and shows the JSON that produced it.
  • The chrome → — launcher states (resting, unread, working, offline, parked) driven live, what the user can do to the panel, and an animated robot avatar that is one inline SVG with its own prefers-reduced-motion guard.
  • Advanced mode → — the assistant highlighting, filling and clicking inside a real app in an iframe, driven over sgiant-ai-agent-bridge. Selectors never cross the wire; only ids the page opted in with data-ai-target.
  • Real charts → — animated bars, an animated line with a comparison overlay, and a donut, drawn with no chart library at all.
  • Theming → — launcher geometry as custom properties, and an inline SVG brand mark.

Two ways in

As a module (React, Next, Vue, Svelte, plain ESM)

import { createAiChatWidget } from "sgiant-ai-widget";

const chat = createAiChatWidget({
  endpoint: "https://api.example.com/chat",
  title: "Aria",
  greeting: "Hi! Ask me anything.",
  accent: "#6d28d9",
});
chat.open();

As a <script> tag, no build step

<script src="https://unpkg.com/sgiant-ai-widget/dist/sgiant-ai-widget.global.js"></script>
<script>
  SgiantAiWidget.createAiChatWidget({ endpoint: "/chat" });
</script>

The global build bundles its one dependency, so it is a single file. If your page already uses a bundler, prefer the module build — it shares that dependency instead of carrying a second copy.

Charts, if you have no chart library

The widget draws a stat tile for a kpi and a table for anything else. That is deliberate: a host with real chart components should mount those through renderDataWidget, and a core carrying a second implementation would charge every consumer for one they do not use.

For surfaces with neither — a WordPress admin page, a plain embed — there is an opt-in renderer:

import { createChartFallback } from "sgiant-ai-widget/charts";
createAiChatWidget({ renderChartFallback: createChartFallback() });
<!-- or with no build step at all: a separate 3.8 kB bundle -->
<script src="https://unpkg.com/sgiant-ai-widget/dist/sgiant-ai-widget-charts.global.js"></script>
<script>
  SgiantAiWidget.createAiChatWidget({
    renderChartFallback: SgiantAiWidgetCharts.createChartFallback(),
  });
</script>

It draws time_series, breakdown and donut, reading your --aiw-* tokens so a chart follows the theme, and it plots the column your spec names in metrics / dimension rather than guessing at one — so the same frame renders the same numbers here and in a full dashboard renderer. It throws for kpi, table, pivot_grid, heatmap, scatter and content — which the widget catches and renders with its built-in instead. A bad heatmap made of rectangles is worse than a readable table of the same numbers.

It is a floor, not a charting library. Nothing in the main entry point references it, so it costs zero bytes unless you ask for it.

The backend

One POST endpoint that streams newline-delimited JSON. Four frame types matter:

| frame | meaning | | --- | --- | | {"type":"text","d":"…"} | append this text | | {"threadId":"…"} | name the conversation | | {"type":"done"} | the turn is over | | {"type":"error","message":"…"} | the turn failed | | {"type":"error","code":"…","message":"…"} | the turn failed, and message is a sentence you wrote for the reader: it is shown by itself. Add "retry":false to offer no "Try again" |

The full contract, including a dependency-free reference server short enough to read in one go, is in BACKEND.md.

Attachments

Set uploadEndpoint and the composer grows a paperclip. Leave it unset and there is no attach button at all — an attach control on a surface with nowhere to put a file fails after the user has chosen one, which is the worst moment to say no.

createAiChatWidget({
  endpoint: "/accounts/acc_1/ai/chat",
  uploadEndpoint: "/accounts/acc_1/assets/media", // POST multipart
});

getUploadEndpoint() is the dynamic form, read at call time, for a host whose account can change without remounting — the same shape as getToken.

Each file is a multipart/form-data POST carrying file, session=1, and threadId when there is one; your endpoint answers with a media id, and the chat turn then carries ids rather than bytes. session=1 is the whole difference between a chat attachment and an upload to the asset library: a file dropped into a conversation belongs to that conversation, reachable from its history by id and never filed alongside deliberate library uploads.

| limit | value | why | | --- | --- | --- | | files per turn | 6 | Extras are dropped, not refused — the turn still sends. | | image / document | 25 MB | Read inline by the model, so this bounds a prompt. | | video / audio | 100 MB | Referenced by id, never inline. A 30-second 1080p phone video is already past the document cap, so a shared cap would refuse the everyday case for being ordinary. |

An oversize file gets its own error chip naming the file and the cap, and the rest of the same selection still uploads — one bad file does not lose the batch.

See examples/uploads.html.

When there is no token to give

getToken is asked before every send. Sometimes the honest answer is that there is no token and nothing has gone wrong: this person has not connected their account yet, or has no access to this one. Say so by rejecting with something that has a userMessage, and optionally one button:

createAiChatWidget({
  endpoint: "/chat",
  getToken: async () => {
    const res = await fetch("/my-site/token");
    if (res.status === 409)
      // A plain object is enough; so is an Error with these set on it.
      throw {
        userMessage: t("connectToUse"),          // shown as it is
        action: {
          label: t("connect"),
          // Called inside the click, so a window opened here is not blocked.
          // Resolve true once a token can be had; false if they backed out.
          onClick: () => openConnectWindow(),
        },
      };
    if (!res.ok) throw new Error(`token ${res.status}`); // a real failure
    return (await res.json()).token;
  },
});

The window shows the sentence plainly, with the button under it. It is not the error card: there is no "hit a snag" headline, no "Try again", and the launcher is not marked offline. The question is not sent. It stays on screen as the person's own, and when onClick resolves true the widget asks getToken again and sends it, once; they do not retype it. Only true counts: resolve anything else, or reject, and the notice stays with its button working again. While onClick is running the button is disabled and nothing is sent.

There is one notice at a time. If the second ask is refused too (connected, but no access), that sentence replaces the first. Leave action out and only the sentence shows.

One question is held, the latest. If the person asks a second question while the notice is up, that one replaces the first as the held question; the first stays on screen as theirs and is not sent when they connect.

The words are yours, in the page's language; the widget adds none. The button is not offered to the assistant as a control it can press.

Reject with anything that has no userMessage and it is a failure, shown as one, exactly as before. A notice belongs to a send: a refused token during a file upload shows nothing, as a failed upload always has. A widget older than 1.20.0 does not know this shape and shows its ordinary error card for it.

Theming

The entire stylesheet reads from --aiw-* CSS custom properties set on the widget's roots. accent / gradient are the shorthand; the theme option overrides any token — or theme from plain CSS by targeting the roots, which works because the defaults are declared inside :where() and therefore carry zero specificity. Any rule you write beats them, with no !important needed.

Launcher geometry is tokenised too: --aiw-launcher-size, -offset, -offset-sm (below 480px), -icon, -pill-height, -pill-icon, -parked-size, -parked-icon, -dot, -dot-sm. The -sm pair is deliberately independent, so a larger desktop launcher does not force a larger one on a phone. See examples/theming.html.

createAiChatWidget({
  // …
  accent: "#6d28d9",
  theme: {
    // dark mode
    surface: "#16161e",
    "surface-2": "#22222c",
    bg: "#101016",
    text: "#e7e7ec",
    "text-2": "#b6b6c2",
    muted: "#8a8a96",
    border: "#2b2b36",
    "border-strong": "#3a3a48",
    "border-soft": "#22222c",
  },
});

Tokens: accent, accent-contrast, gradient, surface, surface-2, bg, text, text-2, muted, border, border-strong, border-soft.

Custom renderers (extension point)

The assistant can emit [[<tag>:{json}]] directives; built-ins cover widget (stat/kpis/list/table), preview (sandboxed HTML), navigate, action, chips, and form. Register your own tags to render anything else — the widget strips the directive, mounts a host element in the log, and hands you the parsed JSON:

const chat = createAiChatWidget({
  // …
  renderers: {
    calendar: (host, spec) => {
      host.textContent = renderMyCalendar(spec); // validate spec yourself
      return () => cleanup(); // optional disposer (message cleared)
    },
  },
});
// or later:
chat.registerRenderer("map", (host, spec) => drawMap(host, spec));

Built-in tags are reserved (a plugin can't hijack the confirm-gated action/form security model). A throwing renderer removes its host card instead of breaking the message.

Letting people choose the model

Pass models and a small pill appears in the composer, next to Send. The chosen option's id is sent as model with every message.

createAiChatWidget({
  endpoint: "/api/chat",
  models: [
    { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5",  hint: "Fastest, lightest", cost: "1×" },
    { id: "claude-sonnet-5-5",         label: "Sonnet 5.5", hint: "Fast and capable",  cost: "2×" },
    { id: "claude-opus-5-5",           label: "Opus 5.5",   hint: "Most capable",      cost: "4×" },
  ],
  defaultModel: "claude-sonnet-5-5",
  onModelChange: (id) => { /* optional */ },
});

The widget knows nothing about models. Which ones exist, which company serves each and what they cost belongs to your server; the options are plain data, and id is opaque. Give options a group (the vendor, say) and they are listed under it, so offering a second vendor's models is a longer list rather than a widget change.

The choice is remembered per storageNamespace and layout scope, and checked against models on every mount: a model you stop offering is never sent again, the person gets defaultModel instead. Omit models and there is no pill and no model field. See examples/models.html.

If your list of models failed to load and you would rather open the chat than hold it back, pass modelsUnavailable: true instead of models. The pill is drawn where it would be, chooses nothing, and says why to a mouse, a keyboard, a screen reader and a tap (the modelPickerUnavailable label). No model field is sent, so the turn runs on your server's default.

In the page's language

labels carries the words; locale carries how a figure is written.

createAiChatWidget({
  // …
  locale: i18n.language, // a BCP 47 tag: "tr", "en-GB"
  labels: resolveWidgetLabels(t), // your translations of the widget's own chrome
});

The numbers and times the widget writes itself follow locale: credits left, a turn's counts, "3 hours ago" in the conversation list. Omit it and they follow the browser, which is another language whenever someone reads a Turkish page in an English browser: "24,000" for 24.000, and "8/1/2026" for 1 August. A tag the engine refuses falls back to the browser's instead of throwing.

The widget carries its own words in English and Turkish. For every label you do not pass in labels, it uses its own for the page's language: Turkish when locale is a Turkish tag, or, when you pass no locale, when the page's own <html lang> is. So a host that passes neither gets a Turkish window on a Turkish page and an English one everywhere else. For any other language, pass labels; a label you leave out is shown in English.

A label that says {name} is given the window's own name (title), so a sentence about what the assistant does names your assistant.

Six words are options of their own, older than the label list: voteUpLabel, voteDownLabel, editLabel, regenerateLabel, cancelLabel, saveLabel. They are chosen the same way: yours, else the window's own for the page's language. subtitle, the small line under the name, has no default: pass one or nothing is drawn there.

Charts without React

In-app hosts wire renderDataWidget (real @sgiant/ui charts). A standalone embed has no React, so render_chart frames degrade to a stat/table — unless you plug renderChartFallback(host, spec, rows, comparisonRows) with any lightweight chart lib.

Transport

POSTs { accountId, threadId, content } (plus model when the host passes models; see BACKEND.md) and reads a streamed body, tolerating both shapes the platform emits:

  • SSE — data: {"type":"assistant_delta","text":"…"}
  • NDJSON — {"d":"…"}

plus {type:"thread",threadId}, {type:"error",message}, {type:"done"}.

Security

The widget holds no long-lived secret. In-app surfaces pass a Clerk session token; external sites pass no token at all — they identify themselves with a public site key in the POST body (extraBody: { siteKey }), which the backend verifies against the paired site and rate-limits per site on top of the per-IP ceiling. An unknown or inactive key is a 403, never a silent fall back to anonymous. The backend's OBO scoping is the real ceiling; this is presentation only.

This section said until 2026-09-22 that external sites use "a short-lived embed token" and that the endpoint minting it "is the next backend step". There is no such endpoint and there never was: the site-key design shipped instead (sgiant-platform#339), and the sgiant-connect WordPress plugin has been emitting extraBody: { siteKey: … } since.

External embedding

The single-file IIFE build ships: npm run build:global writes dist/sgiant-ai-widget.global.js, exposed as the ./global export and as window.SgiantAiWidget (see "As a <script> tag" above) — NOT window.SgiantChat, which never existed. A third-party page needs no token: pass its public site key and the endpoint does the rest — see Security.

<script src="https://unpkg.com/sgiant-ai-widget/dist/sgiant-ai-widget.global.js"></script>
<script>
  SgiantAiWidget.createAiChatWidget({
    endpoint: "https://api.example.com/public/ai/chat",
    extraBody: { siteKey: "…" },
  });
</script>

Flagging a conversation

Pass onFlag to put a flag control in the header: clicking it asks for a reason and calls back with the reason plus the live thread id. Wire it to a flags API (POST /admin/ai/conversations/:kind/:threadId/flag for staff surfaces, POST /public/ai/threads/:threadId/flag for the visitor widget). Omit it and the control does not render.

The surface manifest (sgiant-ai-widget/manifest)

An opt-in entry point re-exporting the bridge's generateManifest, verifySurface, canAct and hashManifest. It is separate on purpose: the core is one runtime dependency with no React, and a host that never generates or verifies a manifest should pay nothing for the machinery. Importing the main entry pulls none of it.

About the #123 references in the source

The comments cite issue numbers from the private tracker this widget was built in, where it lived inside a monorepo until 2026-09-02. They are kept because the reasoning around them is worth more than the tidiness of removing it — a comment explaining why a wire value must not change is useful even when you cannot open the ticket it cites.

Nothing behind those numbers is needed to use, read, or modify this package.

Licence

MIT © Vedat Aydın Uğur