Skip to content

Commit 60dc56e

Browse files
authored
feat(chat-agent): accept callback for emptyState config (#147)
1 parent 871bed9 commit 60dc56e

5 files changed

Lines changed: 101 additions & 31 deletions

File tree

chat-agent/README.md

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,21 @@ Provider API keys are never read from `process.env` by the plugin — pass them
3434

3535
## Configuration
3636

37-
| Option | Type | Required | Description |
38-
| ----------------- | ------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39-
| `model` | `(modelId: string) => LanguageModel` | Yes | Resolves a model id to a Vercel AI SDK `LanguageModel`. Called once per request with the selected model |
40-
| `defaultModel` | `string` | Yes | Model id used when no per-request override is provided |
41-
| `availableModels` | `ModelOption[]` | No | Models the user can choose from in the chat UI (selector shown when 2+ entries) |
42-
| `systemPrompt` | `({ req, defaultPrompt }) => string \| Promise<string>` | No | Customize the agent's system prompt. Wrap or replace `defaultPrompt` and return the final string. Called per request, so the prompt can be loaded from a Payload global / varied per tenant (see below) |
43-
| `access` | `(req) => boolean` | No | Override the default auth check (default: requires authenticated user) |
44-
| `maxSteps` | `number` | No | Maximum tool-use loop steps per request (default: 20) |
45-
| `modes` | `ModesConfig` | No | Agent modes configuration (see below) |
46-
| `adminView` | `{ path, Component }` | No | Customize the admin chat view route or component |
47-
| `navLink` | `boolean` | No | Show a "Chat" link at the top of the admin nav sidebar (default: `true`) |
48-
| `budget` | `BudgetConfig` | No | Optional token budget (see below) |
49-
| `emptyState` | `EmptyStateConfig` | No | Customize the empty chat screen — title, markdown description, and suggested-prompt chips (see below) |
50-
| `tools` | `({ req, defaultTools, modelId }) => ToolMap` | No | Compose the final toolset — add user or provider-native tools, drop defaults, etc. (see below) |
51-
| `toolDiscovery` | `{ searchTool, eager? }` | No | Anthropic's Tool Search Tool — defer cold-path tool definitions and load them on demand (see below) |
37+
| Option | Type | Required | Description |
38+
| ----------------- | ------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39+
| `model` | `(modelId: string) => LanguageModel` | Yes | Resolves a model id to a Vercel AI SDK `LanguageModel`. Called once per request with the selected model |
40+
| `defaultModel` | `string` | Yes | Model id used when no per-request override is provided |
41+
| `availableModels` | `ModelOption[]` | No | Models the user can choose from in the chat UI (selector shown when 2+ entries) |
42+
| `systemPrompt` | `({ req, defaultPrompt }) => string \| Promise<string>` | No | Customize the agent's system prompt. Wrap or replace `defaultPrompt` and return the final string. Called per request, so the prompt can be loaded from a Payload global / varied per tenant (see below) |
43+
| `access` | `(req) => boolean` | No | Override the default auth check (default: requires authenticated user) |
44+
| `maxSteps` | `number` | No | Maximum tool-use loop steps per request (default: 20) |
45+
| `modes` | `ModesConfig` | No | Agent modes configuration (see below) |
46+
| `adminView` | `{ path, Component }` | No | Customize the admin chat view route or component |
47+
| `navLink` | `boolean` | No | Show a "Chat" link at the top of the admin nav sidebar (default: `true`) |
48+
| `budget` | `BudgetConfig` | No | Optional token budget (see below) |
49+
| `emptyState` | `EmptyStateConfig \| (({ req }) => MaybePromise<EmptyStateConfig>)` | No | Customize the empty chat screen — static object or per-request callback (see below) |
50+
| `tools` | `({ req, defaultTools, modelId }) => ToolMap` | No | Compose the final toolset — add user or provider-native tools, drop defaults, etc. (see below) |
51+
| `toolDiscovery` | `{ searchTool, eager? }` | No | Anthropic's Tool Search Tool — defer cold-path tool definitions and load them on demand (see below) |
5252

5353
### Mixing providers
5454

@@ -100,9 +100,10 @@ chatAgentPlugin({
100100

101101
Customize what editors see before they've sent the first message. Without this option, the chat opens to a generic "What can I help you with?" headline and a few example prompt chips.
102102

103-
Example:
103+
Accepts a static object or a per-request callback (e.g. to read from a Payload global):
104104

105105
```ts
106+
// Static
106107
chatAgentPlugin({
107108
emptyState: {
108109
title: 'Content Assistant',
@@ -112,6 +113,17 @@ chatAgentPlugin({
112113
starterPrompts: ['Audit my recent draft posts', 'Translate the homepage tagline to German'],
113114
},
114115
})
116+
117+
// Per-request callback
118+
chatAgentPlugin({
119+
emptyState: async ({ req }) => {
120+
const site = await req.payload.findGlobal({ slug: 'site' })
121+
return {
122+
title: site.chatTitle,
123+
starterPrompts: site.chatPrompts,
124+
}
125+
},
126+
})
115127
```
116128

117129
### Custom endpoints

chat-agent/src/index.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,46 @@ describe('chatAgentPlugin modes', () => {
576576
expect(body.emptyState).toBeUndefined()
577577
})
578578

579+
it('modes endpoint resolves emptyState callback per-request', async () => {
580+
const plugin = chatAgentPlugin({
581+
defaultModel: 'claude-sonnet-4-20250514',
582+
emptyState: ({ req }) => ({
583+
title: `Hello, ${(req.user as { id: string }).id}`,
584+
starterPrompts: ['Draft a post'],
585+
}),
586+
model: makeModelFactory().factory,
587+
})
588+
const result = plugin({ endpoints: [] })
589+
const handler = result.endpoints.find((ep: Endpoint) => ep.path === '/chat-agent/modes').handler
590+
591+
const response = await handler({ user: { id: 'u1' } })
592+
const body = await response.json()
593+
expect(body.emptyState).toEqual({
594+
title: 'Hello, u1',
595+
starterPrompts: ['Draft a post'],
596+
})
597+
})
598+
599+
it('modes endpoint resolves async emptyState callback', async () => {
600+
const plugin = chatAgentPlugin({
601+
defaultModel: 'claude-sonnet-4-20250514',
602+
emptyState: async () => {
603+
const config = await Promise.resolve({ title: 'Async title', starterPrompts: ['Hello'] })
604+
return config
605+
},
606+
model: makeModelFactory().factory,
607+
})
608+
const result = plugin({ endpoints: [] })
609+
const handler = result.endpoints.find((ep: Endpoint) => ep.path === '/chat-agent/modes').handler
610+
611+
const response = await handler({ user: { id: 'u1' } })
612+
const body = await response.json()
613+
expect(body.emptyState).toEqual({
614+
title: 'Async title',
615+
starterPrompts: ['Hello'],
616+
})
617+
})
618+
579619
it('chat endpoint rejects invalid mode', async () => {
580620
const plugin = chatAgentPlugin({
581621
defaultModel: 'claude-sonnet-4-20250514',

chat-agent/src/index.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,23 +61,26 @@ const CHAT_VIEW_COMPONENT = '@jhb.software/payload-chat-agent/server#ChatViewSer
6161
const CHAT_NAV_LINK_COMPONENT = '@jhb.software/payload-chat-agent/server#ChatNavLinkServer'
6262

6363
/**
64-
* Returns the configured empty state if any field is populated, otherwise
65-
* `undefined`. Used by the modes endpoint and the SSR view so an unconfigured
66-
* plugin doesn't ship an empty `emptyState` object that the client then has
67-
* to special-case.
64+
* Resolves the configured empty state — supports both a static object and a
65+
* per-request callback. Returns `undefined` when no field is populated so an
66+
* unconfigured plugin doesn't ship an empty object the client has to special-case.
6867
*/
69-
export function pickEmptyState(input: EmptyStateConfig | undefined): EmptyStateConfig | undefined {
70-
if (!input) {
68+
export async function resolveEmptyState(
69+
input: ChatAgentPluginOptions['emptyState'],
70+
req: PayloadRequest,
71+
): Promise<EmptyStateConfig | undefined> {
72+
const resolved = typeof input === 'function' ? await input({ req }) : input
73+
if (!resolved) {
7174
return undefined
7275
}
7376
// `starterPrompts: []` is meaningful — it's the opt-out signal that
7477
// disables the chips on the client. Treat any array (incl. empty) as a
7578
// configured field so the empty array survives the trip to the client.
7679
const hasField =
77-
(typeof input.title === 'string' && input.title.length > 0) ||
78-
(typeof input.description === 'string' && input.description.length > 0) ||
79-
Array.isArray(input.starterPrompts)
80-
return hasField ? input : undefined
80+
(typeof resolved.title === 'string' && resolved.title.length > 0) ||
81+
(typeof resolved.description === 'string' && resolved.description.length > 0) ||
82+
Array.isArray(resolved.starterPrompts)
83+
return hasField ? resolved : undefined
8184
}
8285

8386
/**
@@ -180,7 +183,7 @@ export function chatAgentPlugin(options: ChatAgentPluginOptions) {
180183
}
181184

182185
const available = await resolveAvailableModes(modesConfig, req)
183-
const emptyState = pickEmptyState(options.emptyState)
186+
const emptyState = await resolveEmptyState(options.emptyState, req)
184187
return Response.json({
185188
default: getDefaultMode(modesConfig),
186189
modes: available,

chat-agent/src/types.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export interface ChatAgentPluginOptions {
208208
* `description` is rendered as Markdown (via the same renderer used for
209209
* assistant messages), so links, lists, and inline formatting all work.
210210
*
211-
* @example
211+
* @example Static config
212212
* ```ts
213213
* chatAgentPlugin({
214214
* emptyState: {
@@ -221,8 +221,23 @@ export interface ChatAgentPluginOptions {
221221
* },
222222
* })
223223
* ```
224+
*
225+
* @example Per-request callback (e.g. read from a Payload global)
226+
* ```ts
227+
* chatAgentPlugin({
228+
* emptyState: async ({ req }) => {
229+
* const site = await req.payload.findGlobal({ slug: 'site' })
230+
* return {
231+
* title: site.chatTitle,
232+
* starterPrompts: site.chatPrompts,
233+
* }
234+
* },
235+
* })
236+
* ```
224237
*/
225-
emptyState?: EmptyStateConfig
238+
emptyState?:
239+
| EmptyStateConfig
240+
| ((args: { req: PayloadRequest }) => EmptyStateConfig | Promise<EmptyStateConfig>)
226241
/** Maximum tool-use loop steps per request. Default: 20 */
227242
maxSteps?: number
228243
/**

chat-agent/src/ui/ChatViewServer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { AgentMode } from '../types.js'
88

99
import { isPluginAccessAllowed } from '../access.js'
1010
import { CONVERSATIONS_SLUG } from '../conversations.js'
11-
import { pickEmptyState } from '../index.js'
11+
import { resolveEmptyState } from '../index.js'
1212
import { getDefaultMode, resolveAvailableModes } from '../modes.js'
1313
import { getPluginCustomConfig, getPluginOptions } from '../plugin-custom-config.js'
1414
import { AGENT_MODES } from '../types.js'
@@ -77,7 +77,7 @@ export default async function ChatViewServer({
7777
const pluginOptions = getPluginOptions(payload)
7878
const availableModels = pluginOptions?.availableModels ?? []
7979
const defaultModel = pluginOptions?.defaultModel
80-
const emptyState = pickEmptyState(pluginOptions?.emptyState)
80+
const emptyState = await resolveEmptyState(pluginOptions?.emptyState, req)
8181

8282
// Fetch the conversation list server-side so the sidebar renders immediately.
8383
// The sidebar only uses `id`, `title`, and `updatedAt`; selecting just

0 commit comments

Comments
 (0)