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"] }