diff --git a/packages/plugins/official/ai-chat-widget/__tests__/index.test.ts b/packages/plugins/official/ai-chat-widget/__tests__/index.test.ts
new file mode 100644
index 0000000..c4d8341
--- /dev/null
+++ b/packages/plugins/official/ai-chat-widget/__tests__/index.test.ts
@@ -0,0 +1,565 @@
+///
+/**
+ * AI Chat Widget — Unit Tests
+ */
+import plugin, {
+ generateSessionId,
+ buildSessionKey,
+ buildActiveKey,
+ trimMessages,
+ ChatSession,
+ ChatMessage,
+ DEFAULT_MODEL,
+ DEFAULT_MAX_HISTORY,
+ SUPPORTED_MODELS,
+} from "../src/index";
+import {
+ PluginContext,
+ PluginAPI,
+ PluginDatabaseAPI,
+ PluginEventBus,
+ EndpointDefinition,
+ EndpointRequest,
+} from "@agentbase/plugin-sdk";
+
+// ── Mock factory ─────────────────────────────────────────────────────────────
+
+function createMockAPI(): PluginAPI & { _endpoints: EndpointDefinition[] } {
+ const store = new Map();
+ const _endpoints: EndpointDefinition[] = [];
+
+ const db: PluginDatabaseAPI = {
+ set: jest
+ .fn()
+ .mockImplementation(async (k: string, v: any) => store.set(k, v)),
+ get: jest
+ .fn()
+ .mockImplementation(async (k: string) => store.get(k) ?? null),
+ delete: jest.fn().mockImplementation(async (k: string) => {
+ const had = store.has(k);
+ store.delete(k);
+ return had;
+ }),
+ keys: jest
+ .fn()
+ .mockImplementation(async (prefix?: string) =>
+ [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
+ ),
+ find: jest.fn().mockResolvedValue([]),
+ count: jest.fn().mockResolvedValue(0),
+ };
+
+ const events: PluginEventBus = {
+ emit: jest.fn().mockResolvedValue(undefined),
+ on: jest.fn(),
+ off: jest.fn(),
+ };
+
+ const api = {
+ _endpoints,
+ getConfig: jest.fn().mockReturnValue(undefined),
+ setConfig: jest.fn().mockResolvedValue(undefined),
+ makeRequest: jest.fn().mockResolvedValue({ ok: true, status: 200 }),
+ log: jest.fn(),
+ db,
+ events,
+ registerEndpoint: jest
+ .fn()
+ .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)),
+ registerCronJob: jest.fn(),
+ registerWebhook: jest.fn(),
+ registerAdminPage: jest.fn(),
+ } as unknown as PluginAPI & { _endpoints: EndpointDefinition[] };
+
+ return api;
+}
+
+function makeCtx(
+ overrides: Partial = {},
+): PluginContext & { api: ReturnType } {
+ return {
+ appId: "app1",
+ userId: "user1",
+ config: {},
+ api: createMockAPI(),
+ ...overrides,
+ } as any;
+}
+
+/** Run app:init and return the registered endpoints array. */
+async function initEndpoints(ctx: ReturnType) {
+ await plugin.definition.hooks!["app:init"]!(ctx);
+ return ctx.api._endpoints;
+}
+
+function makeRes() {
+ const res = {
+ _status: 200,
+ _data: undefined as any,
+ status(code: number) {
+ this._status = code;
+ return this;
+ },
+ json(data: any) {
+ this._data = data;
+ },
+ send(data: string) {
+ this._data = data;
+ },
+ };
+ return res;
+}
+
+function fakeReq(override: Partial = {}): EndpointRequest {
+ return {
+ method: "GET",
+ path: "/",
+ params: {},
+ query: {},
+ body: {},
+ headers: {},
+ ...override,
+ };
+}
+
+// ── Helper function unit tests ────────────────────────────────────────────────
+
+describe("generateSessionId", () => {
+ it("returns a non-empty string", () => {
+ expect(typeof generateSessionId()).toBe("string");
+ expect(generateSessionId().length).toBeGreaterThan(0);
+ });
+
+ it("generates unique values", () => {
+ const ids = new Set(Array.from({ length: 50 }, generateSessionId));
+ expect(ids.size).toBe(50);
+ });
+});
+
+describe("buildSessionKey", () => {
+ it("prefixes with session:", () => {
+ expect(buildSessionKey("abc")).toBe("session:abc");
+ });
+});
+
+describe("buildActiveKey", () => {
+ it("builds composite key", () => {
+ expect(buildActiveKey("app1", "user1")).toBe("active:app1:user1");
+ });
+});
+
+describe("trimMessages", () => {
+ const msg = (n: number): ChatMessage => ({
+ role: "user",
+ content: `m${n}`,
+ ts: n,
+ });
+
+ it("returns array unchanged when under limit", () => {
+ const msgs = [msg(1), msg(2)];
+ expect(trimMessages(msgs, 5)).toEqual(msgs);
+ });
+
+ it("trims from the oldest end when over limit", () => {
+ const msgs = [msg(1), msg(2), msg(3), msg(4), msg(5)];
+ const result = trimMessages(msgs, 3);
+ expect(result).toHaveLength(3);
+ expect(result[0].content).toBe("m3");
+ expect(result[2].content).toBe("m5");
+ });
+
+ it("returns unchanged when exactly at limit", () => {
+ const msgs = [msg(1), msg(2), msg(3)];
+ expect(trimMessages(msgs, 3)).toHaveLength(3);
+ });
+
+ it("returns unchanged when maxHistory is 0 (disabled)", () => {
+ const msgs = [msg(1), msg(2)];
+ expect(trimMessages(msgs, 0)).toEqual(msgs);
+ });
+});
+
+// ── Plugin structure ──────────────────────────────────────────────────────────
+
+describe("plugin definition", () => {
+ it("has correct name and version", () => {
+ expect(plugin.definition.name).toBe("ai-chat-widget");
+ expect(plugin.definition.version).toBe("1.0.0");
+ });
+
+ it("defines all four settings with correct types", () => {
+ const s = plugin.definition.settings!;
+ expect(s.systemPrompt.type).toBe("string");
+ expect(s.model.type).toBe("select");
+ expect(s.model.options).toEqual([...SUPPORTED_MODELS]);
+ expect(s.model.default).toBe(DEFAULT_MODEL);
+ expect(s.streamingEnabled.type).toBe("boolean");
+ expect(s.streamingEnabled.default).toBe(true);
+ expect(s.maxHistory.type).toBe("number");
+ expect(s.maxHistory.default).toBe(DEFAULT_MAX_HISTORY);
+ });
+
+ it("defines hooks for all four lifecycle events", () => {
+ const h = Object.keys(plugin.definition.hooks!);
+ expect(h).toContain("app:init");
+ expect(h).toContain("conversation:start");
+ expect(h).toContain("conversation:beforeMessage");
+ expect(h).toContain("conversation:end");
+ });
+
+ it("defines filters for prompt:modify and response:modify", () => {
+ const f = Object.keys(plugin.definition.filters!);
+ expect(f).toContain("prompt:modify");
+ expect(f).toContain("response:modify");
+ });
+});
+
+// ── app:init hook ─────────────────────────────────────────────────────────────
+
+describe("app:init hook", () => {
+ it("registers exactly 4 endpoints", async () => {
+ const ctx = makeCtx();
+ const eps = await initEndpoints(ctx);
+ expect(eps).toHaveLength(4);
+ });
+
+ it("registers the expected method+path combinations", async () => {
+ const ctx = makeCtx();
+ const eps = await initEndpoints(ctx);
+ const signatures = eps.map((e) => `${e.method} ${e.path}`);
+ expect(signatures).toContain("GET /config");
+ expect(signatures).toContain("POST /session");
+ expect(signatures).toContain("GET /session/:id");
+ expect(signatures).toContain("DELETE /session/:id");
+ });
+
+ it("marks all endpoints as auth: true", async () => {
+ const ctx = makeCtx();
+ const eps = await initEndpoints(ctx);
+ expect(eps.every((e) => e.auth === true)).toBe(true);
+ });
+});
+
+// ── conversation:start hook ───────────────────────────────────────────────────
+
+describe("conversation:start hook", () => {
+ it("writes a session record and an active key", async () => {
+ const ctx = makeCtx();
+ await plugin.definition.hooks!["conversation:start"]!(ctx, {});
+
+ const setCalls = (ctx.api.db.set as jest.Mock).mock.calls;
+ expect(setCalls).toHaveLength(2);
+
+ const sessionCall = setCalls.find(([k]) => k.startsWith("session:"));
+ const activeCall = setCalls.find(([k]) => k.startsWith("active:"));
+ expect(sessionCall).toBeDefined();
+ expect(activeCall).toBeDefined();
+ });
+
+ it("session record contains correct appId, userId and empty messages", async () => {
+ const ctx = makeCtx({ appId: "myapp", userId: "myuser" });
+ await plugin.definition.hooks!["conversation:start"]!(ctx, {});
+
+ const setCalls = (ctx.api.db.set as jest.Mock).mock.calls;
+ const session: ChatSession = setCalls.find(([k]) =>
+ k.startsWith("session:"),
+ )[1];
+
+ expect(session.appId).toBe("myapp");
+ expect(session.userId).toBe("myuser");
+ expect(session.messages).toEqual([]);
+ });
+
+ it("active key encodes appId and userId", async () => {
+ const ctx = makeCtx({ appId: "myapp", userId: "myuser" });
+ await plugin.definition.hooks!["conversation:start"]!(ctx, {});
+
+ const setCalls = (ctx.api.db.set as jest.Mock).mock.calls;
+ const [activeKey] = setCalls.find(([k]) => k.startsWith("active:"));
+ expect(activeKey).toBe("active:myapp:myuser");
+ });
+});
+
+// ── conversation:beforeMessage hook ──────────────────────────────────────────
+
+describe("conversation:beforeMessage hook", () => {
+ it("does nothing when no active session exists", async () => {
+ const ctx = makeCtx();
+ // db.get returns null for everything (default mock)
+ await plugin.definition.hooks!["conversation:beforeMessage"]!(ctx, {
+ content: "hello",
+ role: "user",
+ });
+ expect(ctx.api.db.set).not.toHaveBeenCalled();
+ });
+
+ it("appends the incoming message to the session", async () => {
+ const ctx = makeCtx();
+ const sessionId = "s1";
+ const session: ChatSession = {
+ id: sessionId,
+ appId: "app1",
+ userId: "user1",
+ messages: [],
+ model: DEFAULT_MODEL,
+ createdAt: 0,
+ updatedAt: 0,
+ };
+ await ctx.api.db.set(buildActiveKey("app1", "user1"), sessionId);
+ await ctx.api.db.set(buildSessionKey(sessionId), session);
+ (ctx.api.db.set as jest.Mock).mockClear();
+
+ await plugin.definition.hooks!["conversation:beforeMessage"]!(ctx, {
+ content: "Hello",
+ role: "user",
+ });
+
+ expect(ctx.api.db.set).toHaveBeenCalledWith(
+ buildSessionKey(sessionId),
+ expect.objectContaining({
+ messages: expect.arrayContaining([
+ expect.objectContaining({ content: "Hello", role: "user" }),
+ ]),
+ }),
+ );
+ });
+
+ it("trims messages to maxHistory", async () => {
+ const ctx = makeCtx();
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) =>
+ k === "maxHistory" ? 2 : undefined,
+ );
+
+ const sessionId = "s2";
+ const existing: ChatMessage[] = [
+ { role: "user", content: "a", ts: 1 },
+ { role: "assistant", content: "b", ts: 2 },
+ ];
+ const session: ChatSession = {
+ id: sessionId,
+ appId: "app1",
+ userId: "user1",
+ messages: existing,
+ model: DEFAULT_MODEL,
+ createdAt: 0,
+ updatedAt: 0,
+ };
+ await ctx.api.db.set(buildActiveKey("app1", "user1"), sessionId);
+ await ctx.api.db.set(buildSessionKey(sessionId), session);
+ (ctx.api.db.set as jest.Mock).mockClear();
+
+ await plugin.definition.hooks!["conversation:beforeMessage"]!(ctx, {
+ content: "c",
+ role: "user",
+ });
+
+ const saved = (ctx.api.db.set as jest.Mock).mock.calls[0][1] as ChatSession;
+ expect(saved.messages).toHaveLength(2);
+ expect(saved.messages[0].content).toBe("b");
+ expect(saved.messages[1].content).toBe("c");
+ });
+});
+
+// ── conversation:end hook ─────────────────────────────────────────────────────
+
+describe("conversation:end hook", () => {
+ it("does nothing when no active session", async () => {
+ const ctx = makeCtx();
+ await plugin.definition.hooks!["conversation:end"]!(ctx);
+ expect(ctx.api.db.set).not.toHaveBeenCalled();
+ });
+
+ it("updates updatedAt and removes the active key", async () => {
+ const ctx = makeCtx();
+ const sessionId = "send1";
+ const session: ChatSession = {
+ id: sessionId,
+ appId: "app1",
+ userId: "user1",
+ messages: [],
+ model: DEFAULT_MODEL,
+ createdAt: 1000,
+ updatedAt: 1000,
+ };
+ await ctx.api.db.set(buildActiveKey("app1", "user1"), sessionId);
+ await ctx.api.db.set(buildSessionKey(sessionId), session);
+ (ctx.api.db.set as jest.Mock).mockClear();
+
+ await plugin.definition.hooks!["conversation:end"]!(ctx);
+
+ expect(ctx.api.db.set).toHaveBeenCalledWith(
+ buildSessionKey(sessionId),
+ expect.objectContaining({ updatedAt: expect.any(Number) }),
+ );
+ expect(ctx.api.db.delete).toHaveBeenCalledWith(
+ buildActiveKey("app1", "user1"),
+ );
+ });
+});
+
+// ── prompt:modify filter ──────────────────────────────────────────────────────
+
+describe("prompt:modify filter", () => {
+ it("prepends system prompt to string prompts", async () => {
+ const ctx = makeCtx();
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) =>
+ k === "systemPrompt" ? "Be concise." : undefined,
+ );
+ const result = await plugin.definition.filters!["prompt:modify"]!(
+ ctx,
+ "Tell me a joke",
+ );
+ expect(result).toBe("Be concise.\n\nTell me a joke");
+ });
+
+ it("returns prompt unchanged when system prompt is empty", async () => {
+ const ctx = makeCtx();
+ (ctx.api.getConfig as jest.Mock).mockReturnValue("");
+ expect(
+ await plugin.definition.filters!["prompt:modify"]!(ctx, "Hello"),
+ ).toBe("Hello");
+ });
+
+ it("returns non-string values unchanged regardless of system prompt", async () => {
+ const ctx = makeCtx();
+ (ctx.api.getConfig as jest.Mock).mockReturnValue("system");
+ const obj = { type: "structured" };
+ expect(await plugin.definition.filters!["prompt:modify"]!(ctx, obj)).toBe(
+ obj,
+ );
+ });
+});
+
+// ── response:modify filter ────────────────────────────────────────────────────
+
+describe("response:modify filter", () => {
+ it("injects _widget field into object responses", async () => {
+ const ctx = makeCtx();
+ const result = await plugin.definition.filters!["response:modify"]!(ctx, {
+ text: "hello",
+ });
+ expect(result).toEqual({ text: "hello", _widget: "ai-chat-widget" });
+ });
+
+ it("leaves string responses unchanged", async () => {
+ const ctx = makeCtx();
+ expect(
+ await plugin.definition.filters!["response:modify"]!(ctx, "raw string"),
+ ).toBe("raw string");
+ });
+
+ it("leaves null unchanged", async () => {
+ const ctx = makeCtx();
+ expect(
+ await plugin.definition.filters!["response:modify"]!(ctx, null),
+ ).toBeNull();
+ });
+});
+
+// ── Endpoint handlers ─────────────────────────────────────────────────────────
+
+describe("endpoint handlers (via app:init closure)", () => {
+ it("GET /config returns widget config values", async () => {
+ const ctx = makeCtx();
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => {
+ const cfg: Record = {
+ model: "gpt-4o",
+ streamingEnabled: false,
+ maxHistory: 10,
+ };
+ return cfg[k];
+ });
+ const [ep] = (await initEndpoints(ctx)).filter(
+ (e) => e.method === "GET" && e.path === "/config",
+ );
+ const res = makeRes();
+ await ep.handler(fakeReq(), res);
+ expect(res._data.widgetId).toBe("ai-chat-widget");
+ expect(res._data.model).toBe("gpt-4o");
+ expect(res._data.streamingEnabled).toBe(false);
+ expect(res._data.maxHistory).toBe(10);
+ });
+
+ it("POST /session creates and returns a session with status 201", async () => {
+ const ctx = makeCtx();
+ const eps = await initEndpoints(ctx);
+ const ep = eps.find((e) => e.method === "POST" && e.path === "/session")!;
+ const res = makeRes();
+ await ep.handler(
+ fakeReq({
+ method: "POST",
+ path: "/session",
+ user: { id: "u42", email: "u@x.com", role: "user" },
+ }),
+ res,
+ );
+ expect(res._status).toBe(201);
+ expect(res._data).toMatchObject({
+ id: expect.any(String),
+ userId: "u42",
+ appId: "app1",
+ messages: [],
+ });
+ });
+
+ it("GET /session/:id returns 404 when session does not exist", async () => {
+ const ctx = makeCtx();
+ const eps = await initEndpoints(ctx);
+ const ep = eps.find(
+ (e) => e.method === "GET" && e.path === "/session/:id",
+ )!;
+ const res = makeRes();
+ await ep.handler(fakeReq({ params: { id: "ghost" } }), res);
+ expect(res._status).toBe(404);
+ expect(res._data.error).toBeDefined();
+ });
+
+ it("GET /session/:id returns the session when it exists", async () => {
+ const ctx = makeCtx();
+ const session: ChatSession = {
+ id: "s99",
+ appId: "app1",
+ userId: "user1",
+ messages: [],
+ model: DEFAULT_MODEL,
+ createdAt: 0,
+ updatedAt: 0,
+ };
+ await ctx.api.db.set(buildSessionKey("s99"), session);
+
+ const eps = await initEndpoints(ctx);
+ const ep = eps.find(
+ (e) => e.method === "GET" && e.path === "/session/:id",
+ )!;
+ const res = makeRes();
+ await ep.handler(fakeReq({ params: { id: "s99" } }), res);
+ expect(res._data).toMatchObject({ id: "s99" });
+ });
+
+ it("DELETE /session/:id returns 404 when session does not exist", async () => {
+ const ctx = makeCtx();
+ const eps = await initEndpoints(ctx);
+ const ep = eps.find(
+ (e) => e.method === "DELETE" && e.path === "/session/:id",
+ )!;
+ const res = makeRes();
+ await ep.handler(fakeReq({ params: { id: "ghost" } }), res);
+ expect(res._status).toBe(404);
+ });
+
+ it("DELETE /session/:id deletes the session and returns 204", async () => {
+ const ctx = makeCtx();
+ await ctx.api.db.set(buildSessionKey("del1"), { id: "del1" });
+
+ const eps = await initEndpoints(ctx);
+ const ep = eps.find(
+ (e) => e.method === "DELETE" && e.path === "/session/:id",
+ )!;
+ const res = makeRes();
+ await ep.handler(fakeReq({ params: { id: "del1" } }), res);
+ expect(res._status).toBe(204);
+
+ // Confirm removed from store
+ expect(await ctx.api.db.get(buildSessionKey("del1"))).toBeNull();
+ });
+});
diff --git a/packages/plugins/official/ai-chat-widget/__tests__/tsconfig.json b/packages/plugins/official/ai-chat-widget/__tests__/tsconfig.json
new file mode 100644
index 0000000..71edfea
--- /dev/null
+++ b/packages/plugins/official/ai-chat-widget/__tests__/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "noEmit": true,
+ "rootDir": "../../../",
+ "types": ["jest"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["**/*.ts", "../src/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/plugins/official/ai-chat-widget/manifest.json b/packages/plugins/official/ai-chat-widget/manifest.json
new file mode 100644
index 0000000..e174f09
--- /dev/null
+++ b/packages/plugins/official/ai-chat-widget/manifest.json
@@ -0,0 +1,10 @@
+{
+ "name": "ai-chat-widget",
+ "version": "1.0.0",
+ "description": "Embeddable chat interface with streaming, multi-model support, and session management.",
+ "entryPoint": "dist/index.js",
+ "author": "Agentbase Team",
+ "agentbaseVersion": ">=1.0.0",
+ "permissions": [],
+ "peerDependencies": {}
+}
diff --git a/packages/plugins/official/ai-chat-widget/package.json b/packages/plugins/official/ai-chat-widget/package.json
new file mode 100644
index 0000000..c1e49c9
--- /dev/null
+++ b/packages/plugins/official/ai-chat-widget/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@agentbase/plugin-ai-chat-widget",
+ "version": "1.0.0",
+ "description": "Embeddable chat interface with streaming, multi-model support, and session management.",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "license": "GPL-3.0-or-later",
+ "scripts": {
+ "build": "tsc",
+ "test": "jest --passWithNoTests",
+ "test:cov": "jest --coverage --passWithNoTests"
+ },
+ "dependencies": {
+ "@agentbase/plugin-sdk": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.0",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.2.0",
+ "typescript": "^5.7.0"
+ },
+ "jest": {
+ "preset": "ts-jest",
+ "testEnvironment": "node",
+ "testMatch": [
+ "**/__tests__/**/*.test.ts"
+ ],
+ "globals": {
+ "ts-jest": {
+ "tsconfig": "./tsconfig.test.json"
+ }
+ },
+ "coverageThreshold": {
+ "global": {
+ "lines": 80
+ }
+ }
+ }
+}
diff --git a/packages/plugins/official/ai-chat-widget/src/index.ts b/packages/plugins/official/ai-chat-widget/src/index.ts
new file mode 100644
index 0000000..8160f13
--- /dev/null
+++ b/packages/plugins/official/ai-chat-widget/src/index.ts
@@ -0,0 +1,318 @@
+/**
+ * AI Chat Widget
+ *
+ * Embeddable chat interface with streaming, multi-model support, and session
+ * management. Sessions are stored in the plugin's scoped database (MongoDB-backed
+ * KV store). The system prompt and model settings are configurable per-application.
+ *
+ * @package @agentbase/plugin-ai-chat-widget
+ * @version 1.0.0
+ */
+import { createPlugin, PluginContext } from "@agentbase/plugin-sdk";
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+export const SUPPORTED_MODELS = [
+ "gpt-4o",
+ "claude-3-5-sonnet",
+ "gemini-2-0-flash",
+] as const;
+
+export type SupportedModel = (typeof SUPPORTED_MODELS)[number];
+
+export const DEFAULT_MODEL: SupportedModel = "gpt-4o";
+export const DEFAULT_MAX_HISTORY = 20;
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export interface ChatMessage {
+ role: "user" | "assistant" | "system";
+ content: string;
+ ts: number;
+}
+
+export interface ChatSession {
+ id: string;
+ appId: string;
+ userId: string;
+ messages: ChatMessage[];
+ model: string;
+ createdAt: number;
+ updatedAt: number;
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+/** Generate a random session ID using timestamp + random component. */
+export function generateSessionId(): string {
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 9);
+}
+
+/** Plugin DB key for a session record. */
+export function buildSessionKey(sessionId: string): string {
+ return `session:${sessionId}`;
+}
+
+/** Plugin DB key tracking the active session for an app+user pair. */
+export function buildActiveKey(appId: string, userId: string): string {
+ return `active:${appId}:${userId}`;
+}
+
+/**
+ * Keep only the most recent `maxHistory` messages, dropping from the oldest end.
+ * If maxHistory is 0 (or negative), returns the array unchanged.
+ */
+export function trimMessages(
+ messages: ChatMessage[],
+ maxHistory: number,
+): ChatMessage[] {
+ if (maxHistory <= 0 || messages.length <= maxHistory) return messages;
+ return messages.slice(messages.length - maxHistory);
+}
+
+// ── Plugin ────────────────────────────────────────────────────────────────────
+
+export default createPlugin({
+ name: "ai-chat-widget",
+ version: "1.0.0",
+ description:
+ "Embeddable chat interface with streaming, multi-model support, and session management.",
+ author: "Agentbase Team",
+
+ // ── Settings ───────────────────────────────────────────────────────────────
+ settings: {
+ systemPrompt: {
+ type: "string",
+ label: "System Prompt",
+ default: "",
+ },
+ model: {
+ type: "select",
+ label: "AI Model",
+ options: [...SUPPORTED_MODELS],
+ default: DEFAULT_MODEL,
+ },
+ streamingEnabled: {
+ type: "boolean",
+ label: "Enable Streaming Responses",
+ default: true,
+ },
+ maxHistory: {
+ type: "number",
+ label: "Max Message History (per session)",
+ default: DEFAULT_MAX_HISTORY,
+ },
+ },
+
+ // ── Hooks ──────────────────────────────────────────────────────────────────
+ hooks: {
+ /**
+ * app:init — register the four session endpoints.
+ * Endpoint handlers close over `context` to access the plugin DB.
+ */
+ "app:init": async (context: PluginContext) => {
+ context.api.log("AI Chat Widget initialized");
+
+ // GET /config — return public plugin configuration
+ context.api.registerEndpoint({
+ method: "GET",
+ path: "/config",
+ auth: true,
+ description: "Return public plugin configuration",
+ handler: async (_req, res) => {
+ res.json({
+ widgetId: "ai-chat-widget",
+ model: (context.api.getConfig("model") as string) ?? DEFAULT_MODEL,
+ streamingEnabled:
+ (context.api.getConfig("streamingEnabled") as boolean) ?? true,
+ maxHistory:
+ (context.api.getConfig("maxHistory") as number) ??
+ DEFAULT_MAX_HISTORY,
+ });
+ },
+ });
+
+ // POST /session — create a new chat session
+ context.api.registerEndpoint({
+ method: "POST",
+ path: "/session",
+ auth: true,
+ description: "Create a new chat session",
+ handler: async (req, res) => {
+ const userId = req.user?.id ?? "anonymous";
+ const session: ChatSession = {
+ id: generateSessionId(),
+ appId: context.appId,
+ userId,
+ messages: [],
+ model: (context.api.getConfig("model") as string) ?? DEFAULT_MODEL,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+ await context.api.db.set(buildSessionKey(session.id), session);
+ res.status(201).json(session);
+ },
+ });
+
+ // GET /session/:id — retrieve a session by ID
+ context.api.registerEndpoint({
+ method: "GET",
+ path: "/session/:id",
+ auth: true,
+ description: "Retrieve a chat session by ID",
+ handler: async (req, res) => {
+ const session = (await context.api.db.get(
+ buildSessionKey(req.params.id),
+ )) as ChatSession | null;
+ if (!session) {
+ res.status(404).json({ error: "Session not found" });
+ return;
+ }
+ res.json(session);
+ },
+ });
+
+ // DELETE /session/:id — delete a session
+ context.api.registerEndpoint({
+ method: "DELETE",
+ path: "/session/:id",
+ auth: true,
+ description: "Delete a chat session",
+ handler: async (req, res) => {
+ const deleted = await context.api.db.delete(
+ buildSessionKey(req.params.id),
+ );
+ if (!deleted) {
+ res.status(404).json({ error: "Session not found" });
+ return;
+ }
+ res.status(204).send("");
+ },
+ });
+ },
+
+ /**
+ * conversation:start — create a session and mark it as active for this
+ * app+user pair. The active key is used by subsequent hooks to look up the
+ * session without scanning all keys.
+ */
+ "conversation:start": async (
+ context: PluginContext,
+ _conversation: { id?: string },
+ ) => {
+ const sessionId = generateSessionId();
+ const session: ChatSession = {
+ id: sessionId,
+ appId: context.appId,
+ userId: context.userId,
+ messages: [],
+ model: (context.api.getConfig("model") as string) ?? DEFAULT_MODEL,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+ await context.api.db.set(buildSessionKey(sessionId), session);
+ await context.api.db.set(
+ buildActiveKey(context.appId, context.userId),
+ sessionId,
+ );
+ context.api.log(
+ `Session created for user ${context.userId}: ${sessionId}`,
+ );
+ },
+
+ /**
+ * conversation:beforeMessage — append the incoming message to the active
+ * session and trim the history to stay within the maxHistory limit.
+ */
+ "conversation:beforeMessage": async (
+ context: PluginContext,
+ message: { content?: string; role?: string },
+ ) => {
+ const sessionId = (await context.api.db.get(
+ buildActiveKey(context.appId, context.userId),
+ )) as string | null;
+ if (!sessionId) return;
+
+ const session = (await context.api.db.get(
+ buildSessionKey(sessionId),
+ )) as ChatSession | null;
+ if (!session) return;
+
+ const maxHistory =
+ (context.api.getConfig("maxHistory") as number) ?? DEFAULT_MAX_HISTORY;
+
+ const newMessage: ChatMessage = {
+ role: (message.role as ChatMessage["role"]) ?? "user",
+ content: message.content ?? "",
+ ts: Date.now(),
+ };
+
+ const messages = trimMessages(
+ [...session.messages, newMessage],
+ maxHistory,
+ );
+
+ await context.api.db.set(buildSessionKey(sessionId), {
+ ...session,
+ messages,
+ updatedAt: Date.now(),
+ });
+ },
+
+ /**
+ * conversation:end — stamp the session with the end time and remove the
+ * active key so future messages start a fresh session.
+ */
+ "conversation:end": async (context: PluginContext) => {
+ const sessionId = (await context.api.db.get(
+ buildActiveKey(context.appId, context.userId),
+ )) as string | null;
+ if (!sessionId) return;
+
+ const session = (await context.api.db.get(
+ buildSessionKey(sessionId),
+ )) as ChatSession | null;
+
+ if (session) {
+ await context.api.db.set(buildSessionKey(sessionId), {
+ ...session,
+ updatedAt: Date.now(),
+ });
+ }
+
+ await context.api.db.delete(
+ buildActiveKey(context.appId, context.userId),
+ );
+ },
+ },
+
+ // ── Filters ────────────────────────────────────────────────────────────────
+ filters: {
+ /**
+ * prompt:modify — prepend the configured system prompt to every prompt
+ * string, ensuring the LLM respects the widget's persona/instructions.
+ */
+ "prompt:modify": async (context: PluginContext, value: unknown) => {
+ const systemPrompt =
+ (context.api.getConfig("systemPrompt") as string) ?? "";
+ if (systemPrompt && typeof value === "string" && value.trim()) {
+ return `${systemPrompt}\n\n${value}`;
+ }
+ return value;
+ },
+
+ /**
+ * response:modify — tag every object response with the widget identifier
+ * so clients can distinguish widget-originated responses.
+ */
+ "response:modify": async (_context: PluginContext, value: unknown) => {
+ if (value !== null && typeof value === "object") {
+ return {
+ ...(value as Record),
+ _widget: "ai-chat-widget",
+ };
+ }
+ return value;
+ },
+ },
+});
diff --git a/packages/plugins/official/ai-chat-widget/tsconfig.json b/packages/plugins/official/ai-chat-widget/tsconfig.json
new file mode 100644
index 0000000..1ec1969
--- /dev/null
+++ b/packages/plugins/official/ai-chat-widget/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "outDir": "./dist",
+ "rootDir": "../..",
+ "baseUrl": ".",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "strict": true,
+ "noEmit": true,
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "__tests__"]
+}
diff --git a/packages/plugins/official/ai-chat-widget/tsconfig.test.json b/packages/plugins/official/ai-chat-widget/tsconfig.test.json
new file mode 100644
index 0000000..4437ff8
--- /dev/null
+++ b/packages/plugins/official/ai-chat-widget/tsconfig.test.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "../..",
+ "baseUrl": ".",
+ "lib": ["ES2022", "DOM"],
+ "types": ["jest"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*", "__tests__/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1bd92c5..c9ab522 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -178,7 +178,7 @@ importers:
version: 7.2.2
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.0
version: 10.9.2(@types/node@22.19.11)(typescript@5.9.3)
@@ -284,7 +284,7 @@ importers:
version: 1.0.7(tailwindcss@3.4.19)
ts-jest:
specifier: ^29.4.6
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -354,7 +354,7 @@ importers:
version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
ts-jest:
specifier: ^29.4.6
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.11)(typescript@5.9.3)
@@ -362,6 +362,25 @@ importers:
specifier: ^5.7.0
version: 5.9.3
+ packages/plugins/official/ai-chat-widget:
+ dependencies:
+ '@agentbase/plugin-sdk':
+ specifier: workspace:*
+ version: link:../..
+ devDependencies:
+ '@types/jest':
+ specifier: ^29.5.0
+ version: 29.5.14
+ jest:
+ specifier: ^29.7.0
+ version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ ts-jest:
+ specifier: ^29.2.0
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3)
+ typescript:
+ specifier: ^5.7.0
+ version: 5.9.3
+
packages/plugins/template:
dependencies:
'@agentbase/plugin-sdk':
@@ -376,7 +395,7 @@ importers:
version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -397,7 +416,7 @@ importers:
version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.3.0
version: 5.9.3
@@ -11787,7 +11806,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
- ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3):
+ ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
diff --git a/tsconfig.json b/tsconfig.json
index 506c6fc..dd76fd3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -19,5 +19,5 @@
"@agentbase/shared/*": ["packages/shared/src/*"]
}
},
- "exclude": ["node_modules", "dist"]
+ "exclude": ["node_modules", "dist", "packages/plugins"]
}