diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx index d739546b..c1558147 100644 --- a/dato-agent/src/entrypoints/AgentFrame.tsx +++ b/dato-agent/src/entrypoints/AgentFrame.tsx @@ -26,6 +26,7 @@ import { createAgentRuntime, type FieldReferenceInput, type GetModelSchemaCallback, + type GetRecordPreviewLinksCallback, MAX_AGENT_ATTACHMENT_TOTAL_BYTES, MAX_AGENT_ATTACHMENTS_PER_MESSAGE, MAX_AGENT_ATTACHMENTS_PER_REQUEST, @@ -170,6 +171,7 @@ export type AgentFrameProps = { | undefined | Promise; getModelSchema?: GetModelSchemaCallback; + getRecordPreviewLinks?: GetRecordPreviewLinksCallback; /** * Sidebar-only verifier for field receipts. It must resolve against the * latest current-model schema before a receipt is shown. @@ -3578,6 +3580,7 @@ export default function AgentFrame(props: AgentFrameProps) { additionalInstructions: props.config.additionalInstructions, hostContext, getModelSchema: props.getModelSchema, + getRecordPreviewLinks: props.getRecordPreviewLinks, ...(!readOnlyRef.current && createDatoAsset ? { createDatoAsset } : {}), context: runtimeSystemContext(), navigation: { diff --git a/dato-agent/src/entrypoints/AgentInspector.tsx b/dato-agent/src/entrypoints/AgentInspector.tsx index 5bef8970..25b8464a 100644 --- a/dato-agent/src/entrypoints/AgentInspector.tsx +++ b/dato-agent/src/entrypoints/AgentInspector.tsx @@ -12,6 +12,7 @@ import { resolveFieldsWithinDeadline } from '../lib/hostContextDeadline'; import { createAgentMentionHost } from '../lib/mentionHost'; import { createInspectorNavigator } from '../lib/navigation'; import { canRoleUseDatoAgent } from '../lib/permissions'; +import { getRecordPreviewLinks } from '../lib/previewLinks'; import AgentFrame from './AgentFrame'; import AgentUnavailableFrame from './AgentUnavailableFrame'; @@ -264,6 +265,7 @@ function AuthorizedAgentInspector({ ctx }: Props) { 'project', ].join(':'); const mentionHost = createAgentMentionHost(ctx); + const config = normalizeConfig(ctx.plugin.attributes.parameters); return ( @@ -279,11 +281,27 @@ function AuthorizedAgentInspector({ ctx }: Props) { scope={{ type: 'project' }} navigator={createInspectorNavigator(ctx)} mentionHost={mentionHost} - config={normalizeConfig(ctx.plugin.attributes.parameters)} + config={config} loadHostContext={loadHostContext} getModelSchema={async ({ identifier, cursor }) => ({ schema: (await schemaResolver(identifier, cursor)).text, })} + getRecordPreviewLinks={(request, signal) => + getRecordPreviewLinks( + { + siteId: ctx.site.id, + environment: ctx.environment, + locale: ctx.ui.locale, + cmaBaseUrl: ctx.cmaBaseUrl, + accessToken: ctx.currentUserAccessToken, + currentUser: ctx.currentUser, + savedFrontends: config.previewFrontends, + websiteBaseUrl: config.websiteBaseUrl, + }, + request, + signal, + ) + } onReviewApprovalDetails={(approval) => openApprovalDetailsModal(ctx, approval) } diff --git a/dato-agent/src/entrypoints/AgentSidebar.tsx b/dato-agent/src/entrypoints/AgentSidebar.tsx index 4841a6ea..5cd1a92b 100644 --- a/dato-agent/src/entrypoints/AgentSidebar.tsx +++ b/dato-agent/src/entrypoints/AgentSidebar.tsx @@ -28,6 +28,7 @@ import { createAgentMentionHost } from '../lib/mentionHost'; import { createSidebarNavigator } from '../lib/navigation'; import { canRoleUseDatoAgent } from '../lib/permissions'; import { usePersistedSidebarWidth } from '../lib/persistedWidth'; +import { getRecordPreviewLinks } from '../lib/previewLinks'; import AgentFrame, { type AgentFrameProps } from './AgentFrame'; import AgentUnavailableFrame from './AgentUnavailableFrame'; @@ -487,6 +488,7 @@ function AuthorizedAgentSidebar({ ctx }: Props) { const mentionHost = createAgentMentionHost(ctx, { currentModelId: ctx.itemType.id, }); + const config = normalizeConfig(ctx.plugin.attributes.parameters); return ( @@ -508,7 +510,7 @@ function AuthorizedAgentSidebar({ ctx }: Props) { onMigrationAttempted={onMigrationAttempted} navigator={createSidebarNavigator(ctx)} mentionHost={mentionHost} - config={normalizeConfig(ctx.plugin.attributes.parameters)} + config={config} loadHostContext={loadHostContext} prepareCurrentFieldReferences={prepareCurrentFieldReferences} readCurrentRecordLiveFormState={readCurrentRecordLiveFormState} @@ -516,6 +518,22 @@ function AuthorizedAgentSidebar({ ctx }: Props) { getModelSchema={async ({ identifier, cursor }) => ({ schema: (await schemaResolver(identifier, cursor)).text, })} + getRecordPreviewLinks={(request, signal) => + getRecordPreviewLinks( + { + siteId: ctx.site.id, + environment: ctx.environment, + locale: ctx.locale, + cmaBaseUrl: ctx.cmaBaseUrl, + accessToken: ctx.currentUserAccessToken, + currentUser: ctx.currentUser, + savedFrontends: config.previewFrontends, + websiteBaseUrl: config.websiteBaseUrl, + }, + request, + signal, + ) + } onReviewApprovalDetails={(approval) => openApprovalDetailsModal(ctx, approval) } diff --git a/dato-agent/src/entrypoints/ConfigScreen.tsx b/dato-agent/src/entrypoints/ConfigScreen.tsx index 1ebea212..35492202 100644 --- a/dato-agent/src/entrypoints/ConfigScreen.tsx +++ b/dato-agent/src/entrypoints/ConfigScreen.tsx @@ -37,6 +37,10 @@ import { withActiveReasoningEffort, } from '../lib/config'; import { ACCOUNT_ROLE_ID } from '../lib/permissions'; +import { + loadWebPreviewFrontends, + type PreviewFrontend, +} from '../lib/previewLinks'; import { listProviderModels, type ProviderModel, @@ -797,6 +801,111 @@ function ProviderConfigurationFields({ ); } +function WebsiteLinksSection({ + accessToken, + baseUrl, + canEdit, + environment, + onPreviewFrontendsChange, + onWebsiteBaseUrlChange, + previewFrontends, + saving, + websiteBaseUrl, +}: { + accessToken: string | null | undefined; + baseUrl: string; + canEdit: boolean; + environment: string; + onPreviewFrontendsChange: (frontends: PreviewFrontend[]) => void; + onWebsiteBaseUrlChange: (value: string) => void; + previewFrontends: readonly PreviewFrontend[]; + saving: boolean; + websiteBaseUrl: string; +}) { + const [importing, setImporting] = useState(false); + const [error, setError] = useState(); + const [message, setMessage] = useState(); + + const importFrontends = async () => { + if (!accessToken) { + setError('The current user access token is unavailable.'); + setMessage(undefined); + return; + } + + setImporting(true); + setError(undefined); + setMessage(undefined); + try { + const frontends = await loadWebPreviewFrontends( + buildClient({ + apiToken: accessToken, + environment, + baseUrl, + }), + ); + if (frontends.length === 0) { + setError( + 'Web Previews is not installed, or none of its frontends have a preview link endpoint.', + ); + return; + } + + onPreviewFrontendsChange(frontends); + const noun = frontends.length === 1 ? 'frontend' : 'frontends'; + const pronoun = frontends.length === 1 ? 'it' : 'them'; + setMessage( + `Imported ${frontends.length} ${noun}. Save settings to keep ${pronoun}.`, + ); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : 'Could not read the Web Previews settings.', + ); + } finally { + setImporting(false); + } + }; + + return ( +
+ + + + {previewFrontends.length > 0 && ( +

+ Saved preview endpoints:{' '} + {previewFrontends.map((frontend) => frontend.name).join(', ')}. +

+ )} + {message &&

{message}

} + {error &&

{error}

} +
+
+ ); +} + export default function ConfigScreen({ ctx }: Props) { const incomingConfig = useMemo( () => normalizeConfig(ctx.plugin.attributes.parameters), @@ -1054,6 +1163,20 @@ export default function ConfigScreen({ ctx }: Props) { + + update('previewFrontends', frontends) + } + onWebsiteBaseUrlChange={(value) => update('websiteBaseUrl', value)} + previewFrontends={config.previewFrontends} + saving={saving} + websiteBaseUrl={config.websiteBaseUrl} + /> +
{ expect(toolNames).not.toContain('present_fields'); expect(toolNames).not.toContain('read_current_record_live_form_state'); expect(toolNames).toContain('present_assets'); + expect(toolNames).not.toContain('open_record_in_full_editor'); + }); + + it('looks up a record website URL through the host callback', async () => { + const previewCall = { + type: 'function_call', + id: 'function-preview', + call_id: 'call-preview', + name: 'get_record_preview_links', + arguments: JSON.stringify({ + item_id: 'item-1', + locale: null, + }), + status: 'completed', + } satisfies ResponseFunctionToolCall; + const client = new QueueResponsesClient([ + eventsFor(response('resp-preview', [previewCall])), + eventsFor(response('resp-preview-done'), ['Here is the link.']), + ]); + const getRecordPreviewLinks = vi.fn().mockResolvedValue({ + source: 'web_previews', + links: [ + { + label: 'Published', + url: 'https://example.com/thanksgiving', + }, + ], + }); + const runtime = runtimeWith(client, { getRecordPreviewLinks }); + + await drain(runtime.streamTurn({ message: 'What is the page URL?' })); + + expect(getRecordPreviewLinks.mock.calls[0]?.[0]).toEqual({ + itemId: 'item-1', + }); + expect(JSON.stringify(client.requests[1]?.input)).toContain( + 'https://example.com/thanksgiving', + ); }); it('injects host context once and keeps continuations on the stored response chain', async () => { diff --git a/dato-agent/src/lib/agentRuntime.ts b/dato-agent/src/lib/agentRuntime.ts index 923d6365..e0d076b9 100644 --- a/dato-agent/src/lib/agentRuntime.ts +++ b/dato-agent/src/lib/agentRuntime.ts @@ -562,6 +562,16 @@ export type GetModelSchemaCallback = ( signal?: AbortSignal, ) => unknown | Promise; +export interface GetRecordPreviewLinksInput { + itemId: string; + locale?: string; +} + +export type GetRecordPreviewLinksCallback = ( + input: GetRecordPreviewLinksInput, + signal?: AbortSignal, +) => unknown | Promise; + export interface AgentResponsesClient { create( params: ResponseCreateParamsStreaming, @@ -633,6 +643,11 @@ export interface AgentRuntimeConfig { * advertised to the model. */ getModelSchema?: GetModelSchemaCallback; + /** + * Optional host-bound website URL lookup. When absent, the local tool is + * not advertised to the model. + */ + getRecordPreviewLinks?: GetRecordPreviewLinksCallback; /** * Optional host-owned DatoCMS asset creator. It is intentionally separate * from the Remote MCP and is advertised only when the active host user can @@ -1273,6 +1288,30 @@ export const GET_MODEL_SCHEMA_TOOL = { }, } as const satisfies FunctionTool; +export const GET_RECORD_PREVIEW_LINKS_TOOL = { + type: 'function', + name: 'get_record_preview_links', + description: + 'Look up the full website URL for one verified DatoCMS record. Use this whenever the editor asks for a page link, public URL, or domain. It calls the Web Previews endpoints configured for this project and otherwise builds a URL from the configured website address and the record slug. This never changes content. Return the full URLs from the result. If the result says no domain is configured, give the slug and say the full URL is unavailable.', + strict: true, + parameters: { + type: 'object', + additionalProperties: false, + properties: { + item_id: { + type: 'string', + description: 'A DatoCMS record ID from the authorized environment.', + }, + locale: { + type: ['string', 'null'], + description: + 'The locale to request, or null to use the editor’s current locale.', + }, + }, + required: ['item_id', 'locale'], + }, +} as const satisfies FunctionTool; + interface SingleResponseSummary { response: Response; text: string; @@ -2090,6 +2129,27 @@ function normalizeNullableBoundedString( : normalizeBoundedNonEmptyString(value, label, maxCharacters); } +function parseRecordPreviewLinksInput( + rawArguments: string, +): GetRecordPreviewLinksInput { + const parsed = parseObjectArguments(rawArguments); + assertExactObjectKeys( + parsed, + ['item_id', 'locale'], + 'get_record_preview_links arguments', + ); + const itemId = normalizeNonEmptyString(parsed.item_id, 'item_id'); + const locale = + parsed.locale === null + ? undefined + : normalizeBoundedNonEmptyString(parsed.locale, 'locale', 35); + + return { + itemId, + ...(locale ? { locale } : {}), + }; +} + function parseOpenRecordInput(rawArguments: string): OpenRecordInput { const parsed = parseObjectArguments(rawArguments); const itemId = normalizeNonEmptyString(parsed.item_id, 'item_id'); @@ -3072,6 +3132,7 @@ interface ExecuteLocalToolCallsOptions { calls: readonly LocalToolCall[]; navigation: AgentNavigationCallbacks; getModelSchema?: GetModelSchemaCallback; + getRecordPreviewLinks?: GetRecordPreviewLinksCallback; createDatoAsset?: CreateDatoAssetCallback; readOnly?: boolean; loadedModelSchemaIdentifiers: Set; @@ -3117,6 +3178,7 @@ async function* executeLocalToolCalls({ calls, navigation, getModelSchema, + getRecordPreviewLinks, createDatoAsset, readOnly = false, loadedModelSchemaIdentifiers, @@ -3308,6 +3370,27 @@ async function* executeLocalToolCalls({ const result = await getModelSchema(parsed, signal); throwIfAborted(signal); output = stringifyModelSchemaResult(result); + } else if (call.name === GET_RECORD_PREVIEW_LINKS_TOOL.name) { + if (!getRecordPreviewLinks) { + throw new Error( + 'Website links are not available on the current DatoCMS surface.', + ); + } + const parsed = parseRecordPreviewLinksInput(call.arguments); + activityArguments = parsed; + const result = await getRecordPreviewLinks(parsed, signal); + throwIfAborted(signal); + const callbackResult = + result && typeof result === 'object' && !Array.isArray(result) + ? (result as Record) + : typeof result === 'string' + ? result + : undefined; + output = stringifyCallbackResult(callbackResult, { + action: GET_RECORD_PREVIEW_LINKS_TOOL.name, + itemId: parsed.itemId, + ...(parsed.locale ? { locale: parsed.locale } : {}), + }); } else { throw new Error(`Unsupported local tool: ${call.name}.`); } @@ -3373,6 +3456,7 @@ export class AgentRuntime implements AgentRuntimeHandle { private readonly additionalInstructions?: string; private readonly hostContext?: string; private readonly getModelSchema?: GetModelSchemaCallback; + private readonly getRecordPreviewLinks?: GetRecordPreviewLinksCallback; private readonly createDatoAsset?: CreateDatoAssetCallback; private readonly pendingApprovalBundles = new Map< string, @@ -3397,6 +3481,7 @@ export class AgentRuntime implements AgentRuntimeHandle { config.additionalInstructions?.trim().slice(0, 10_000) || undefined; this.hostContext = normalizeHostContext(config.hostContext); this.getModelSchema = config.getModelSchema; + this.getRecordPreviewLinks = config.getRecordPreviewLinks; this.createDatoAsset = config.createDatoAsset; this.client = config.client ?? @@ -3840,6 +3925,7 @@ export class AgentRuntime implements AgentRuntimeHandle { this.readOnly, ), ...(this.getModelSchema ? [GET_MODEL_SCHEMA_TOOL] : []), + ...(this.getRecordPreviewLinks ? [GET_RECORD_PREVIEW_LINKS_TOOL] : []), ]; } @@ -4832,6 +4918,7 @@ export class AgentRuntime implements AgentRuntimeHandle { })), navigation: this.navigation, getModelSchema: this.getModelSchema, + getRecordPreviewLinks: this.getRecordPreviewLinks, createDatoAsset: this.createDatoAsset, readOnly: this.readOnly, loadedModelSchemaIdentifiers, @@ -5100,7 +5187,10 @@ function anthropicMcpTool(tool: DatoMcpToolDescriptor): AnthropicTool { } function anthropicLocalTool( - tool: LocalFunctionTool | typeof GET_MODEL_SCHEMA_TOOL, + tool: + | LocalFunctionTool + | typeof GET_MODEL_SCHEMA_TOOL + | typeof GET_RECORD_PREVIEW_LINKS_TOOL, ): AnthropicTool { return { name: tool.name, @@ -5395,6 +5485,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle { private readonly additionalInstructions?: string; private readonly hostContext?: string; private readonly getModelSchema?: GetModelSchemaCallback; + private readonly getRecordPreviewLinks?: GetRecordPreviewLinksCallback; private readonly createDatoAsset?: CreateDatoAssetCallback; private readonly pendingApprovalBundles = new Map< string, @@ -5426,6 +5517,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle { config.additionalInstructions?.trim().slice(0, 10_000) || undefined; this.hostContext = normalizeHostContext(config.hostContext); this.getModelSchema = config.getModelSchema; + this.getRecordPreviewLinks = config.getRecordPreviewLinks; this.createDatoAsset = config.createDatoAsset; const normalizedApiKey = config.apiKey?.trim(); const defaultClients = @@ -6082,6 +6174,9 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle { ...(this.getModelSchema ? [anthropicLocalTool(GET_MODEL_SCHEMA_TOOL)] : []), + ...(this.getRecordPreviewLinks + ? [anthropicLocalTool(GET_RECORD_PREVIEW_LINKS_TOOL)] + : []), ]; } @@ -6234,6 +6329,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle { })), navigation: this.navigation, getModelSchema: this.getModelSchema, + getRecordPreviewLinks: this.getRecordPreviewLinks, createDatoAsset: this.createDatoAsset, readOnly: this.readOnly, loadedModelSchemaIdentifiers: state.loadedModelSchemaIdentifiers, diff --git a/dato-agent/src/lib/config.test.ts b/dato-agent/src/lib/config.test.ts index 4518e232..589e23b0 100644 --- a/dato-agent/src/lib/config.test.ts +++ b/dato-agent/src/lib/config.test.ts @@ -105,6 +105,37 @@ describe('normalizeConfig', () => { expect(normalizeConfig({ allowedRoleIds: [] }).allowedRoleIds).toEqual([]); }); + it('keeps valid preview frontends and a website address', () => { + expect( + normalizeConfig({ + websiteBaseUrl: ' https://example.com/ ', + previewFrontends: [ + { + name: 'Production', + apiEndpointUrl: 'https://example.com/api/preview-links', + customHeaders: [{ name: 'X-Token', value: 'secret' }], + }, + { name: 'Bad', apiEndpointUrl: 'notaurl' }, + ], + }), + ).toMatchObject({ + websiteBaseUrl: 'https://example.com', + previewFrontends: [ + { + name: 'Production', + apiEndpointUrl: 'https://example.com/api/preview-links', + customHeaders: [{ name: 'X-Token', value: 'secret' }], + }, + ], + }); + expect( + normalizeConfig({ websiteBaseUrl: 'javascript:alert(1)' }), + ).toMatchObject({ + websiteBaseUrl: '', + previewFrontends: [], + }); + }); + it('uses the documented provider effort sets', () => { expect(REASONING_EFFORTS).toEqual([ 'none', diff --git a/dato-agent/src/lib/config.ts b/dato-agent/src/lib/config.ts index 2dbf21d9..d1ccf9e3 100644 --- a/dato-agent/src/lib/config.ts +++ b/dato-agent/src/lib/config.ts @@ -1,3 +1,9 @@ +import { + normalizePreviewFrontends, + normalizeWebsiteBaseUrl, + type PreviewFrontend, +} from './previewLinks'; + export const DEFAULT_MODEL = 'gpt-5.6-terra'; export const AGENT_PROVIDERS = ['openai', 'anthropic'] as const; @@ -49,6 +55,16 @@ export type AgentConfig = { additionalInstructions: string; readOnly: boolean; enableRecordSidebar: boolean; + /** + * Frontends copied from the Web Previews plugin. Editors who cannot read + * that plugin's settings still use this saved copy. + */ + previewFrontends: PreviewFrontend[]; + /** + * Used only when no preview endpoint returns a URL. Combined with a record + * slug to produce a full website link. + */ + websiteBaseUrl: string; /** * Collaborator roles allowed to use Dato Agent. Project owners are always * allowed separately. `null` means this setting has not been configured yet. @@ -70,6 +86,8 @@ export const DEFAULT_CONFIG: AgentConfig = { additionalInstructions: '', readOnly: false, enableRecordSidebar: true, + previewFrontends: [], + websiteBaseUrl: '', allowedRoleIds: null, }; @@ -151,6 +169,8 @@ export function normalizeConfig(parameters: unknown): AgentConfig { typeof source.enableRecordSidebar === 'boolean' ? source.enableRecordSidebar : DEFAULT_CONFIG.enableRecordSidebar; + const previewFrontends = normalizePreviewFrontends(source.previewFrontends); + const websiteBaseUrl = normalizeWebsiteBaseUrl(source.websiteBaseUrl); const allowedRoleIds = normalizeRoleIds(source.allowedRoleIds); return { @@ -167,6 +187,8 @@ export function normalizeConfig(parameters: unknown): AgentConfig { additionalInstructions, readOnly, enableRecordSidebar, + previewFrontends, + websiteBaseUrl, allowedRoleIds, }; } diff --git a/dato-agent/src/lib/previewLinks.test.ts b/dato-agent/src/lib/previewLinks.test.ts new file mode 100644 index 00000000..26d57cd7 --- /dev/null +++ b/dato-agent/src/lib/previewLinks.test.ts @@ -0,0 +1,312 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + fetchPreviewLinks, + getRecordPreviewLinks, + loadWebPreviewFrontends, + normalizePreviewFrontends, + normalizeWebsiteBaseUrl, + type PreviewFrontend, + WEB_PREVIEWS_PACKAGE_NAME, +} from './previewLinks'; + +const cmaMocks = vi.hoisted(() => ({ + listPlugins: vi.fn(), + rawFindItem: vi.fn(), + rawFindItemType: vi.fn(), +})); + +vi.mock('@datocms/cma-client-browser', () => ({ + buildClient: () => ({ + plugins: { list: cmaMocks.listPlugins }, + items: { rawFind: cmaMocks.rawFindItem }, + itemTypes: { rawFind: cmaMocks.rawFindItemType }, + }), +})); + +const production: PreviewFrontend = { + name: 'Production', + apiEndpointUrl: 'https://example.com/api/preview-links', + customHeaders: [{ name: 'Authorization', value: 'Bearer secret' }], +}; + +const staging: PreviewFrontend = { + name: 'Staging', + apiEndpointUrl: 'https://staging.example.com/api/preview-links', + customHeaders: [], +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('normalizePreviewFrontends', () => { + it('keeps complete HTTP endpoints and drops disabled or invalid ones', () => { + expect( + normalizePreviewFrontends([ + { + name: ' Production ', + apiEndpointUrl: 'https://example.com/api/preview-links', + customHeaders: [ + { name: ' Authorization ', value: 'Bearer secret' }, + { name: '', value: 'ignored' }, + ], + }, + { + name: 'Disabled', + apiEndpointUrl: 'https://example.com', + disabled: true, + }, + { name: 'Bad', apiEndpointUrl: 'javascript:alert(1)' }, + { name: '', apiEndpointUrl: 'https://example.com' }, + ]), + ).toEqual([ + { + name: 'Production', + apiEndpointUrl: 'https://example.com/api/preview-links', + customHeaders: [{ name: 'Authorization', value: 'Bearer secret' }], + }, + ]); + }); +}); + +describe('normalizeWebsiteBaseUrl', () => { + it('accepts an HTTP origin and rejects other values', () => { + expect(normalizeWebsiteBaseUrl(' https://example.com/blog/ ')).toBe( + 'https://example.com/blog', + ); + expect(normalizeWebsiteBaseUrl('ftp://example.com')).toBe(''); + expect(normalizeWebsiteBaseUrl('not a url')).toBe(''); + }); +}); + +describe('loadWebPreviewFrontends', () => { + it('reads enabled preview endpoints from the Web Previews plugin', async () => { + const frontends = await loadWebPreviewFrontends({ + plugins: { + list: vi.fn().mockResolvedValue([ + { + package_name: 'something-else', + parameters: {}, + }, + { + package_name: WEB_PREVIEWS_PACKAGE_NAME, + parameters: { + frontends: [ + { + name: 'Production', + previewWebhook: 'https://example.com/api/preview-links', + customHeaders: [{ name: 'X-Token', value: 'secret' }], + }, + { + name: 'Disabled', + disabled: true, + previewWebhook: 'https://example.com/disabled', + }, + { name: 'No endpoint' }, + ], + }, + }, + ]), + }, + }); + + expect(frontends).toEqual([ + { + name: 'Production', + apiEndpointUrl: 'https://example.com/api/preview-links', + customHeaders: [{ name: 'X-Token', value: 'secret' }], + }, + ]); + }); + + it('returns an empty list when Web Previews is not installed', async () => { + await expect( + loadWebPreviewFrontends({ + plugins: { list: vi.fn().mockResolvedValue([]) }, + }), + ).resolves.toEqual([]); + }); +}); + +describe('fetchPreviewLinks', () => { + it('returns links from every frontend and keeps a failed frontend separate', async () => { + const fetchImpl = vi.fn( + async (url: string | URL | Request, _init?: RequestInit) => { + if (String(url).includes('staging')) { + return jsonResponse({ error: 'nope' }, 500); + } + return jsonResponse({ + previewLinks: [ + { label: 'Published', url: 'https://example.com/blog/post' }, + ], + }); + }, + ); + + await expect( + fetchPreviewLinks({ + frontends: [production, staging], + payload: { item: { id: 'item-1' } }, + fetchImpl: fetchImpl as typeof fetch, + }), + ).resolves.toEqual({ + links: [ + { + frontendName: 'Production', + label: 'Published', + url: 'https://example.com/blog/post', + }, + ], + errors: [ + { + frontendName: 'Staging', + message: 'The preview endpoint for "Staging" returned status 500.', + }, + ], + }); + + const request = fetchImpl.mock.calls[0]?.[1]; + expect(request).toMatchObject({ + method: 'POST', + body: JSON.stringify({ item: { id: 'item-1' } }), + }); + if (!request || !(request.headers instanceof Headers)) { + throw new Error('Expected preview request headers.'); + } + expect(request.headers.get('Authorization')).toBe('Bearer secret'); + }); + + it('rejects a payload that is not a list of labeled HTTP URLs', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + previewLinks: [{ label: 'Bad', url: 'javascript:alert(1)' }], + }), + ); + + const result = await fetchPreviewLinks({ + frontends: [staging], + payload: {}, + fetchImpl: fetchImpl as typeof fetch, + }); + + expect(result.links).toEqual([]); + expect(result.errors[0]?.message).toContain('invalid payload'); + }); + + it('reports a timeout without failing the other frontends', async () => { + const fetchImpl = vi.fn( + (url: string | URL | Request, init?: RequestInit) => { + if (String(url).includes('staging')) { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new Error('aborted')); + }); + }); + } + return Promise.resolve( + jsonResponse({ + previewLinks: [ + { label: 'Published', url: 'https://example.com/page' }, + ], + }), + ); + }, + ); + + const result = await fetchPreviewLinks({ + frontends: [production, staging], + payload: {}, + timeoutMs: 10, + fetchImpl: fetchImpl as typeof fetch, + }); + + expect(result.links).toEqual([ + { + frontendName: 'Production', + label: 'Published', + url: 'https://example.com/page', + }, + ]); + expect(result.errors).toEqual([ + { + frontendName: 'Staging', + message: 'The preview endpoint for "Staging" timed out.', + }, + ]); + }); +}); + +describe('getRecordPreviewLinks', () => { + const host = { + siteId: 'site', + environment: 'main', + locale: 'en', + cmaBaseUrl: 'https://site-api.datocms.com', + accessToken: 'token', + currentUser: { id: 'user' }, + savedFrontends: [] as PreviewFrontend[], + websiteBaseUrl: 'https://example.com', + }; + + beforeEach(() => { + cmaMocks.listPlugins.mockReset(); + cmaMocks.rawFindItem.mockReset(); + cmaMocks.rawFindItemType.mockReset(); + cmaMocks.listPlugins.mockResolvedValue([]); + cmaMocks.rawFindItem.mockResolvedValue({ + data: { + id: 'item-1', + attributes: { slug: { en: '/thanksgiving' } }, + relationships: { item_type: { data: { id: 'page' } } }, + }, + }); + cmaMocks.rawFindItemType.mockResolvedValue({ + data: { id: 'page', attributes: { api_key: 'page' } }, + }); + }); + + it('builds a URL from the website address and slug when no frontend is configured', async () => { + await expect( + getRecordPreviewLinks(host, { itemId: 'item-1' }), + ).resolves.toMatchObject({ + source: 'website_base_url', + links: [{ label: 'Website', url: 'https://example.com/thanksgiving' }], + }); + expect(cmaMocks.rawFindItemType).not.toHaveBeenCalled(); + }); + + it('uses the saved frontend copy when the live plugin list fails', async () => { + cmaMocks.listPlugins.mockRejectedValue(new Error('forbidden')); + const fetchImpl = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + previewLinks: [ + { label: 'Published', url: 'https://example.com/en/thanksgiving' }, + ], + }), + ); + + await expect( + getRecordPreviewLinks( + { + ...host, + savedFrontends: [production], + }, + { itemId: 'item-1', locale: 'en' }, + ), + ).resolves.toMatchObject({ + source: 'web_previews', + links: [ + { + frontendName: 'Production', + label: 'Published', + url: 'https://example.com/en/thanksgiving', + }, + ], + }); + + fetchImpl.mockRestore(); + }); +}); diff --git a/dato-agent/src/lib/previewLinks.ts b/dato-agent/src/lib/previewLinks.ts new file mode 100644 index 00000000..ba12e6d5 --- /dev/null +++ b/dato-agent/src/lib/previewLinks.ts @@ -0,0 +1,512 @@ +import { buildClient } from '@datocms/cma-client-browser'; + +export const WEB_PREVIEWS_PACKAGE_NAME = 'datocms-plugin-web-previews'; +export const PREVIEW_LINK_TIMEOUT_MS = 8_000; + +export type PreviewHeader = { + name: string; + value: string; +}; + +export type PreviewFrontend = { + name: string; + apiEndpointUrl: string; + customHeaders: PreviewHeader[]; +}; + +export type PreviewLink = { + frontendName: string; + label: string; + url: string; +}; + +export type PreviewFrontendError = { + frontendName: string; + message: string; +}; + +export type FetchedPreviewLinks = { + links: PreviewLink[]; + errors: PreviewFrontendError[]; +}; + +type PluginInstall = { + package_name: string | null; + parameters: { [key: string]: unknown }; +}; + +type PreviewPluginClient = { + plugins: { + list(): Promise; + }; +}; + +type RawRecordClient = { + items: { + rawFind(itemId: string): Promise<{ data?: unknown }>; + }; + itemTypes: { + rawFind(itemTypeId: string): Promise<{ data?: unknown }>; + }; +}; + +export type PreviewLinksHost = { + siteId: string; + environment: string; + locale: string; + cmaBaseUrl: string; + accessToken: string | null | undefined; + currentUser: unknown; + savedFrontends: readonly PreviewFrontend[]; + websiteBaseUrl: string; +}; + +export type RecordPreviewLinksRequest = { + itemId: string; + locale?: string; +}; + +const SLUG_FIELD_KEYS = ['slug'] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function trimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function httpUrl(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + return url.toString(); + } catch { + return null; + } +} + +export function normalizeWebsiteBaseUrl(value: unknown): string { + const candidate = trimmedString(value); + if (!candidate) { + return ''; + } + + const url = httpUrl(candidate); + return url ? url.replace(/\/$/, '') : ''; +} + +function normalizePreviewHeaders(value: unknown): PreviewHeader[] { + if (!Array.isArray(value)) { + return []; + } + + const headers: PreviewHeader[] = []; + for (const header of value) { + if (!isRecord(header)) { + continue; + } + const name = trimmedString(header.name); + if (!name) { + continue; + } + headers.push({ + name, + value: typeof header.value === 'string' ? header.value : '', + }); + } + + return headers; +} + +function normalizePreviewFrontend(value: unknown): PreviewFrontend | null { + if (!isRecord(value) || value.disabled === true) { + return null; + } + + const name = trimmedString(value.name); + const apiEndpointUrl = httpUrl(trimmedString(value.apiEndpointUrl)); + if (!name || !apiEndpointUrl) { + return null; + } + + return { + name, + apiEndpointUrl, + customHeaders: normalizePreviewHeaders(value.customHeaders), + }; +} + +export function normalizePreviewFrontends(value: unknown): PreviewFrontend[] { + if (!Array.isArray(value)) { + return []; + } + + const frontends: PreviewFrontend[] = []; + for (const entry of value) { + const frontend = normalizePreviewFrontend(entry); + if (frontend) { + frontends.push(frontend); + } + } + + return frontends; +} + +function frontendsFromParameters(parameters: unknown): PreviewFrontend[] { + if (!isRecord(parameters) || !Array.isArray(parameters.frontends)) { + return []; + } + + return normalizePreviewFrontends( + parameters.frontends.map((frontend) => { + if (!isRecord(frontend)) { + return frontend; + } + return { + name: frontend.name, + disabled: frontend.disabled, + apiEndpointUrl: frontend.previewWebhook, + customHeaders: frontend.customHeaders, + }; + }), + ); +} + +export async function loadWebPreviewFrontends( + client: PreviewPluginClient, +): Promise { + const plugins = await client.plugins.list(); + const install = plugins.find( + (plugin) => plugin.package_name === WEB_PREVIEWS_PACKAGE_NAME, + ); + if (!install) { + return []; + } + + return frontendsFromParameters(install.parameters); +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message.trim() + ? error.message.trim() + : fallback; +} + +function previewResponseLinks(payload: unknown): PreviewLink[] | null { + if (!isRecord(payload) || !Array.isArray(payload.previewLinks)) { + return null; + } + + const links: PreviewLink[] = []; + for (const entry of payload.previewLinks) { + if (!isRecord(entry)) { + return null; + } + const label = trimmedString(entry.label); + const url = httpUrl(trimmedString(entry.url)); + if (!label || !url) { + return null; + } + links.push({ frontendName: '', label, url }); + } + + return links; +} + +async function requestFrontendLinks( + frontend: PreviewFrontend, + payload: string, + signal: AbortSignal | undefined, + fetchImpl: typeof fetch, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const abortFromParent = () => controller.abort(); + signal?.addEventListener('abort', abortFromParent, { once: true }); + + try { + if (signal?.aborted) { + throw new Error('The preview link request was cancelled.'); + } + + const headers = new Headers({ 'Content-Type': 'application/json' }); + for (const header of frontend.customHeaders) { + headers.set(header.name, header.value); + } + + const response = await fetchImpl(frontend.apiEndpointUrl, { + method: 'POST', + headers, + body: payload, + signal: controller.signal, + }); + if (response.status !== 200) { + throw new Error( + `The preview endpoint for "${frontend.name}" returned status ${response.status}.`, + ); + } + + const links = previewResponseLinks(await response.json()); + if (!links) { + throw new Error( + `The preview endpoint for "${frontend.name}" returned an invalid payload.`, + ); + } + + return { + links: links.map((link) => ({ ...link, frontendName: frontend.name })), + errors: [], + }; + } catch (error) { + const timedOut = controller.signal.aborted && !signal?.aborted; + return { + links: [], + errors: [ + { + frontendName: frontend.name, + message: timedOut + ? `The preview endpoint for "${frontend.name}" timed out.` + : errorMessage(error, 'The preview link request failed.'), + }, + ], + }; + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', abortFromParent); + } +} + +export async function fetchPreviewLinks({ + frontends, + payload, + signal, + fetchImpl = fetch, + timeoutMs = PREVIEW_LINK_TIMEOUT_MS, +}: { + frontends: readonly PreviewFrontend[]; + payload: unknown; + signal?: AbortSignal; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}): Promise { + const body = JSON.stringify(payload); + const results = await Promise.all( + frontends.map((frontend) => + requestFrontendLinks(frontend, body, signal, fetchImpl, timeoutMs), + ), + ); + + return { + links: results.flatMap((result) => result.links), + errors: results.flatMap((result) => result.errors), + }; +} + +function rawData(response: { data?: unknown }): Record { + if (!isRecord(response.data)) { + throw new Error('DatoCMS returned an unexpected record payload.'); + } + return response.data; +} + +function relationshipId(record: Record): string { + const relationships = isRecord(record.relationships) + ? record.relationships + : undefined; + const itemType = + relationships && isRecord(relationships.item_type) + ? relationships.item_type + : undefined; + const data = itemType && isRecord(itemType.data) ? itemType.data : undefined; + const id = trimmedString(data?.id); + if (!id) { + throw new Error('The record model could not be determined.'); + } + return id; +} + +function localizedText(value: unknown, locale: string): string | null { + if (typeof value === 'string' && value.trim()) { + return value.trim().replace(/^\/+/, ''); + } + if (!isRecord(value)) { + return null; + } + + const preferred = value[locale]; + if (typeof preferred === 'string' && preferred.trim()) { + return preferred.trim().replace(/^\/+/, ''); + } + + for (const entry of Object.values(value)) { + if (typeof entry === 'string' && entry.trim()) { + return entry.trim().replace(/^\/+/, ''); + } + } + + return null; +} + +function recordSlug( + item: Record, + locale: string, +): string | null { + const attributes = isRecord(item.attributes) ? item.attributes : undefined; + if (!attributes) { + return null; + } + + for (const key of SLUG_FIELD_KEYS) { + const slug = localizedText(attributes[key], locale); + if (slug) { + return slug; + } + } + + return null; +} + +function serializableUser(user: unknown): unknown { + try { + return JSON.parse(JSON.stringify(user ?? null)) as unknown; + } catch { + return null; + } +} + +async function resolveFrontends( + client: PreviewPluginClient | null, + savedFrontends: readonly PreviewFrontend[], +): Promise { + if (!client) { + return [...savedFrontends]; + } + + try { + const live = await loadWebPreviewFrontends(client); + return live.length > 0 ? live : [...savedFrontends]; + } catch { + return [...savedFrontends]; + } +} + +function inferredLink( + websiteBaseUrl: string, + slug: string | null, +): PreviewLink | null { + if (!websiteBaseUrl || !slug) { + return null; + } + + const url = httpUrl( + `${websiteBaseUrl.replace(/\/$/, '')}/${slug.replace(/^\/+/, '')}`, + ); + if (!url) { + return null; + } + + return { + frontendName: 'Website address', + label: 'Website', + url, + }; +} + +export async function getRecordPreviewLinks( + host: PreviewLinksHost, + request: RecordPreviewLinksRequest, + signal?: AbortSignal, +): Promise> { + const itemId = request.itemId.trim(); + if (!itemId) { + throw new Error('A record ID is required to look up preview links.'); + } + const locale = request.locale?.trim() || host.locale.trim() || 'en'; + const accessToken = host.accessToken?.trim(); + const client = accessToken + ? buildClient({ + apiToken: accessToken, + environment: host.environment, + baseUrl: host.cmaBaseUrl, + }) + : null; + const frontends = await resolveFrontends(client, host.savedFrontends); + + let item: Record | null = null; + let itemType: Record | null = null; + if (client && frontends.length > 0) { + const recordClient = client as unknown as RawRecordClient; + item = rawData(await recordClient.items.rawFind(itemId)); + itemType = rawData( + await recordClient.itemTypes.rawFind(relationshipId(item)), + ); + } else if (client && host.websiteBaseUrl) { + item = rawData( + await (client as unknown as RawRecordClient).items.rawFind(itemId), + ); + } + + const fetched = + item && itemType && frontends.length > 0 + ? await fetchPreviewLinks({ + frontends, + signal, + payload: { + item, + itemType, + currentUser: serializableUser(host.currentUser), + siteId: host.siteId, + environmentId: host.environment, + locale, + }, + }) + : { links: [], errors: [] }; + + if (fetched.links.length > 0) { + return { + action: 'get_record_preview_links', + itemId, + locale, + source: 'web_previews', + links: fetched.links, + errors: fetched.errors, + message: + 'These are the full website URLs returned by the configured preview endpoints.', + }; + } + + const fallback = inferredLink( + host.websiteBaseUrl, + item ? recordSlug(item, locale) : null, + ); + if (fallback) { + return { + action: 'get_record_preview_links', + itemId, + locale, + source: 'website_base_url', + links: [fallback], + errors: fetched.errors, + message: + 'No preview endpoint returned a URL. This link was built from the configured website address and the record slug.', + }; + } + + return { + action: 'get_record_preview_links', + itemId, + locale, + source: 'unavailable', + links: [], + errors: fetched.errors, + slug: item ? recordSlug(item, locale) : null, + message: + 'No website domain is configured. Give the editor the record slug and say the full URL is unavailable.', + }; +} diff --git a/dato-agent/src/lib/systemPrompt.test.ts b/dato-agent/src/lib/systemPrompt.test.ts index 88c400cc..bd89b07b 100644 --- a/dato-agent/src/lib/systemPrompt.test.ts +++ b/dato-agent/src/lib/systemPrompt.test.ts @@ -176,6 +176,7 @@ describe('buildSystemPrompt', () => { expect(prompt).toContain( 'after successfully changing one record, call open_record', ); + expect(prompt).toContain('get_record_preview_links'); expect(prompt).toContain( 'inside the standalone Agent inspector. Use show_records', ); diff --git a/dato-agent/src/lib/systemPrompt.ts b/dato-agent/src/lib/systemPrompt.ts index bad0a504..4fd473d1 100644 --- a/dato-agent/src/lib/systemPrompt.ts +++ b/dato-agent/src/lib/systemPrompt.ts @@ -280,6 +280,8 @@ LARGE OPERATIONS AND SCRIPT SAFETY RECORD AND ASSET RESULTS ${recordTools} +- When the editor asks for a page link, public URL, or domain, call get_record_preview_links with the verified record ID. Pass the relevant locale, or null to use the current one. +- Give the full URLs from that result. If it says the link was built from the configured website address, say so. If it says no domain is configured, give the slug and say the full URL is unavailable. - Use present_models or present_users when verified models or project users would be useful clickable references in the answer. These references do not change schema, permissions, or notify users. - Briefly summarize completed work and any item that still needs attention. ${currentRecordGuidance(currentRecord, surface)}${