diff --git a/docs/src/content/docs/configuration/widget.md b/docs/src/content/docs/configuration/widget.md index 1076175..8b005f0 100644 --- a/docs/src/content/docs/configuration/widget.md +++ b/docs/src/content/docs/configuration/widget.md @@ -25,6 +25,7 @@ attributes on the `` web component. | `locale` | `"en" \| "es" \| "fr" \| "de"` | auto-detected | UI language; see [Localization](/configuration/localization/) | | `translations` | `Partial` | built-in | Override individual UI strings | | `triggers` | `Trigger[]` | `undefined` | Proactive triggers; see [Proactive triggers](/configuration/triggers/) | +| `plugins` | `ClaudiusPlugin[]` | `undefined` | Message middleware run around each send (`onBeforeSend` / `onAfterReceive` / `onError`); see [Plugins](/plugins/) | ## Web component attributes @@ -41,8 +42,10 @@ attributes on the `` web component. > ``` -`locale`, `translations`, and `triggers` are not available as attributes — use -`window.ClaudiusConfig` or the React component for those. +`locale`, `translations`, `triggers`, and `plugins` are not available as +attributes — use `window.ClaudiusConfig` or the React component for those. +(`plugins` are functions, so set them in JS via `window.ClaudiusConfig` or as a +React prop.) ## Checking the deployed version diff --git a/docs/src/content/docs/plugins/index.md b/docs/src/content/docs/plugins/index.md index 8c892fb..b1fa525 100644 --- a/docs/src/content/docs/plugins/index.md +++ b/docs/src/content/docs/plugins/index.md @@ -1,37 +1,233 @@ --- title: Plugins & Tools -description: Extension points available today, and the planned plugin SDK and tool registry. +description: The Claudius plugin SDK — client and server middleware for message transforms, PII redaction, canned responses, and analytics. --- -## Extension points today +Claudius plugins are small middleware that run around the chat message +lifecycle. The same three lifecycle hooks exist on the **client** (the React +widget) and the **server** (the Worker), so you can inject context, redact PII, +route to different models, log analytics events, or answer locally — without +forking the widget. -Claudius doesn't have a plugin API yet, but several supported extension points -cover most customization needs: +## Lifecycle hooks + +| Hook | Runs | Can | +|------|------|-----| +| `onBeforeSend` | before a message is sent | modify it, replace it, answer locally (`respondWith`), or cancel it (`abort`) | +| `onAfterReceive` | after the reply arrives | modify or replace the reply | +| `onError` | when a send fails | observe the error; on the client, recover with a fallback reply | + +All hooks may be `async`. A hook that throws is caught and logged, so a single +misbehaving plugin will not break the chat — write security-sensitive +transforms (like redaction) defensively. + +## Client plugins (widget) + +Pass an array of plugins to `ChatWidget`. Hooks run in array order. + +```tsx +import { ChatWidget, pluginRedactPII, pluginAnalytics } from "claudius-chat-widget"; + +export function App() { + return ( + console.log(e) }), + ]} + /> + ); +} +``` + +`onBeforeSend` returns the (possibly modified) message — and the returned +message is what is **both displayed and sent**, so a redaction is visible to the +user. + +### Short-circuiting + +The `onBeforeSend` context can answer without a network call, or cancel the +send entirely: + +```ts +import type { ClaudiusPlugin } from "claudius-chat-widget"; + +const slashCommands: ClaudiusPlugin = { + name: "slash-commands", + onBeforeSend(message, ctx) { + if (message.content === "/clear") return ctx.abort(); + if (message.content.startsWith("/help")) { + ctx.respondWith("Type a question and press Enter."); + } + }, +}; +``` + +- `ctx.respondWith(reply)` — render `reply` as the assistant message and skip + the API. `reply` is a string or `{ content, sources }`. +- `ctx.abort()` — drop the message and render nothing. + +`onError` can recover the same way, replacing the error UI with a reply: + +```ts +const fallback: ClaudiusPlugin = { + name: "fallback", + onError: (_err, ctx) => + ctx.respondWith("We're offline right now — email help@example.com."), +}; +``` + +### Reference plugins + +Three plugins ship with `claudius-chat-widget`. + +**`pluginAnalytics`** — emit a structured event for every sent message, reply, +and error: + +```ts +import { pluginAnalytics } from "claudius-chat-widget"; + +pluginAnalytics({ + onEvent: (event) => window.gtag?.("event", event.type, event), + includeContent: false, // record only character counts, not message text +}); +``` + +**`pluginRedactPII`** — strip emails, phone numbers, SSNs, and card-like +numbers before the message leaves the browser: + +```ts +import { pluginRedactPII } from "claudius-chat-widget"; + +pluginRedactPII(); // sensible defaults +pluginRedactPII({ replacement: "***", redactReplies: true }); +``` + +**`pluginCannedResponses`** — answer matched intents locally, with no API call: + +```ts +import { pluginCannedResponses } from "claudius-chat-widget"; + +pluginCannedResponses({ + rules: [ + { match: "hours", reply: "We're open 9–5, Mon–Fri." }, + { match: /pricing|cost/i, reply: "See https://example.com/pricing." }, + ], +}); +``` + +### Writing your own + +A plugin is an object with a `name` and any of the three hooks: + +```ts +import type { ClaudiusPlugin } from "claudius-chat-widget"; + +const pageContext: ClaudiusPlugin = { + name: "page-context", + onBeforeSend(message) { + return { ...message, content: `[on ${location.pathname}] ${message.content}` }; + }, +}; +``` + +## Server plugins (Worker) + +The Worker exposes the equivalent hooks as Hono middleware. Server hooks +operate on the whole request (the messages array) and the reply string, rather +than on a single widget message. + +Register plugins in `worker/src/index.ts` — the file ships this block with an +empty list, so just drop your plugins in: + +```ts +import { chatPlugins, pluginRedactPII } from "./plugins"; + +const serverPlugins = [pluginRedactPII({ redactReplies: true })]; +if (serverPlugins.length > 0) { + app.use("/api/chat", chatPlugins(serverPlugins)); +} +``` + +### Server hooks + +```ts +import type { ClaudiusServerPlugin } from "./plugins"; + +const example: ClaudiusServerPlugin = { + name: "example", + onBeforeSend(messages, ctx) { + // Inspect ctx.env, transform messages, or short-circuit the model: + // ctx.respondWith("a canned reply"); + return messages; + }, + onAfterReceive(reply) { + return reply.trim(); + }, + onError(error) { + console.error("chat failed:", error.message); + }, +}; +``` + +- `onBeforeSend(messages, ctx)` — return a new messages array, or call + `ctx.respondWith(text)` to answer without calling Claude. +- `onAfterReceive(reply, ctx)` — return a new reply string. +- `onError(error, ctx)` — observe failures. + +### Server reference plugins + +The same three plugins, adapted for the request lifecycle: + +```ts +import { + chatPlugins, + pluginAnalytics, + pluginRedactPII, + pluginCannedResponses, +} from "./plugins"; + +const serverPlugins = [ + pluginRedactPII(), // redact every message before it reaches Claude + pluginCannedResponses({ + rules: [{ match: /refund/i, reply: "See our refund policy at /refunds." }], + }), + pluginAnalytics({ onEvent: (e) => console.log(e) }), +]; + +app.use("/api/chat", chatPlugins(serverPlugins)); +``` + +Redacting server-side is defense in depth — it runs even for clients that don't +ship the widget redactor. A canned (short-circuit) response skips the model, and +with it the built-in D1 analytics for that turn. + +## Notes + +- Hooks run in array order; the first `respondWith` / `abort` wins and stops + that hook's chain. +- A throwing hook is caught and logged — it never breaks the chat. +- The client and server interfaces are parallel but not identical: a widget + message carries an `id` and `sources`; the server works on plain + `{ role, content }` messages. + +## Other extension points + +These complement plugins and cover most customization without code: | Extension point | What it controls | |-----------------|------------------| | [System prompt](/configuration/worker/#system-prompt) (`worker/src/system-prompt.ts`) | The bot's personality, knowledge, guardrails, and FAQ answers | | [Translations](/configuration/localization/) | Every user-facing UI string | -| [Theming options](/configuration/theming/) and `widget/tailwind.config.ts` | Colors, fonts, radii | +| [Theming](/configuration/theming/) and `widget/tailwind.config.ts` | Colors, fonts, radii | | [Proactive triggers](/configuration/triggers/) | When the widget opens or greets proactively | | Worker env vars (`CLAUDE_MODEL`, `MAX_TOKENS`, rate limits) | Model behavior and abuse protection | -| Fork-level: `widget/src/` and `worker/src/` | Anything else — both packages are small, typed, and well-tested | - -For testing integrations, the widget exports a `MockChatApiClient` test -utility (`widget/src/test-utils/`) that fakes the API client without network -calls. ## Planned -These are tracked on the [v1.3.0 and v2.0.0 milestones](https://github.com/PMDevSolutions/Claudius/milestones): - - **Anthropic tool-use / function calling** with a declarative tool registry, so the bot can call your APIs mid-conversation — [#51](https://github.com/PMDevSolutions/Claudius/issues/51) -- **Plugin/hook SDK** for message middleware: pre-send and post-receive - transforms — [#45](https://github.com/PMDevSolutions/Claudius/issues/45) - **Plugin SDK RFC for Claudius v2** — the longer-term plugin architecture — [#79](https://github.com/PMDevSolutions/Claudius/issues/79) - -Nothing on this list is shipped yet. If you need one of these, comment on the -issue — it helps prioritization. diff --git a/widget/src/components/ChatWidget.tsx b/widget/src/components/ChatWidget.tsx index 8fb1877..ccd5eaf 100644 --- a/widget/src/components/ChatWidget.tsx +++ b/widget/src/components/ChatWidget.tsx @@ -13,6 +13,7 @@ import { import { resolveTranslations, type LocaleCode } from "../locales"; import { useTheme } from "../theme/useTheme"; import type { ClaudiusThemeInput } from "../theme/types"; +import type { ClaudiusPlugin } from "../plugins/types"; /** Corner of the viewport the widget docks to. */ export type WidgetPosition = @@ -66,6 +67,12 @@ export interface ChatWidgetProps { translations?: Partial; /** Proactive open/greeting rules evaluated against the current page. */ triggers?: Trigger[]; + /** + * Middleware run around each message: `onBeforeSend`, `onAfterReceive`, and + * `onError`. Hooks run in array order and may modify, replace, or + * short-circuit messages. See {@link ClaudiusPlugin}. + */ + plugins?: ClaudiusPlugin[]; } function readDismissed(): boolean { @@ -111,6 +118,7 @@ export function ChatWidget({ locale, translations: translationOverrides, triggers, + plugins, }: ChatWidgetProps) { const [isOpen, setIsOpen] = useState(false); const [greeting, setGreeting] = useState(null); @@ -129,6 +137,7 @@ export function ChatWidget({ storageKeyPrefix, timeoutMs: requestTimeoutMs, translations, + plugins, }); const toggleRef = useRef(null); const prevOpenRef = useRef(isOpen); diff --git a/widget/src/hooks/__tests__/useChat.plugins.test.ts b/widget/src/hooks/__tests__/useChat.plugins.test.ts new file mode 100644 index 0000000..1fe6753 --- /dev/null +++ b/widget/src/hooks/__tests__/useChat.plugins.test.ts @@ -0,0 +1,142 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useChat } from "../useChat"; +import type { ClaudiusPlugin } from "../../plugins/types"; +import { pluginRedactPII } from "../../plugins/reference/redact-pii"; +import { pluginCannedResponses } from "../../plugins/reference/canned-responses"; + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +function mockReplyOnce(reply: string) { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ reply }), + }); +} + +function lastFetchBody(): { messages: { role: string; content: string }[] } { + const init = mockFetch.mock.calls.at(-1)?.[1] as RequestInit; + return JSON.parse(init.body as string); +} + +describe("useChat plugins", () => { + beforeEach(() => { + mockFetch.mockReset(); + sessionStorage.clear(); + }); + + it("onBeforeSend transforms both the displayed and the sent message", async () => { + mockReplyOnce("ok"); + const { result } = renderHook(() => + useChat({ + apiUrl: "https://test.workers.dev", + plugins: [pluginRedactPII()], + }), + ); + + await act(async () => { + await result.current.sendMessage("email me at a@b.com"); + }); + + // Displayed user message is redacted... + expect(result.current.messages[0]).toMatchObject({ + role: "user", + content: "email me at [redacted]", + }); + // ...and so is what went over the wire. + expect(lastFetchBody().messages.at(-1)?.content).toBe( + "email me at [redacted]", + ); + }); + + it("onAfterReceive transforms the assistant reply before render", async () => { + mockReplyOnce("hello"); + const shout: ClaudiusPlugin = { + name: "shout", + onAfterReceive: (m) => ({ ...m, content: m.content.toUpperCase() }), + }; + const { result } = renderHook(() => + useChat({ apiUrl: "https://test.workers.dev", plugins: [shout] }), + ); + + await act(async () => { + await result.current.sendMessage("hi"); + }); + + expect(result.current.messages[1]).toMatchObject({ + role: "assistant", + content: "HELLO", + }); + }); + + it("a canned response short-circuits the network entirely", async () => { + const { result } = renderHook(() => + useChat({ + apiUrl: "https://test.workers.dev", + plugins: [ + pluginCannedResponses({ + rules: [{ match: "hours", reply: "9 to 5" }], + }), + ], + }), + ); + + await act(async () => { + await result.current.sendMessage("what are your hours?"); + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[1]).toMatchObject({ + role: "assistant", + content: "9 to 5", + }); + expect(result.current.error).toBeNull(); + }); + + it("abort drops the message and renders nothing", async () => { + const swallow: ClaudiusPlugin = { + name: "swallow", + onBeforeSend: (_m, ctx) => ctx.abort(), + }; + const { result } = renderHook(() => + useChat({ apiUrl: "https://test.workers.dev", plugins: [swallow] }), + ); + + await act(async () => { + await result.current.sendMessage("/secret-command"); + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.current.messages).toHaveLength(0); + }); + + it("onError recovers with a fallback reply instead of the error UI", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + headers: new Headers(), + json: () => Promise.resolve({ error: "boom", code: "UNKNOWN_ERROR" }), + }); + const recover: ClaudiusPlugin = { + name: "recover", + onError: (_e, ctx) => ctx.respondWith("We're offline — email help@x.com"), + }; + const { result } = renderHook(() => + useChat({ apiUrl: "https://test.workers.dev", plugins: [recover] }), + ); + + await act(async () => { + await result.current.sendMessage("hi"); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.messages.at(-1)).toMatchObject({ + role: "assistant", + content: "We're offline — email help@x.com", + }); + }); +}); diff --git a/widget/src/hooks/useChat.ts b/widget/src/hooks/useChat.ts index 9cc8d3f..3137017 100644 --- a/widget/src/hooks/useChat.ts +++ b/widget/src/hooks/useChat.ts @@ -3,6 +3,8 @@ import type { ClaudiusTranslations } from "../i18n"; import type { ChatMessage } from "../api/types"; import { ChatApiClient } from "../api/client"; import { ChatApiError, DebounceError } from "../api/errors"; +import type { ClaudiusPlugin } from "../plugins/types"; +import { runBeforeSend, runAfterReceive, runError } from "../plugins/runner"; interface UseChatOptions { apiUrl: string; @@ -10,6 +12,7 @@ interface UseChatOptions { storageKeyPrefix?: string; timeoutMs?: number; translations?: ClaudiusTranslations; + plugins?: readonly ClaudiusPlugin[]; } interface UseChatReturn { @@ -63,6 +66,7 @@ export function useChat({ storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX, timeoutMs, translations, + plugins, }: UseChatOptions): UseChatReturn { const client = useMemo( () => new ChatApiClient(apiUrl, { debounceMs: 0, timeoutMs }), @@ -82,6 +86,11 @@ export function useChat({ const isLoadingRef = useRef(false); const messagesRef = useRef(initialMessages); + // Hold the latest plugins in a ref so the send callbacks stay stable while + // always seeing the current array. + const pluginsRef = useRef(plugins ?? []); + pluginsRef.current = plugins ?? []; + const saveMessages = useCallback( (msgs: ChatMessage[]) => { if (!persistMessages) return; @@ -157,18 +166,51 @@ export function useChat({ try { const data = await client.sendMessage(msgsToSend); - const assistantMessage: ChatMessage = { + let assistantMessage: ChatMessage = { id: nextId(), role: "assistant", content: data.reply, sources: data.sources, }; + if (pluginsRef.current.length > 0) { + assistantMessage = await runAfterReceive( + pluginsRef.current, + assistantMessage, + { messages: msgsToSend, apiUrl }, + ); + } const withReply = [...msgsToSend, assistantMessage]; messagesRef.current = withReply; setMessages(withReply); saveMessages(withReply); } catch (err) { if (err instanceof DebounceError) return; + + // Give plugins a chance to recover with a fallback reply before we + // surface the error UI. + if (pluginsRef.current.length > 0) { + const error = err instanceof Error ? err : new Error(String(err)); + const recovery = await runError(pluginsRef.current, error, { + messages: msgsToSend, + apiUrl, + }); + if (recovery) { + const assistantMessage: ChatMessage = { + id: nextId(), + role: "assistant", + content: recovery.content, + sources: recovery.sources, + }; + const withReply = [...msgsToSend, assistantMessage]; + messagesRef.current = withReply; + setMessages(withReply); + saveMessages(withReply); + setError(null); + setCanRetry(false); + return; + } + } + if (err instanceof ChatApiError) { setError(getErrorMessage(err.code, err.message)); } else { @@ -183,7 +225,14 @@ export function useChat({ isLoadingRef.current = false; } }, - [client, getErrorMessage, isRetryableError, saveMessages, translations], + [ + apiUrl, + client, + getErrorMessage, + isRetryableError, + saveMessages, + translations, + ], ); const sendMessage = useCallback( @@ -197,14 +246,50 @@ export function useChat({ content: trimmed, }; - const updatedMessages = [...messagesRef.current, userMessage]; + let outgoing = userMessage; + + if (pluginsRef.current.length > 0) { + const outcome = await runBeforeSend(pluginsRef.current, userMessage, { + messages: messagesRef.current, + apiUrl, + }); + + // A plugin cancelled the send: drop the message, render nothing. + if (outcome.type === "abort") return; + + // A plugin answered locally: show the user message and the canned + // reply, and skip the network entirely. + if (outcome.type === "respond") { + const assistantMessage: ChatMessage = { + id: nextId(), + role: "assistant", + content: outcome.reply.content, + sources: outcome.reply.sources, + }; + const next = [ + ...messagesRef.current, + outcome.message, + assistantMessage, + ]; + messagesRef.current = next; + setMessages(next); + saveMessages(next); + setError(null); + setCanRetry(false); + return; + } + + outgoing = outcome.message; + } + + const updatedMessages = [...messagesRef.current, outgoing]; messagesRef.current = updatedMessages; setMessages(updatedMessages); saveMessages(updatedMessages); await submit(updatedMessages); }, - [saveMessages, submit], + [apiUrl, saveMessages, submit], ); const retry = useCallback(async () => { diff --git a/widget/src/index.ts b/widget/src/index.ts index 43a73f9..7fa6606 100644 --- a/widget/src/index.ts +++ b/widget/src/index.ts @@ -28,3 +28,28 @@ export type { ChatResponse, ChatErrorResponse, } from "./api/types"; + +// Plugin SDK: the ClaudiusPlugin interface, supporting types, and the three +// reference plugins. +export type { + ClaudiusPlugin, + PluginContext, + BeforeSendContext, + ErrorContext, + PluginReply, + MaybePromise, +} from "./plugins"; +export { + pluginAnalytics, + pluginRedactPII, + pluginCannedResponses, + redactText, + DEFAULT_PII_PATTERNS, +} from "./plugins"; +export type { + AnalyticsPluginOptions, + ClaudiusAnalyticsEvent, + RedactPiiOptions, + CannedRule, + CannedResponsesOptions, +} from "./plugins"; diff --git a/widget/src/plugins/__tests__/reference.test.ts b/widget/src/plugins/__tests__/reference.test.ts new file mode 100644 index 0000000..edaab7c --- /dev/null +++ b/widget/src/plugins/__tests__/reference.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from "vitest"; +import { runBeforeSend, runAfterReceive } from "../runner"; +import { + pluginAnalytics, + type ClaudiusAnalyticsEvent, +} from "../reference/analytics"; +import { pluginRedactPII, redactText } from "../reference/redact-pii"; +import { pluginCannedResponses } from "../reference/canned-responses"; +import type { ClaudiusPlugin, PluginContext } from "../types"; +import type { ChatMessage } from "../../api/types"; + +const base: PluginContext = { messages: [], apiUrl: "https://api.test" }; +const userMsg = (content: string): ChatMessage => ({ + id: "u1", + role: "user", + content, +}); +const assistantMsg = (content: string): ChatMessage => ({ + id: "a1", + role: "assistant", + content, +}); + +describe("pluginAnalytics", () => { + it("emits sent, received, and error events", async () => { + const events: ClaudiusAnalyticsEvent[] = []; + const plugin = pluginAnalytics({ onEvent: (e) => events.push(e) }); + + await runBeforeSend([plugin], userMsg("hello"), base); + await runAfterReceive([plugin], assistantMsg("hi there"), base); + await plugin.onError?.( + Object.assign(new Error("boom"), { code: "TIMEOUT" }), + { ...base, respondWith: () => {} }, + ); + + expect(events).toEqual([ + { type: "message_sent", role: "user", content: "hello", chars: 5 }, + { + type: "message_received", + role: "assistant", + content: "hi there", + chars: 8, + }, + { type: "chat_error", message: "boom", code: "TIMEOUT" }, + ]); + }); + + it("omits content but keeps chars when includeContent is false", async () => { + const events: ClaudiusAnalyticsEvent[] = []; + const plugin = pluginAnalytics({ + onEvent: (e) => events.push(e), + includeContent: false, + }); + + await runBeforeSend([plugin], userMsg("secret"), base); + expect(events[0]).toEqual({ + type: "message_sent", + role: "user", + content: "", + chars: 6, + }); + }); + + it("never modifies the message", async () => { + const plugin = pluginAnalytics({ onEvent: () => {} }); + const outcome = await runBeforeSend([plugin], userMsg("untouched"), base); + expect(outcome).toMatchObject({ + type: "send", + message: { content: "untouched" }, + }); + }); +}); + +describe("pluginRedactPII", () => { + it("redacts emails, phones, SSNs, and card numbers from outgoing messages", async () => { + const plugin = pluginRedactPII(); + const outcome = await runBeforeSend( + [plugin], + userMsg("mail me at a@b.com or call 555-123-4567"), + base, + ); + expect(outcome).toMatchObject({ type: "send" }); + if (outcome.type !== "send") throw new Error("expected send"); + expect(outcome.message.content).toBe( + "mail me at [redacted] or call [redacted]", + ); + }); + + it("redactText handles SSN and card-like sequences", () => { + expect(redactText("ssn 123-45-6789", [/\b\d{3}-\d{2}-\d{4}\b/g], "X")).toBe( + "ssn X", + ); + }); + + it("returns nothing when there is no PII (message unchanged)", async () => { + const plugin = pluginRedactPII(); + const outcome = await runBeforeSend([plugin], userMsg("just hello"), base); + expect(outcome).toMatchObject({ + type: "send", + message: { content: "just hello" }, + }); + }); + + it("supports a custom replacement string", async () => { + const plugin = pluginRedactPII({ replacement: "***" }); + const outcome = await runBeforeSend([plugin], userMsg("a@b.com"), base); + if (outcome.type !== "send") throw new Error("expected send"); + expect(outcome.message.content).toBe("***"); + }); + + it("does not touch replies unless redactReplies is set", async () => { + const off = pluginRedactPII(); + expect(off.onAfterReceive).toBeUndefined(); + + const on = pluginRedactPII({ redactReplies: true }); + const result = await runAfterReceive( + [on], + assistantMsg("reach a@b.com"), + base, + ); + expect(result.content).toBe("reach [redacted]"); + }); +}); + +describe("pluginCannedResponses", () => { + const run = (plugin: ClaudiusPlugin, content: string) => + runBeforeSend([plugin], userMsg(content), base); + + it("answers a case-insensitive substring match without hitting the network", async () => { + const plugin = pluginCannedResponses({ + rules: [{ match: "hours", reply: "9 to 5" }], + }); + const outcome = await run(plugin, "What are your HOURS?"); + expect(outcome).toMatchObject({ + type: "respond", + reply: { content: "9 to 5" }, + }); + }); + + it("supports RegExp and predicate matchers, first match wins", async () => { + const plugin = pluginCannedResponses({ + rules: [ + { match: /pricing|cost/i, reply: "See /pricing" }, + { match: (c) => c.length > 100, reply: "too long" }, + ], + }); + expect(await run(plugin, "what is the cost")).toMatchObject({ + type: "respond", + reply: { content: "See /pricing" }, + }); + expect(await run(plugin, "x".repeat(101))).toMatchObject({ + type: "respond", + reply: { content: "too long" }, + }); + }); + + it("passes through (sends) when no rule matches", async () => { + const plugin = pluginCannedResponses({ + rules: [{ match: "hours", reply: "x" }], + }); + const outcome = await run(plugin, "unrelated question"); + expect(outcome.type).toBe("send"); + }); + + it("honors caseSensitive for string matchers", async () => { + const plugin = pluginCannedResponses({ + rules: [{ match: "Help", reply: "hi" }], + caseSensitive: true, + }); + expect((await run(plugin, "help me")).type).toBe("send"); + expect((await run(plugin, "Help me")).type).toBe("respond"); + }); +}); diff --git a/widget/src/plugins/__tests__/runner.test.ts b/widget/src/plugins/__tests__/runner.test.ts new file mode 100644 index 0000000..8636930 --- /dev/null +++ b/widget/src/plugins/__tests__/runner.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi } from "vitest"; +import { runBeforeSend, runAfterReceive, runError } from "../runner"; +import type { ClaudiusPlugin, PluginContext } from "../types"; +import type { ChatMessage } from "../../api/types"; + +const base: PluginContext = { messages: [], apiUrl: "https://api.test" }; + +const userMsg = (content = "hi"): ChatMessage => ({ + id: "u1", + role: "user", + content, +}); +const assistantMsg = (content = "yo"): ChatMessage => ({ + id: "a1", + role: "assistant", + content, +}); + +describe("runBeforeSend", () => { + it("returns the original message when no plugins transform it", async () => { + const outcome = await runBeforeSend([], userMsg("hello"), base); + expect(outcome).toEqual({ type: "send", message: userMsg("hello") }); + }); + + it("threads a transformed message through the chain in order", async () => { + const upper: ClaudiusPlugin = { + name: "upper", + onBeforeSend: (m) => ({ ...m, content: m.content.toUpperCase() }), + }; + const bang: ClaudiusPlugin = { + name: "bang", + onBeforeSend: (m) => ({ ...m, content: `${m.content}!` }), + }; + + const outcome = await runBeforeSend([upper, bang], userMsg("hi"), base); + expect(outcome).toMatchObject({ + type: "send", + message: { content: "HI!" }, + }); + }); + + it("awaits async hooks", async () => { + const slow: ClaudiusPlugin = { + name: "slow", + onBeforeSend: async (m) => { + await Promise.resolve(); + return { ...m, content: "async" }; + }, + }; + const outcome = await runBeforeSend([slow], userMsg(), base); + expect(outcome).toMatchObject({ + type: "send", + message: { content: "async" }, + }); + }); + + it("short-circuits with respondWith, carrying the transformed user message", async () => { + const transform: ClaudiusPlugin = { + name: "transform", + onBeforeSend: (m) => ({ ...m, content: "seen" }), + }; + const canned: ClaudiusPlugin = { + name: "canned", + onBeforeSend: (_m, ctx) => ctx.respondWith("canned reply"), + }; + const never = vi.fn(); + const after: ClaudiusPlugin = { name: "after", onBeforeSend: never }; + + const outcome = await runBeforeSend( + [transform, canned, after], + userMsg(), + base, + ); + + expect(outcome).toEqual({ + type: "respond", + message: { id: "u1", role: "user", content: "seen" }, + reply: { content: "canned reply" }, + }); + expect(never).not.toHaveBeenCalled(); + }); + + it("supports respondWith with a full PluginReply (sources)", async () => { + const sources = [{ url: "https://x", title: "X", type: "page" as const }]; + const plugin: ClaudiusPlugin = { + name: "p", + onBeforeSend: (_m, ctx) => ctx.respondWith({ content: "r", sources }), + }; + const outcome = await runBeforeSend([plugin], userMsg(), base); + expect(outcome).toMatchObject({ + type: "respond", + reply: { content: "r", sources }, + }); + }); + + it("aborts and stops the chain", async () => { + const never = vi.fn(); + const abort: ClaudiusPlugin = { + name: "abort", + onBeforeSend: (_m, ctx) => ctx.abort("nope"), + }; + const after: ClaudiusPlugin = { name: "after", onBeforeSend: never }; + + const outcome = await runBeforeSend([abort, after], userMsg(), base); + expect(outcome).toEqual({ type: "abort", reason: "nope" }); + expect(never).not.toHaveBeenCalled(); + }); + + it("isolates a throwing hook and continues the chain", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const boom: ClaudiusPlugin = { + name: "boom", + onBeforeSend: () => { + throw new Error("kaboom"); + }, + }; + const ok: ClaudiusPlugin = { + name: "ok", + onBeforeSend: (m) => ({ ...m, content: "recovered" }), + }; + + const outcome = await runBeforeSend([boom, ok], userMsg(), base); + expect(outcome).toMatchObject({ + type: "send", + message: { content: "recovered" }, + }); + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('plugin "boom" failed in onBeforeSend'), + expect.any(Error), + ); + spy.mockRestore(); + }); +}); + +describe("runAfterReceive", () => { + it("threads transforms in order", async () => { + const a: ClaudiusPlugin = { + name: "a", + onAfterReceive: (m) => ({ ...m, content: `${m.content}-a` }), + }; + const b: ClaudiusPlugin = { + name: "b", + onAfterReceive: (m) => ({ ...m, content: `${m.content}-b` }), + }; + const result = await runAfterReceive([a, b], assistantMsg("x"), base); + expect(result.content).toBe("x-a-b"); + }); + + it("leaves the message unchanged when a hook returns nothing", async () => { + const noop: ClaudiusPlugin = { + name: "noop", + onAfterReceive: () => undefined, + }; + const result = await runAfterReceive([noop], assistantMsg("keep"), base); + expect(result.content).toBe("keep"); + }); + + it("isolates a throwing hook", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const boom: ClaudiusPlugin = { + name: "boom", + onAfterReceive: () => { + throw new Error("x"); + }, + }; + const result = await runAfterReceive([boom], assistantMsg("safe"), base); + expect(result.content).toBe("safe"); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe("runError", () => { + it("returns the first recovery reply", async () => { + const observe: ClaudiusPlugin = { name: "observe", onError: vi.fn() }; + const recover: ClaudiusPlugin = { + name: "recover", + onError: (_e, ctx) => ctx.respondWith("fallback"), + }; + const later = vi.fn(); + const after: ClaudiusPlugin = { name: "after", onError: later }; + + const reply = await runError( + [observe, recover, after], + new Error("fail"), + base, + ); + expect(reply).toEqual({ content: "fallback" }); + expect(later).not.toHaveBeenCalled(); + }); + + it("returns null when no plugin recovers", async () => { + const observe: ClaudiusPlugin = { name: "observe", onError: vi.fn() }; + const reply = await runError([observe], new Error("fail"), base); + expect(reply).toBeNull(); + }); + + it("isolates a throwing onError hook", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const boom: ClaudiusPlugin = { + name: "boom", + onError: () => { + throw new Error("x"); + }, + }; + const reply = await runError([boom], new Error("fail"), base); + expect(reply).toBeNull(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/widget/src/plugins/index.ts b/widget/src/plugins/index.ts new file mode 100644 index 0000000..8252926 --- /dev/null +++ b/widget/src/plugins/index.ts @@ -0,0 +1,32 @@ +export type { + ClaudiusPlugin, + PluginContext, + BeforeSendContext, + ErrorContext, + PluginReply, + MaybePromise, +} from "./types"; + +export { + runBeforeSend, + runAfterReceive, + runError, + type BeforeSendOutcome, +} from "./runner"; + +export { + pluginAnalytics, + type AnalyticsPluginOptions, + type ClaudiusAnalyticsEvent, +} from "./reference/analytics"; +export { + pluginRedactPII, + redactText, + DEFAULT_PII_PATTERNS, + type RedactPiiOptions, +} from "./reference/redact-pii"; +export { + pluginCannedResponses, + type CannedRule, + type CannedResponsesOptions, +} from "./reference/canned-responses"; diff --git a/widget/src/plugins/reference/analytics.ts b/widget/src/plugins/reference/analytics.ts new file mode 100644 index 0000000..60a9296 --- /dev/null +++ b/widget/src/plugins/reference/analytics.ts @@ -0,0 +1,93 @@ +import type { ClaudiusPlugin } from "../types"; + +/** An analytics event emitted by {@link pluginAnalytics}. */ +export type ClaudiusAnalyticsEvent = + | { + /** Event discriminant. */ + type: "message_sent"; + /** Always `"user"`. */ + role: "user"; + /** Message text, or `""` when `includeContent` is `false`. */ + content: string; + /** Length of the message in characters. */ + chars: number; + } + | { + /** Event discriminant. */ + type: "message_received"; + /** Always `"assistant"`. */ + role: "assistant"; + /** Reply text, or `""` when `includeContent` is `false`. */ + content: string; + /** Length of the reply in characters. */ + chars: number; + } + | { + /** Event discriminant. */ + type: "chat_error"; + /** The error message. */ + message: string; + /** Machine-readable error code, when available (e.g. `"TIMEOUT"`). */ + code?: string; + }; + +/** Options for {@link pluginAnalytics}. */ +export interface AnalyticsPluginOptions { + /** + * Sink called once per chat lifecycle event. Wire it to your analytics + * provider (Google Analytics, PostHog, Segment, a custom endpoint, ...). + */ + onEvent: (event: ClaudiusAnalyticsEvent) => void; + /** + * Include message text in `message_sent` / `message_received` events. Set to + * `false` to record only the character count and avoid logging user content. + * @defaultValue `true` + */ + includeContent?: boolean; +} + +/** + * Reference plugin that emits a structured analytics event for every message + * sent, every reply received, and every error. It never modifies messages. + * + * @example + * ```ts + * gtag("event", e.type, e) })]} + * /> + * ``` + */ +export function pluginAnalytics( + options: AnalyticsPluginOptions, +): ClaudiusPlugin { + const { onEvent, includeContent = true } = options; + const textOf = (content: string) => (includeContent ? content : ""); + + return { + name: "analytics", + onBeforeSend(message) { + onEvent({ + type: "message_sent", + role: "user", + content: textOf(message.content), + chars: message.content.length, + }); + }, + onAfterReceive(message) { + onEvent({ + type: "message_received", + role: "assistant", + content: textOf(message.content), + chars: message.content.length, + }); + }, + onError(error) { + onEvent({ + type: "chat_error", + message: error.message, + code: (error as { code?: string }).code, + }); + }, + }; +} diff --git a/widget/src/plugins/reference/canned-responses.ts b/widget/src/plugins/reference/canned-responses.ts new file mode 100644 index 0000000..b292452 --- /dev/null +++ b/widget/src/plugins/reference/canned-responses.ts @@ -0,0 +1,74 @@ +import type { ClaudiusPlugin, PluginReply } from "../types"; + +/** A single intent-matching rule for {@link pluginCannedResponses}. */ +export interface CannedRule { + /** + * How to match the user's message: + * - `string` — case-insensitive substring match (see + * {@link CannedResponsesOptions.caseSensitive}). + * - `RegExp` — tested against the message content. + * - function — receives the content and returns whether it matches. + */ + match: string | RegExp | ((content: string) => boolean); + /** The reply rendered when the rule matches. */ + reply: string | PluginReply; +} + +/** Options for {@link pluginCannedResponses}. */ +export interface CannedResponsesOptions { + /** Rules evaluated in order; the first match wins. */ + rules: CannedRule[]; + /** + * Make `string` matchers case-sensitive. + * @defaultValue `false` + */ + caseSensitive?: boolean; +} + +function ruleMatches( + match: CannedRule["match"], + content: string, + caseSensitive: boolean, +): boolean { + if (typeof match === "function") return match(content); + if (match instanceof RegExp) return match.test(content); + return caseSensitive + ? content.includes(match) + : content.toLowerCase().includes(match.toLowerCase()); +} + +/** + * Reference plugin that answers matching messages locally, without calling the + * API. The first rule whose matcher fires short-circuits the send via + * `ctx.respondWith`, so the network is never hit for that turn. + * + * @example + * ```ts + * + * ``` + */ +export function pluginCannedResponses( + options: CannedResponsesOptions, +): ClaudiusPlugin { + const { rules, caseSensitive = false } = options; + + return { + name: "canned-responses", + onBeforeSend(message, ctx) { + for (const rule of rules) { + if (ruleMatches(rule.match, message.content, caseSensitive)) { + ctx.respondWith(rule.reply); + return; + } + } + }, + }; +} diff --git a/widget/src/plugins/reference/redact-pii.ts b/widget/src/plugins/reference/redact-pii.ts new file mode 100644 index 0000000..d565698 --- /dev/null +++ b/widget/src/plugins/reference/redact-pii.ts @@ -0,0 +1,86 @@ +import type { ChatMessage } from "../../api/types"; +import type { ClaudiusPlugin } from "../types"; + +/** + * Default PII patterns: email addresses, North-American-style phone numbers, + * US Social Security numbers, and 13–16 digit card-like sequences. These are + * intentionally conservative starting points — tune {@link RedactPiiOptions.patterns} + * for your data. + */ +export const DEFAULT_PII_PATTERNS: readonly RegExp[] = [ + // Email + /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, + // US SSN (before the looser card pattern so it wins on 9-digit groups) + /\b\d{3}-\d{2}-\d{4}\b/g, + // Card-like: 13–16 digits, optionally separated by spaces or hyphens + /\b(?:\d[ -]?){13,16}\b/g, + // Phone: optional +1, area code, 7 digits with common separators + /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, +]; + +/** Options for {@link pluginRedactPII}. */ +export interface RedactPiiOptions { + /** + * Patterns to redact. Each must carry the global (`g`) flag. + * @defaultValue {@link DEFAULT_PII_PATTERNS} + */ + patterns?: readonly RegExp[]; + /** + * Text substituted for each match. + * @defaultValue `"[redacted]"` + */ + replacement?: string; + /** + * Also redact the assistant's replies, not just outgoing user messages. + * @defaultValue `false` + */ + redactReplies?: boolean; +} + +/** Replace every match of every pattern in `text` with `replacement`. */ +export function redactText( + text: string, + patterns: readonly RegExp[], + replacement: string, +): string { + return patterns.reduce( + // Reset lastIndex defensively: a shared global regex is stateful. + (acc, pattern) => { + pattern.lastIndex = 0; + return acc.replace(pattern, replacement); + }, + text, + ); +} + +/** + * Reference plugin that strips PII from the user's message before it leaves the + * browser. The redacted text is what gets displayed and sent, so the user sees + * that redaction happened. Optionally redacts assistant replies too. + * + * @example + * ```ts + * + * ``` + */ +export function pluginRedactPII( + options: RedactPiiOptions = {}, +): ClaudiusPlugin { + const { + patterns = DEFAULT_PII_PATTERNS, + replacement = "[redacted]", + redactReplies = false, + } = options; + + const redact = (message: ChatMessage): ChatMessage | void => { + const content = redactText(message.content, patterns, replacement); + if (content === message.content) return; + return { ...message, content }; + }; + + return { + name: "redact-pii", + onBeforeSend: redact, + onAfterReceive: redactReplies ? redact : undefined, + }; +} diff --git a/widget/src/plugins/runner.ts b/widget/src/plugins/runner.ts new file mode 100644 index 0000000..a534bf1 --- /dev/null +++ b/widget/src/plugins/runner.ts @@ -0,0 +1,126 @@ +import type { ChatMessage } from "../api/types"; +import type { + BeforeSendContext, + ClaudiusPlugin, + ErrorContext, + PluginContext, + PluginReply, +} from "./types"; + +/** + * The outcome of running the `onBeforeSend` chain: + * - `send` — proceed to the network with `message` (possibly transformed). + * - `respond` — skip the network; render `message` plus the synthesized `reply`. + * - `abort` — drop the message; render nothing. + */ +export type BeforeSendOutcome = + | { type: "send"; message: ChatMessage } + | { type: "respond"; message: ChatMessage; reply: PluginReply } + | { type: "abort"; reason?: string }; + +function normalizeReply(reply: string | PluginReply): PluginReply { + return typeof reply === "string" ? { content: reply } : reply; +} + +function warn(plugin: ClaudiusPlugin, hook: string, error: unknown): void { + // A misbehaving plugin must not break the chat: log and carry on. + console.error(`[claudius] plugin "${plugin.name}" failed in ${hook}:`, error); +} + +/** + * Run every plugin's `onBeforeSend` hook in order, threading the (possibly + * transformed) message through the chain. The first hook to call + * `respondWith` or `abort` stops the chain. + */ +export async function runBeforeSend( + plugins: readonly ClaudiusPlugin[], + message: ChatMessage, + base: PluginContext, +): Promise { + let current = message; + + for (const plugin of plugins) { + if (!plugin.onBeforeSend) continue; + + let shortCircuit: BeforeSendOutcome | null = null; + const ctx: BeforeSendContext = { + ...base, + respondWith(reply) { + shortCircuit = { + type: "respond", + message: current, + reply: normalizeReply(reply), + }; + }, + abort(reason) { + shortCircuit = { type: "abort", reason }; + }, + }; + + try { + const result = await plugin.onBeforeSend(current, ctx); + if (shortCircuit) return shortCircuit; + if (result) current = result; + } catch (error) { + warn(plugin, "onBeforeSend", error); + } + } + + return { type: "send", message: current }; +} + +/** + * Run every plugin's `onAfterReceive` hook in order, threading the (possibly + * transformed) assistant message through the chain. Returns the final message. + */ +export async function runAfterReceive( + plugins: readonly ClaudiusPlugin[], + message: ChatMessage, + base: PluginContext, +): Promise { + let current = message; + + for (const plugin of plugins) { + if (!plugin.onAfterReceive) continue; + try { + const result = await plugin.onAfterReceive(current, base); + if (result) current = result; + } catch (error) { + warn(plugin, "onAfterReceive", error); + } + } + + return current; +} + +/** + * Run every plugin's `onError` hook in order. Returns the first recovery reply + * a hook supplies via `respondWith`, or `null` if none recover (the caller + * then falls back to the normal error UI). + */ +export async function runError( + plugins: readonly ClaudiusPlugin[], + error: Error, + base: PluginContext, +): Promise { + for (const plugin of plugins) { + if (!plugin.onError) continue; + + let recovery: PluginReply | null = null; + const ctx: ErrorContext = { + ...base, + respondWith(reply) { + recovery = normalizeReply(reply); + }, + }; + + try { + await plugin.onError(error, ctx); + if (recovery) return recovery; + } catch (hookError) { + warn(plugin, "onError", hookError); + } + } + + return null; +} diff --git a/widget/src/plugins/types.ts b/widget/src/plugins/types.ts new file mode 100644 index 0000000..661f7dd --- /dev/null +++ b/widget/src/plugins/types.ts @@ -0,0 +1,111 @@ +import type { ChatMessage, Source } from "../api/types"; + +/** A value that may be returned synchronously or as a promise. */ +export type MaybePromise = T | Promise; + +/** + * A synthesized assistant reply, produced by a plugin instead of (or in + * recovery from) a network round-trip. Passed to + * {@link BeforeSendContext.respondWith} and {@link ErrorContext.respondWith}. + */ +export interface PluginReply { + /** The assistant reply text to render. */ + content: string; + /** Optional sources to attach to the synthesized reply. */ + sources?: Source[]; +} + +/** + * Read-only context shared by every plugin hook. + * + * `messages` is a snapshot of the conversation at the moment the hook runs: + * in {@link ClaudiusPlugin.onBeforeSend} it excludes the in-flight user + * message; in {@link ClaudiusPlugin.onAfterReceive} and + * {@link ClaudiusPlugin.onError} it includes it. + */ +export interface PluginContext { + /** Conversation snapshot, oldest message first. Treat as immutable. */ + readonly messages: readonly ChatMessage[]; + /** The Worker chat endpoint URL the widget posts to. */ + readonly apiUrl: string; +} + +/** + * Context for {@link ClaudiusPlugin.onBeforeSend}. Adds the ability to + * short-circuit the request before it reaches the network. + */ +export interface BeforeSendContext extends PluginContext { + /** + * Skip the network request and render this assistant reply instead. The + * (possibly modified) user message is still shown. Stops the hook chain — + * later plugins' `onBeforeSend` hooks do not run. + */ + respondWith(reply: string | PluginReply): void; + /** + * Cancel the send entirely: no request is made and nothing is rendered (the + * user message is dropped). Stops the hook chain. Use for client-only + * commands the chat should swallow. + */ + abort(reason?: string): void; +} + +/** + * Context for {@link ClaudiusPlugin.onError}. Adds the ability to recover from + * a failed send by rendering a reply in place of the error UI. + */ +export interface ErrorContext extends PluginContext { + /** + * Recover from the failure by rendering this assistant reply instead of the + * error state. Stops the hook chain — later plugins' `onError` hooks do not + * run. + */ + respondWith(reply: string | PluginReply): void; +} + +/** + * A client-side middleware that runs around the chat message lifecycle. Pass + * an array of plugins to {@link ChatWidget} via the `plugins` prop; hooks run + * in array order. + * + * Hooks may be async, and may modify, replace, or short-circuit messages. A + * hook that throws is caught and logged — a misbehaving plugin will not break + * the chat — so security-sensitive transforms (e.g. PII redaction) should be + * written defensively. + * + * @example + * ```ts + * const logger: ClaudiusPlugin = { + * name: "logger", + * onBeforeSend: (message) => { console.log("sending", message.content); }, + * onAfterReceive: (message) => { console.log("received", message.content); }, + * }; + * ``` + */ +export interface ClaudiusPlugin { + /** Stable identifier, used in log messages. */ + name: string; + /** + * Runs before the user message is sent. Return a {@link ChatMessage} to + * replace it (the returned message is both displayed and sent), return + * nothing to leave it unchanged, or call {@link BeforeSendContext.respondWith} + * / {@link BeforeSendContext.abort} to short-circuit. + */ + onBeforeSend?( + message: ChatMessage, + ctx: BeforeSendContext, + ): MaybePromise; + /** + * Runs after the assistant reply is received, before it is rendered. Return + * a {@link ChatMessage} to replace it, or nothing to leave it unchanged. + */ + onAfterReceive?( + message: ChatMessage, + ctx: PluginContext, + ): MaybePromise; + /** + * Runs when a send fails. Observe the error, or call + * {@link ErrorContext.respondWith} to render a fallback reply instead of the + * error UI. + */ + onError?(error: Error, ctx: ErrorContext): MaybePromise; +} diff --git a/worker/src/__tests__/chat-route-success.test.ts b/worker/src/__tests__/chat-route-success.test.ts new file mode 100644 index 0000000..4fef094 --- /dev/null +++ b/worker/src/__tests__/chat-route-success.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from "vitest"; + +// Mock the Anthropic SDK so the success path returns a deterministic reply. +vi.mock("@anthropic-ai/sdk", () => ({ + default: class MockAnthropic { + messages = { + create: vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "Hi from model" }], + usage: { input_tokens: 3, output_tokens: 4 }, + }), + }; + }, +})); + +import app from "../index"; + +interface ChatRouteEnv { + ANTHROPIC_API_KEY: string; + ALLOWED_ORIGIN: string; + RATE_LIMIT: KVNamespace; +} + +function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string) => store.get(key) ?? null, + put: async (key: string, value: string) => void store.set(key, value), + } as unknown as KVNamespace; +} + +function createMockCtx(): ExecutionContext { + return { + waitUntil: () => {}, + passThroughOnException: () => {}, + } as unknown as ExecutionContext; +} + +describe("POST /api/chat success path", () => { + it("returns the model reply (covers the stashed-request fallback read)", async () => { + const env: ChatRouteEnv = { + ANTHROPIC_API_KEY: "test-key", + ALLOWED_ORIGIN: "http://localhost:5173", + RATE_LIMIT: createMockKV(), + }; + + const request = new Request("http://localhost/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await app.fetch(request, env, createMockCtx()); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ reply: "Hi from model" }); + }); +}); diff --git a/worker/src/__tests__/plugins-middleware.test.ts b/worker/src/__tests__/plugins-middleware.test.ts new file mode 100644 index 0000000..428d504 --- /dev/null +++ b/worker/src/__tests__/plugins-middleware.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import { chatPlugins } from "../plugins/middleware"; +import { + pluginRedactPII, + pluginCannedResponses, +} from "../plugins/reference"; +import type { ClaudiusServerPlugin } from "../plugins/types"; +import type { ChatRequest } from "../chat"; + +/** + * Exercise the `chatPlugins` Hono middleware against a stub route that echoes + * the last message it receives. This isolates the middleware contract from the + * Anthropic call: a canned short-circuit never reaches the handler, a + * `onBeforeSend` transform changes what the handler sees, and an + * `onAfterReceive` transform rewrites the handler's response. + */ +function buildApp(plugins: ClaudiusServerPlugin[]) { + const app = new Hono<{ Variables: { chatRequest?: ChatRequest } }>(); + app.use("/chat", chatPlugins(plugins)); + app.post("/chat", async (c) => { + const body = c.get("chatRequest") ?? (await c.req.json()); + const last = body.messages.at(-1); + return c.json({ reply: `model:${last?.content}` }); + }); + return app; +} + +function post(app: ReturnType, ...contents: string[]) { + return app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: contents.map((content) => ({ role: "user", content })), + }), + }); +} + +describe("chatPlugins middleware", () => { + it("passes through transparently with no plugins", async () => { + const res = await post(buildApp([]), "hello"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ reply: "model:hello" }); + }); + + it("short-circuits a canned response without reaching the handler", async () => { + const app = buildApp([ + pluginCannedResponses({ rules: [{ match: "hours", reply: "9 to 5" }] }), + ]); + const res = await post(app, "what are your hours?"); + expect(res.status).toBe(200); + // "model:" prefix would be present only if the handler ran. + expect(await res.json()).toEqual({ reply: "9 to 5" }); + }); + + it("onBeforeSend transforms what the handler receives", async () => { + const app = buildApp([pluginRedactPII()]); + const res = await post(app, "ping a@b.com"); + expect(await res.json()).toEqual({ reply: "model:ping [redacted]" }); + }); + + it("onAfterReceive rewrites the handler's reply, preserving status and JSON", async () => { + const shout: ClaudiusServerPlugin = { + name: "shout", + onAfterReceive: (reply) => reply.toUpperCase(), + }; + const res = await post(buildApp([shout]), "hi"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(await res.json()).toEqual({ reply: "MODEL:HI" }); + }); + + it("leaves non-JSON / error responses untouched", async () => { + const app = new Hono<{ Variables: { chatRequest?: ChatRequest } }>(); + const shout: ClaudiusServerPlugin = { + name: "shout", + onAfterReceive: (reply) => reply.toUpperCase(), + }; + app.use("/chat", chatPlugins([shout])); + app.post("/chat", (c) => c.text("plain", 500)); + + const res = await post(app, "hi"); + expect(res.status).toBe(500); + expect(await res.text()).toBe("plain"); + }); +}); diff --git a/worker/src/__tests__/plugins.test.ts b/worker/src/__tests__/plugins.test.ts new file mode 100644 index 0000000..4b68456 --- /dev/null +++ b/worker/src/__tests__/plugins.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi } from "vitest"; +import { + runServerBeforeSend, + runServerAfterReceive, + runServerError, +} from "../plugins/runner"; +import { + pluginAnalytics, + pluginRedactPII, + pluginCannedResponses, + redactServerText, + type ServerAnalyticsEvent, +} from "../plugins/reference"; +import type { ClaudiusServerPlugin, ServerPluginContext } from "../plugins/types"; +import type { ChatMessage } from "../chat"; + +const base: ServerPluginContext = { messages: [], env: {} }; +const msgs = (...contents: string[]): ChatMessage[] => + contents.map((content) => ({ role: "user", content })); + +describe("runServerBeforeSend", () => { + it("threads transformed messages through the chain", async () => { + const tag: ClaudiusServerPlugin = { + name: "tag", + onBeforeSend: (m) => m.map((x) => ({ ...x, content: `[${x.content}]` })), + }; + const outcome = await runServerBeforeSend([tag], msgs("hi"), base); + expect(outcome).toEqual({ type: "send", messages: [{ role: "user", content: "[hi]" }] }); + }); + + it("short-circuits on respondWith and stops the chain", async () => { + const canned: ClaudiusServerPlugin = { + name: "canned", + onBeforeSend: (_m, ctx) => ctx.respondWith("answered"), + }; + const never = vi.fn(); + const after: ClaudiusServerPlugin = { name: "after", onBeforeSend: never }; + + const outcome = await runServerBeforeSend([canned, after], msgs("q"), base); + expect(outcome).toEqual({ type: "respond", reply: "answered" }); + expect(never).not.toHaveBeenCalled(); + }); + + it("isolates a throwing hook", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const boom: ClaudiusServerPlugin = { + name: "boom", + onBeforeSend: () => { + throw new Error("x"); + }, + }; + const outcome = await runServerBeforeSend([boom], msgs("hi"), base); + expect(outcome).toEqual({ type: "send", messages: [{ role: "user", content: "hi" }] }); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe("runServerAfterReceive", () => { + it("threads reply transforms in order, allowing empty results", async () => { + const a: ClaudiusServerPlugin = { name: "a", onAfterReceive: (r) => `${r}-a` }; + const result = await runServerAfterReceive([a], "x", base); + expect(result).toBe("x-a"); + + const blank: ClaudiusServerPlugin = { name: "blank", onAfterReceive: () => "" }; + expect(await runServerAfterReceive([blank], "x", base)).toBe(""); + }); + + it("leaves the reply unchanged when a hook returns void", async () => { + const noop: ClaudiusServerPlugin = { name: "noop", onAfterReceive: () => undefined }; + expect(await runServerAfterReceive([noop], "keep", base)).toBe("keep"); + }); +}); + +describe("runServerError", () => { + it("invokes every onError and isolates throws", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const seen: string[] = []; + const observe: ClaudiusServerPlugin = { + name: "observe", + onError: (e) => void seen.push(e.message), + }; + const boom: ClaudiusServerPlugin = { + name: "boom", + onError: () => { + throw new Error("y"); + }, + }; + await runServerError([observe, boom], new Error("fail"), base); + expect(seen).toEqual(["fail"]); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe("reference plugins", () => { + it("pluginAnalytics emits request/response/error events", async () => { + const events: ServerAnalyticsEvent[] = []; + const plugin = pluginAnalytics({ onEvent: (e) => events.push(e) }); + + await runServerBeforeSend( + [plugin], + [ + { role: "user", content: "first" }, + { role: "assistant", content: "..." }, + { role: "user", content: "second" }, + ], + base, + ); + await runServerAfterReceive([plugin], "a reply", base); + await runServerError([plugin], new Error("oops"), base); + + expect(events).toEqual([ + { type: "request", messageCount: 3, lastUserChars: 6 }, + { type: "response", chars: 7 }, + { type: "error", message: "oops" }, + ]); + }); + + it("pluginRedactPII redacts every message and (optionally) the reply", async () => { + const outcome = await runServerBeforeSend( + [pluginRedactPII()], + [{ role: "user", content: "call 555-123-4567 or a@b.com" }], + base, + ); + if (outcome.type !== "send") throw new Error("expected send"); + expect(outcome.messages[0].content).toBe("call [redacted] or [redacted]"); + + expect(redactServerText("a@b.com", [/\w+@\w+\.\w+/g], "X")).toBe("X"); + + const reply = await runServerAfterReceive( + [pluginRedactPII({ redactReplies: true })], + "reach a@b.com", + base, + ); + expect(reply).toBe("reach [redacted]"); + }); + + it("pluginCannedResponses matches the last user message", async () => { + const plugin = pluginCannedResponses({ + rules: [{ match: /refund/i, reply: "See our refund policy." }], + }); + const outcome = await runServerBeforeSend( + [plugin], + [ + { role: "user", content: "earlier" }, + { role: "assistant", content: "hi" }, + { role: "user", content: "How do I get a REFUND?" }, + ], + base, + ); + expect(outcome).toEqual({ type: "respond", reply: "See our refund policy." }); + }); + + it("pluginCannedResponses passes through when nothing matches", async () => { + const plugin = pluginCannedResponses({ rules: [{ match: "refund", reply: "x" }] }); + const outcome = await runServerBeforeSend([plugin], msgs("hello"), base); + expect(outcome.type).toBe("send"); + }); +}); diff --git a/worker/src/index.ts b/worker/src/index.ts index da38306..47c5b6d 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -3,6 +3,8 @@ import { cors } from "hono/cors"; import { handleChat, ChatRequest, ChatTelemetry } from "./chat"; import { checkRateLimit } from "./rate-limit"; import { recordEvent } from "./analytics"; +import { chatPlugins } from "./plugins"; +import type { ClaudiusServerPlugin } from "./plugins"; interface Env { ANTHROPIC_API_KEY: string; @@ -22,7 +24,16 @@ export interface ErrorResponse { limitType?: "minute" | "hour"; } -const app = new Hono<{ Bindings: Env }>(); +const app = new Hono<{ + Bindings: Env; + Variables: { chatRequest?: ChatRequest }; +}>(); + +// Server-side plugins run around POST /api/chat as Hono middleware — the +// equivalent of the widget's `plugins` prop. Empty by default (behavior +// unchanged); add plugins here to enable PII redaction, canned responses, +// analytics, model routing, etc. See docs: /plugins. +const serverPlugins: ClaudiusServerPlugin[] = []; app.use( "/api/*", @@ -31,7 +42,7 @@ app.use( // Comma-separated list, e.g. "https://pmds.info,https://docs.example" const allowed = (c.env.ALLOWED_ORIGIN || "http://localhost:5173") .split(",") - .map((o) => o.trim()) + .map((o: string) => o.trim()) .filter(Boolean); if (origin?.startsWith("http://localhost:")) { return origin; @@ -44,6 +55,10 @@ app.use( }) ); +if (serverPlugins.length > 0) { + app.use("/api/chat", chatPlugins(serverPlugins)); +} + app.post("/api/chat", async (c) => { const startedAt = Date.now(); let body: ChatRequest | undefined; @@ -89,7 +104,9 @@ app.post("/api/chat", async (c) => { ); } - body = await c.req.json(); + // When the plugin middleware ran, it stashed the transformed request here; + // fall back to parsing the body when no plugins are configured. + body = c.get("chatRequest") ?? (await c.req.json()); const chatConfig = { model: c.env.CLAUDE_MODEL, diff --git a/worker/src/plugins/index.ts b/worker/src/plugins/index.ts new file mode 100644 index 0000000..6f3f917 --- /dev/null +++ b/worker/src/plugins/index.ts @@ -0,0 +1,28 @@ +export type { + ClaudiusServerPlugin, + ServerPluginContext, + ServerBeforeSendContext, + MaybePromise, +} from "./types"; + +export { + runServerBeforeSend, + runServerAfterReceive, + runServerError, + type ServerBeforeOutcome, +} from "./runner"; + +export { chatPlugins, CHAT_REQUEST_KEY } from "./middleware"; + +export { + pluginAnalytics, + pluginRedactPII, + pluginCannedResponses, + redactServerText, + DEFAULT_PII_PATTERNS, + type ServerAnalyticsEvent, + type ServerAnalyticsOptions, + type ServerRedactPiiOptions, + type ServerCannedRule, + type ServerCannedResponsesOptions, +} from "./reference"; diff --git a/worker/src/plugins/middleware.ts b/worker/src/plugins/middleware.ts new file mode 100644 index 0000000..0303406 --- /dev/null +++ b/worker/src/plugins/middleware.ts @@ -0,0 +1,99 @@ +import type { MiddlewareHandler } from "hono"; +import type { ChatRequest, ChatResponse } from "../chat"; +import type { ClaudiusServerPlugin, ServerPluginContext } from "./types"; +import { + runServerBeforeSend, + runServerAfterReceive, + runServerError, +} from "./runner"; + +/** Context key under which the transformed request is stashed for the route. */ +export const CHAT_REQUEST_KEY = "chatRequest"; + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +/** + * Hono middleware that runs a list of {@link ClaudiusServerPlugin}s around the + * chat route — the server-side equivalent of the widget's `plugins` prop. + * + * It parses the JSON body, runs `onBeforeSend` (which may transform the + * messages or short-circuit with `respondWith`), stashes the transformed + * request under `c.get("chatRequest")` for the route handler, then runs + * `onAfterReceive` over the reply in a successful JSON response. + * + * With an empty plugin list it is a transparent pass-through. + * + * @example + * ```ts + * app.use("/api/chat", chatPlugins([pluginRedactPII()])); + * app.post("/api/chat", async (c) => { + * const body = c.get("chatRequest") ?? (await c.req.json()); + * // ... + * }); + * ``` + */ +export function chatPlugins( + plugins: ClaudiusServerPlugin[], +): MiddlewareHandler { + return async (c, next) => { + if (plugins.length === 0) return next(); + + let body: ChatRequest; + try { + body = await c.req.json(); + } catch { + // Not a JSON body we can introspect; let the route handle it as usual. + return next(); + } + + const messages = body.messages ?? []; + const ctx: ServerPluginContext = { + messages, + env: c.env as Record, + }; + + let outcome; + try { + outcome = await runServerBeforeSend(plugins, messages, ctx); + } catch (error) { + await runServerError(plugins, asError(error), ctx); + throw error; + } + + if (outcome.type === "respond") { + return c.json({ reply: outcome.reply }); + } + + // Hand the (possibly transformed) request to the route handler. + c.set(CHAT_REQUEST_KEY, { ...body, messages: outcome.messages }); + + await next(); + + // Transform the reply on a successful JSON response. Any failure here + // leaves the original response untouched. + try { + const res = c.res; + const isJson = res.headers + .get("content-type") + ?.includes("application/json"); + if (!res.ok || !isJson) return; + + const data = (await res.clone().json()) as ChatResponse; + if (typeof data?.reply !== "string") return; + + const newReply = await runServerAfterReceive(plugins, data.reply, ctx); + if (newReply === data.reply) return; + + const headers = new Headers(res.headers); + headers.delete("content-length"); + c.res = new Response(JSON.stringify({ ...data, reply: newReply }), { + status: res.status, + headers, + }); + } catch { + // Leave the original response in place. + } + }; +} diff --git a/worker/src/plugins/reference.ts b/worker/src/plugins/reference.ts new file mode 100644 index 0000000..843d339 --- /dev/null +++ b/worker/src/plugins/reference.ts @@ -0,0 +1,169 @@ +import type { ChatMessage } from "../chat"; +import type { ClaudiusServerPlugin } from "./types"; + +// --------------------------------------------------------------------------- +// Analytics +// --------------------------------------------------------------------------- + +/** An analytics event emitted by the server {@link pluginAnalytics}. */ +export type ServerAnalyticsEvent = + | { type: "request"; messageCount: number; lastUserChars: number } + | { type: "response"; chars: number } + | { type: "error"; message: string }; + +/** Options for the server {@link pluginAnalytics}. */ +export interface ServerAnalyticsOptions { + /** Sink called once per lifecycle event (log, push to D1, forward, ...). */ + onEvent: (event: ServerAnalyticsEvent) => void; +} + +function lastUserMessage(messages: readonly ChatMessage[]): ChatMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i]; + } + return undefined; +} + +/** + * Reference plugin that emits a structured event for each request, reply, and + * error. It never modifies the request. + */ +export function pluginAnalytics( + options: ServerAnalyticsOptions, +): ClaudiusServerPlugin { + const { onEvent } = options; + return { + name: "analytics", + onBeforeSend(messages) { + onEvent({ + type: "request", + messageCount: messages.length, + lastUserChars: lastUserMessage(messages)?.content.length ?? 0, + }); + }, + onAfterReceive(reply) { + onEvent({ type: "response", chars: reply.length }); + }, + onError(error) { + onEvent({ type: "error", message: error.message }); + }, + }; +} + +// --------------------------------------------------------------------------- +// Redact PII +// --------------------------------------------------------------------------- + +/** Default PII patterns. See the widget's `DEFAULT_PII_PATTERNS` for notes. */ +export const DEFAULT_PII_PATTERNS: readonly RegExp[] = [ + /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, + /\b\d{3}-\d{2}-\d{4}\b/g, + /\b(?:\d[ -]?){13,16}\b/g, + /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, +]; + +/** Replace every match of every pattern in `text` with `replacement`. */ +export function redactServerText( + text: string, + patterns: readonly RegExp[], + replacement: string, +): string { + return patterns.reduce((acc, pattern) => { + pattern.lastIndex = 0; + return acc.replace(pattern, replacement); + }, text); +} + +/** Options for the server {@link pluginRedactPII}. */ +export interface ServerRedactPiiOptions { + /** Patterns to redact; each must carry the global (`g`) flag. */ + patterns?: readonly RegExp[]; + /** Text substituted for each match. @defaultValue `"[redacted]"` */ + replacement?: string; + /** Also redact the model's reply. @defaultValue `false` */ + redactReplies?: boolean; +} + +/** + * Reference plugin that strips PII from every message before it reaches the + * model — defense in depth alongside the widget-side redactor. Optionally + * redacts the reply too. + */ +export function pluginRedactPII( + options: ServerRedactPiiOptions = {}, +): ClaudiusServerPlugin { + const { + patterns = DEFAULT_PII_PATTERNS, + replacement = "[redacted]", + redactReplies = false, + } = options; + + return { + name: "redact-pii", + onBeforeSend(messages) { + return messages.map((m) => ({ + ...m, + content: redactServerText(m.content, patterns, replacement), + })); + }, + onAfterReceive: redactReplies + ? (reply) => redactServerText(reply, patterns, replacement) + : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Canned responses +// --------------------------------------------------------------------------- + +/** A single intent-matching rule for the server {@link pluginCannedResponses}. */ +export interface ServerCannedRule { + /** Substring (string), pattern (RegExp), or predicate matched against the last user message. */ + match: string | RegExp | ((content: string) => boolean); + /** The reply text returned when the rule matches. */ + reply: string; +} + +/** Options for the server {@link pluginCannedResponses}. */ +export interface ServerCannedResponsesOptions { + /** Rules evaluated in order against the last user message; first match wins. */ + rules: ServerCannedRule[]; + /** Make `string` matchers case-sensitive. @defaultValue `false` */ + caseSensitive?: boolean; +} + +function ruleMatches( + match: ServerCannedRule["match"], + content: string, + caseSensitive: boolean, +): boolean { + if (typeof match === "function") return match(content); + if (match instanceof RegExp) return match.test(content); + return caseSensitive + ? content.includes(match) + : content.toLowerCase().includes(match.toLowerCase()); +} + +/** + * Reference plugin that answers matching requests at the edge without calling + * the model, via `ctx.respondWith`. + */ +export function pluginCannedResponses( + options: ServerCannedResponsesOptions, +): ClaudiusServerPlugin { + const { rules, caseSensitive = false } = options; + + return { + name: "canned-responses", + onBeforeSend(messages, ctx) { + const lastUser = lastUserMessage(messages); + if (!lastUser) return; + for (const rule of rules) { + if (ruleMatches(rule.match, lastUser.content, caseSensitive)) { + ctx.respondWith(rule.reply); + return; + } + } + }, + }; +} diff --git a/worker/src/plugins/runner.ts b/worker/src/plugins/runner.ts new file mode 100644 index 0000000..ae78361 --- /dev/null +++ b/worker/src/plugins/runner.ts @@ -0,0 +1,101 @@ +import type { ChatMessage } from "../chat"; +import type { + ClaudiusServerPlugin, + ServerBeforeSendContext, + ServerPluginContext, +} from "./types"; + +/** + * The outcome of running the server `onBeforeSend` chain: + * - `send` — call the model with `messages` (possibly transformed). + * - `respond` — skip the model; return `reply` to the client. + */ +export type ServerBeforeOutcome = + | { type: "send"; messages: ChatMessage[] } + | { type: "respond"; reply: string }; + +function warn( + plugin: ClaudiusServerPlugin, + hook: string, + error: unknown, +): void { + // A misbehaving plugin must not crash the Worker: log and carry on. + console.error(`[claudius] plugin "${plugin.name}" failed in ${hook}:`, error); +} + +/** + * Run every plugin's `onBeforeSend` hook in order, threading the (possibly + * transformed) messages through the chain. The first hook to call `respondWith` + * stops the chain and short-circuits the model call. + */ +export async function runServerBeforeSend( + plugins: readonly ClaudiusServerPlugin[], + messages: ChatMessage[], + base: ServerPluginContext, +): Promise { + let current = messages; + + for (const plugin of plugins) { + if (!plugin.onBeforeSend) continue; + + let responded: string | null = null; + const ctx: ServerBeforeSendContext = { + ...base, + respondWith(reply) { + responded = reply; + }, + }; + + try { + const result = await plugin.onBeforeSend(current, ctx); + if (responded !== null) return { type: "respond", reply: responded }; + if (result) current = result; + } catch (error) { + warn(plugin, "onBeforeSend", error); + } + } + + return { type: "send", messages: current }; +} + +/** + * Run every plugin's `onAfterReceive` hook in order, threading the (possibly + * transformed) reply through the chain. Returns the final reply text. + */ +export async function runServerAfterReceive( + plugins: readonly ClaudiusServerPlugin[], + reply: string, + base: ServerPluginContext, +): Promise { + let current = reply; + + for (const plugin of plugins) { + if (!plugin.onAfterReceive) continue; + try { + const result = await plugin.onAfterReceive(current, base); + // Allow an empty string ("") to replace the reply; only `undefined`/void + // means "no change". + if (typeof result === "string") current = result; + } catch (error) { + warn(plugin, "onAfterReceive", error); + } + } + + return current; +} + +/** Run every plugin's `onError` hook in order, isolating failures. */ +export async function runServerError( + plugins: readonly ClaudiusServerPlugin[], + error: Error, + base: ServerPluginContext, +): Promise { + for (const plugin of plugins) { + if (!plugin.onError) continue; + try { + await plugin.onError(error, base); + } catch (hookError) { + warn(plugin, "onError", hookError); + } + } +} diff --git a/worker/src/plugins/types.ts b/worker/src/plugins/types.ts new file mode 100644 index 0000000..e90a194 --- /dev/null +++ b/worker/src/plugins/types.ts @@ -0,0 +1,62 @@ +import type { ChatMessage } from "../chat"; + +/** A value that may be returned synchronously or as a promise. */ +export type MaybePromise = T | Promise; + +/** + * Read-only context shared by every server plugin hook. + * + * This is the server-side counterpart to the widget's `PluginContext`. Because + * the Worker handles the whole request, hooks operate on the message array and + * the reply string rather than on individual widget messages. + */ +export interface ServerPluginContext { + /** The conversation as received, oldest message first. Treat as immutable. */ + readonly messages: readonly ChatMessage[]; + /** Worker environment bindings (secrets, KV, D1, vars). */ + readonly env: Record; +} + +/** + * Context for {@link ClaudiusServerPlugin.onBeforeSend}. Adds the ability to + * answer without calling the model. + */ +export interface ServerBeforeSendContext extends ServerPluginContext { + /** + * Skip the model call and return this reply text to the client. Stops the + * hook chain — later plugins' `onBeforeSend` hooks do not run. + */ + respondWith(reply: string): void; +} + +/** + * A server-side middleware that runs around the chat request lifecycle. Register + * a list of plugins with {@link chatPlugins} to wrap the `POST /api/chat` route. + * + * Hooks may be async and may modify, replace, or short-circuit the request. A + * hook that throws is caught and logged so a misbehaving plugin will not crash + * the Worker. + */ +export interface ClaudiusServerPlugin { + /** Stable identifier, used in log messages. */ + name: string; + /** + * Runs before the request reaches the model. Return a new messages array to + * replace the conversation, return nothing to leave it unchanged, or call + * {@link ServerBeforeSendContext.respondWith} to answer without the model. + */ + onBeforeSend?( + messages: ChatMessage[], + ctx: ServerBeforeSendContext, + ): MaybePromise; + /** + * Runs after the model replies, before the response is returned. Return a new + * string to replace the reply, or nothing to leave it unchanged. + */ + onAfterReceive?( + reply: string, + ctx: ServerPluginContext, + ): MaybePromise; + /** Runs when handling the request throws. Observe and log the error. */ + onError?(error: Error, ctx: ServerPluginContext): MaybePromise; +}