Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/http-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ Headers: `Authorization: Bearer <visitor token>`. Returns the visitor's own conv
] }
```

## Extra request headers

Hosts may configure the widget to add headers (for example `x-csrf-token`) to every request, and to use a
custom `fetch`. The widget always sets `content-type`, `authorization` and the gateway credential headers
itself; host headers with those names are dropped. A cross-origin backend must list any extra header in
`Access-Control-Allow-Headers`.

The widget refreshes its visitor session (`POST /session` with the old `token`) once less than 10% of the
lifetime it observed (`expiresAt` minus the time the session was issued), capped at 24 h and floored at 30 s,
remains.

## Bring-your-own credentials (optional)

When the backend enables it, the widget may send `x-dewee-gateway-url` and `x-dewee-gateway-token`
Expand Down
23 changes: 23 additions & 0 deletions packages/dewee-webchat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,35 @@ Attributes use kebab-case (`show-reasoning="false"`), `data-*` on the script tag
| `actions`, `confirm`, `getPageContext` | | [Page actions](#agent-page-actions) (JS/props only) |
| `maxActionRounds` | `5` | Automatic action turns per visitor message |
| `actionTimeoutMs` | `30000` | Per-handler timeout |
| `headers` | | Extra request headers, object or `() => object \| Promise<object>` called before every request (JS/props only) |
| `fetch` | `globalThis.fetch` | Custom fetch for every widget request (JS/props only) |

Methods: `open()`, `close()`, `toggle()`, `send(text)`, `stop()`, `newConversation()`, `configure(partial)`,
`registerActions(actions)`, `unregisterAction(name)`.
Events: `dewee:ready`, `dewee:open`, `dewee:close`, `dewee:send`, `dewee:reply`, `dewee:error`, `dewee:action`.
CSS variables: `--dw-accent`, `--dw-font`, `--dw-radius`, `--dw-width`, `--dw-height`, `--dw-z`.

## Custom headers and fetch

Behind a same-origin BFF that wants a CSRF header, cookies or tracing:

```tsx
<DeweeChat
endpoint="/dashboard/api/v1/dewee"
headers={() => ({ "x-csrf-token": readCsrfToken() })} // evaluated before every request
fetch={(input, init) => fetch(input, { ...init, credentials: "include" })}
/>
```

Vanilla: `chat.configure({ headers, fetch })` or the `chat.headers` / `chat.fetch` properties. `headers` applies to
every request to `endpoint` (config, session, history, chat). It cannot override `content-type`, `authorization`,
`x-dewee-gateway-url` or `x-dewee-gateway-token` (case-insensitive; such keys are dropped). Header names are sent
lower-cased. For a cross-origin `endpoint`, the backend's CORS preflight must allow the extra headers; the built-in
handlers only allow their own.

Visitor sessions are reused until less than 10% of the token lifetime remains (at most 24 h, at least 30 s), so
backends with short session TTLs do not mint a new session on every request.

## Agent page actions

Let the agent operate the page the visitor is on: navigate, filter a table, cancel a job. The page
Expand Down
2 changes: 1 addition & 1 deletion packages/dewee-webchat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dewee-webchat",
"version": "0.2.0",
"version": "0.3.0",
"description": "Embeddable chat widget for Dewee AI agents — web component, React/Vue/Astro wrappers and a secure backend handler (Workers, Next.js, Node, Bun, Deno).",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/dewee-webchat/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DeweeChatElement, type DeweeChatConfig } from "./widget/element";

export { DeweeChatElement, type DeweeChatConfig };
export { ApiClient, ApiError, type ApiClientOptions } from "./widget/api-client";
export { ApiClient, ApiError, RESERVED_HEADERS, type ApiClientOptions, type RequestHeadersOption } from "./widget/api-client";
export { renderMarkdown } from "./widget/markdown";
export type { ActionManifestEntry, ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, PublicConfig, SessionResponse, ToolCallView } from "./protocol";
export type { ActionCallStatus, ActionConfirmHook, PageAction, PageActionContext, PageActionInfo } from "./actions/action-runner";
Expand Down
6 changes: 3 additions & 3 deletions packages/dewee-webchat/src/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type EventProp = keyof typeof EVENTS;

export const DeweeChat = forwardRef<DeweeChatElement | null, DeweeChatProps>(function DeweeChat(props, ref) {
const el = useRef<DeweeChatElement | null>(null);
const { className, style, onReady, onSend, onReply, onError, onOpen, onClose, onAction, actions, confirm, getPageContext, ...config } = props;
const { className, style, onReady, onSend, onReply, onError, onOpen, onClose, onAction, actions, confirm, getPageContext, headers, fetch, ...config } = props;
const handlers: Partial<Record<EventProp, (e: CustomEvent<any>) => void>> = { onReady, onSend, onReply, onError, onOpen, onClose, onAction };
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
Expand All @@ -44,7 +44,7 @@ export const DeweeChat = forwardRef<DeweeChatElement | null, DeweeChatProps>(fun
// without re-rendering the widget (inline `actions={[...]}` arrays are fine).
useEffect(() => {
defineDeweeChat();
el.current?.configure({ actions, confirm, getPageContext });
el.current?.configure({ actions, confirm, getPageContext, headers, fetch });
});

useEffect(() => {
Expand All @@ -63,7 +63,7 @@ export const DeweeChat = forwardRef<DeweeChatElement | null, DeweeChatProps>(fun
});

export default DeweeChat;
export type { ActionConfirmHook, ActionEventDetail, PageAction, PageActionContext } from "./index";
export type { ActionConfirmHook, ActionEventDetail, PageAction, PageActionContext, RequestHeadersOption } from "./index";
export type { DeweeChatConfig, DeweeChatElement };

declare module "react" {
Expand Down
4 changes: 2 additions & 2 deletions packages/dewee-webchat/src/vue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const keys = [
"endpoint", "agent", "title", "subtitle", "greeting", "intro", "placeholder", "suggestions", "avatar", "launcherAvatar",
"position", "mode", "theme", "accentColor", "locale", "showReasoning", "showToolCalls", "allowAgentSwitch", "open",
"persist", "hideBranding", "gatewayUrl", "gatewayToken", "width", "height", "zIndex", "actions", "confirm", "getPageContext",
"maxActionRounds", "actionTimeoutMs",
"maxActionRounds", "actionTimeoutMs", "headers", "fetch",
] as const;

const props = Object.fromEntries(keys.map((k) => [k, { type: null as unknown as PropType<any>, default: undefined }])) as {
Expand Down Expand Up @@ -44,5 +44,5 @@ export const DeweeChat = defineComponent({
});

export default DeweeChat;
export type { ActionConfirmHook, ActionEventDetail, PageAction, PageActionContext } from "./index";
export type { ActionConfirmHook, ActionEventDetail, PageAction, PageActionContext, RequestHeadersOption } from "./index";
export type { DeweeChatConfig, DeweeChatElement };
55 changes: 42 additions & 13 deletions packages/dewee-webchat/src/widget/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import {
type SessionResponse,
} from "../protocol";

/** Extra request headers: a static map, or a function evaluated before every request. */
export type RequestHeadersOption =
| Record<string, string>
| (() => Record<string, string> | Promise<Record<string, string>>);

/** Headers the widget owns; `headers` cannot override them (compared case-insensitively). */
export const RESERVED_HEADERS: readonly string[] = ["content-type", "authorization", HEADER_GATEWAY_URL, HEADER_GATEWAY_TOKEN];

export interface ApiClientOptions {
/** Backend base URL, e.g. "/api/dewee" or "https://example.com/api/dewee". */
endpoint: string;
Expand All @@ -17,6 +25,10 @@ export interface ApiClientOptions {
gatewayToken?: string;
/** Called when the backend requires a Turnstile challenge; must resolve to a token. */
turnstile?: (siteKey: string) => Promise<string>;
/** Extra headers merged into every request (reserved headers excluded). */
headers?: RequestHeadersOption;
/** Replaces `globalThis.fetch` for every request. */
fetch?: typeof fetch;
}

export class ApiError extends Error {
Expand All @@ -32,7 +44,7 @@ const store = {
};

export class ApiClient {
private session: SessionResponse | null = null;
private session: (SessionResponse & { obtainedAt?: number }) | null = null;
private config: PublicConfig | null = null;
private sessionPromise: Promise<SessionResponse> | null = null;

Expand All @@ -48,8 +60,24 @@ export class ApiClient {
return `dewee-webchat:session:${this.o.endpoint}`;
}

private headers(auth = false): Record<string, string> {
const h: Record<string, string> = { "content-type": "application/json" };
/** Session reuse: refresh once less than 10% of the token lifetime (at most 24 h, at least 30 s) remains. */
private fresh(s: SessionResponse & { obtainedAt?: number }): boolean {
const lifetime = s.obtainedAt ? s.expiresAt - s.obtainedAt : Infinity;
const margin = Math.min(86_400_000, Math.max(30_000, lifetime * 0.1));
return s.expiresAt - Date.now() > margin;
}

private request(url: string, init: RequestInit): Promise<Response> {
return (this.o.fetch ?? globalThis.fetch)(url, init);
}

private async headers(auth = false): Promise<Record<string, string>> {
const h: Record<string, string> = {};
const extra = typeof this.o.headers === "function" ? await this.o.headers() : this.o.headers;
for (const [k, v] of Object.entries(extra ?? {})) {
if (typeof v === "string" && !RESERVED_HEADERS.includes(k.toLowerCase())) h[k.toLowerCase()] = v;
}
h["content-type"] = "application/json";
if (this.o.gatewayUrl && this.o.gatewayToken) {
h[HEADER_GATEWAY_URL] = this.o.gatewayUrl;
h[HEADER_GATEWAY_TOKEN] = this.o.gatewayToken;
Expand All @@ -66,19 +94,19 @@ export class ApiClient {

async getConfig(force = false): Promise<PublicConfig> {
if (this.config && !force) return this.config;
this.config = await this.parse<PublicConfig>(await fetch(this.url("config"), { headers: this.headers() }));
this.config = await this.parse<PublicConfig>(await this.request(this.url("config"), { headers: await this.headers() }));
return this.config;
}

/** Returns a valid visitor session, creating or refreshing it when needed. */
ensureSession(): Promise<SessionResponse> {
if (this.session && this.session.expiresAt - Date.now() > 86_400_000) return Promise.resolve(this.session);
if (this.session && this.fresh(this.session)) return Promise.resolve(this.session);
if (!this.session) {
const saved = store.get(this.storageKey);
if (saved) {
try {
const s = JSON.parse(saved) as SessionResponse;
if (s.expiresAt - Date.now() > 86_400_000) return Promise.resolve((this.session = s));
if (this.fresh(s)) return Promise.resolve((this.session = s));
this.session = s; // close to expiry: refresh below, keeping the same visitor id
} catch { /* corrupted entry */ }
}
Expand All @@ -95,11 +123,12 @@ export class ApiClient {
body.turnstileToken = await this.o.turnstile(cfg.turnstileSiteKey);
}
const s = await this.parse<SessionResponse>(
await fetch(this.url("session"), { method: "POST", headers: this.headers(), body: JSON.stringify(body) }),
await this.request(this.url("session"), { method: "POST", headers: await this.headers(), body: JSON.stringify(body) }),
);
this.session = s;
store.set(this.storageKey, JSON.stringify(s));
return s;
// obtainedAt lets fresh() size the refresh margin to the server's token lifetime.
this.session = { ...s, obtainedAt: Date.now() };
store.set(this.storageKey, JSON.stringify(this.session));
return this.session;
}

forgetSession(): void {
Expand All @@ -109,7 +138,7 @@ export class ApiClient {

async history(agent: string, conversationId: string): Promise<HistoryMessage[]> {
await this.ensureSession();
const res = await fetch(this.url("history", { agent, conversationId }), { headers: this.headers(true) });
const res = await this.request(this.url("history", { agent, conversationId }), { headers: await this.headers(true) });
return (await this.parse<{ messages: HistoryMessage[] }>(res)).messages;
}

Expand All @@ -120,11 +149,11 @@ export class ApiClient {
signal?: AbortSignal,
): Promise<void> {
await this.ensureSession();
let res = await fetch(this.url("chat"), { method: "POST", headers: this.headers(true), body: JSON.stringify(input), signal });
let res = await this.request(this.url("chat"), { method: "POST", headers: await this.headers(true), body: JSON.stringify(input), signal });
if (res.status === 401) {
this.forgetSession();
await this.ensureSession();
res = await fetch(this.url("chat"), { method: "POST", headers: this.headers(true), body: JSON.stringify(input), signal });
res = await this.request(this.url("chat"), { method: "POST", headers: await this.headers(true), body: JSON.stringify(input), signal });
}
if (!res.ok || !res.body) await this.parse(res);

Expand Down
31 changes: 27 additions & 4 deletions packages/dewee-webchat/src/widget/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type ParsedCall,
} from "../actions/directives";
import { ACTION_LIMITS, stripPageBlock } from "../actions/page-block";
import { ApiClient, ApiError } from "./api-client";
import { ApiClient, ApiError, type RequestHeadersOption } from "./api-client";
import { showConfirmDialog } from "./confirm-dialog";
import { icons, pickStrings, type Strings } from "./i18n";
import { renderMarkdown } from "./markdown";
Expand Down Expand Up @@ -67,6 +67,13 @@ export interface DeweeChatConfig {
maxActionRounds?: number;
/** Per-handler timeout. Default 30000 ms. */
actionTimeoutMs?: number;
/**
* Extra headers for every request to `endpoint` (e.g. a CSRF token). A function is called before
* each request. Cannot override content-type, authorization or the gateway credential headers.
*/
headers?: RequestHeadersOption;
/** Replaces `globalThis.fetch` for every widget request (e.g. to add credentials or tracing). */
fetch?: typeof fetch;
}

/** A page action call as shown in the transcript. */
Expand All @@ -93,7 +100,7 @@ export interface ActionEventDetail {
}

/** Option keys that hold functions/objects: applied without re-rendering the widget. */
type BehaviorKey = "actions" | "confirm" | "getPageContext";
type BehaviorKey = "actions" | "confirm" | "getPageContext" | "headers" | "fetch";

interface ViewRefs {
root: HTMLElement;
Expand Down Expand Up @@ -168,7 +175,7 @@ export class DeweeChatElement extends ElementBase {
private mql: MediaQueryList | null = null;
private registry = new ActionRegistry();
private propActionNames = new Set<string>();
private hooks: { confirm?: ActionConfirmHook; getPageContext?: () => unknown } = {};
private hooks: Pick<DeweeChatConfig, "confirm" | "getPageContext" | "headers" | "fetch"> = {};
private acks = new Map<string, string>();
private els!: {
launcher: HTMLButtonElement; panel: HTMLElement; log: HTMLElement; title: HTMLElement; subtitle: HTMLElement;
Expand Down Expand Up @@ -199,11 +206,13 @@ export class DeweeChatElement extends ElementBase {

/** Updates configuration at runtime (wins over attributes). */
configure(partial: Partial<DeweeChatConfig>): void {
const { actions, confirm, getPageContext, ...rest } = partial;
const { actions, confirm, getPageContext, headers, fetch, ...rest } = partial;
const has = (k: BehaviorKey) => Object.prototype.hasOwnProperty.call(partial, k);
if (has("actions")) this.setPropActions(actions ?? []);
if (has("confirm")) this.hooks.confirm = confirm;
if (has("getPageContext")) this.hooks.getPageContext = getPageContext;
if (has("headers")) this.hooks.headers = headers;
if (has("fetch")) this.hooks.fetch = fetch;
if (!Object.keys(rest).length) return;
this.overrides = { ...this.overrides, ...rest };
if (this.els) this.applyConfig(); // otherwise applied on connect
Expand All @@ -220,6 +229,14 @@ export class DeweeChatElement extends ElementBase {
return this.registry.unregister(name);
}

/** Extra request headers (property only; same as `configure({ headers })`). */
get headers(): RequestHeadersOption | undefined { return this.hooks.headers; }
set headers(v: RequestHeadersOption | undefined) { this.hooks.headers = v; }

/** Custom fetch for widget requests (property only; same as `configure({ fetch })`). */
get fetch(): typeof fetch | undefined { return this.hooks.fetch; }
set fetch(v: typeof fetch | undefined) { this.hooks.fetch = v; }

/** Names of the currently registered page actions. */
get actionNames(): string[] {
return this.registry.names();
Expand Down Expand Up @@ -458,6 +475,12 @@ export class DeweeChatElement extends ElementBase {
gatewayUrl: c.gatewayUrl,
gatewayToken: c.gatewayToken,
turnstile: (siteKey) => this.challenge(siteKey),
// Read the current hooks on every request so configure({ headers, fetch }) applies at once.
headers: () => {
const h = this.hooks.headers;
return (typeof h === "function" ? h() : h) ?? {};
},
fetch: (input, init) => (this.hooks.fetch ?? globalThis.fetch)(input, init),
});
}
return this.api;
Expand Down
Loading
Loading