diff --git a/dev/app/(payload)/admin/importMap.js b/dev/app/(payload)/admin/importMap.js index ce09f61..f9f0113 100644 --- a/dev/app/(payload)/admin/importMap.js +++ b/dev/app/(payload)/admin/importMap.js @@ -28,6 +28,7 @@ import { TableFeatureClient as TableFeatureClient_e70f5e05f09f93e00b997edb1ef0c8 import { SelectField as SelectField_c25bd927cd468b8e16d7bdb2cc282659 } from '@ai-stack/payloadcms/fields' import { PromptEditorField as PromptEditorField_c25bd927cd468b8e16d7bdb2cc282659 } from '@ai-stack/payloadcms/fields' import { InstructionsProvider as InstructionsProvider_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client' +import { AgentProvider as AgentProvider_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client' export const importMap = { "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, @@ -59,5 +60,6 @@ export const importMap = { "@payloadcms/richtext-lexical/client#TableFeatureClient": TableFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@ai-stack/payloadcms/fields#SelectField": SelectField_c25bd927cd468b8e16d7bdb2cc282659, "@ai-stack/payloadcms/fields#PromptEditorField": PromptEditorField_c25bd927cd468b8e16d7bdb2cc282659, - "@ai-stack/payloadcms/client#InstructionsProvider": InstructionsProvider_4490b89d4413c1ffaecdacfe72efaf73 + "@ai-stack/payloadcms/client#InstructionsProvider": InstructionsProvider_4490b89d4413c1ffaecdacfe72efaf73, + "@ai-stack/payloadcms/client#AgentProvider": AgentProvider_4490b89d4413c1ffaecdacfe72efaf73 } diff --git a/dev/dev.db b/dev/dev.db index dafe376..a1d95b4 100644 Binary files a/dev/dev.db and b/dev/dev.db differ diff --git a/package.json b/package.json index e0710ef..e816ada 100644 --- a/package.json +++ b/package.json @@ -192,6 +192,7 @@ "react-mentions": "^4.4.10", "scroll-into-view-if-needed": "^3.1.0", "textarea-caret": "^3.0.2", - "zod": "^4.1.7" + "zod": "^4.1.7", + "use-stick-to-bottom": "^1.1.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efb3de6..f52e01f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: textarea-caret: specifier: ^3.0.2 version: 3.1.0 + use-stick-to-bottom: + specifier: ^1.1.1 + version: 1.1.1(react@19.1.0) zod: specifier: ^4.1.7 version: 4.1.7 @@ -4063,7 +4066,6 @@ packages: libsql@0.4.7: resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} - cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lines-and-columns@1.2.4: @@ -5576,6 +5578,11 @@ packages: '@types/react': optional: true + use-stick-to-bottom@1.1.1: + resolution: {integrity: sha512-JkDp0b0tSmv7HQOOpL1hT7t7QaoUBXkq045WWWOFDTlLGRzgIIyW7vyzOIJzY7L2XVIG7j1yUxeDj2LHm9Vwng==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + use-sync-external-store@1.5.0: resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} peerDependencies: @@ -12252,6 +12259,10 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + use-stick-to-bottom@1.1.1(react@19.1.0): + dependencies: + react: 19.1.0 + use-sync-external-store@1.5.0(react@19.1.0): dependencies: react: 19.1.0 diff --git a/src/defaults.ts b/src/defaults.ts index d362ee2..75ea797 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -2,11 +2,12 @@ export const PLUGIN_NAME = 'plugin-ai' export const PLUGIN_INSTRUCTIONS_TABLE = `${PLUGIN_NAME}-instructions` export const PLUGIN_LEXICAL_EDITOR_FEATURE = `${PLUGIN_NAME}-actions-feature` -// Endpoint defaults + // Endpoint defaults export const PLUGIN_API_ENDPOINT_BASE = `/${PLUGIN_NAME}` export const PLUGIN_API_ENDPOINT_GENERATE = `${PLUGIN_API_ENDPOINT_BASE}/generate` export const PLUGIN_API_ENDPOINT_GENERATE_UPLOAD = `${PLUGIN_API_ENDPOINT_GENERATE}/upload` export const PLUGIN_FETCH_FIELDS_ENDPOINT = `${PLUGIN_API_ENDPOINT_BASE}/fetch-fields` +export const PLUGIN_API_ENDPOINT_AGENT_CHAT = `${PLUGIN_API_ENDPOINT_BASE}/agent/chat` // LLM Settings export const PLUGIN_DEFAULT_OPENAI_MODEL = `gpt-4o-mini` diff --git a/src/endpoints/chat.ts b/src/endpoints/chat.ts new file mode 100644 index 0000000..5210755 --- /dev/null +++ b/src/endpoints/chat.ts @@ -0,0 +1,57 @@ +import type { OpenAIChatModelId, OpenAIProviderOptions } from '@ai-sdk/openai/internal' +import type { Endpoint, PayloadRequest } from 'payload' + +import { convertToModelMessages, streamText } from 'ai' + +import type { PluginConfig } from '../types.js' + +import { openai } from '../ai/models/openai/openai.js' +import { PLUGIN_API_ENDPOINT_AGENT_CHAT, PLUGIN_DEFAULT_OPENAI_MODEL } from '../defaults.js' +import { checkAccess } from '../utilities/checkAccess.js' + +export const Chat = (pluginConfig: PluginConfig): Endpoint => ({ + handler: async (req: PayloadRequest) => { + try { + await checkAccess(req, pluginConfig) + + const body = await req.json?.() + const messages = Array.isArray(body?.messages) ? body.messages : [] + const system = ""; + const modelId: OpenAIChatModelId = "gpt-5" + + const result = streamText({ + messages: convertToModelMessages(messages), + model: openai(modelId), + providerOptions:{ + openai:{ + reasoningEffort: "low", + structuredOutputs: true, + } satisfies OpenAIProviderOptions + }, + system + }) + + return result.toUIMessageStreamResponse({ + sendReasoning: true, + }) + + } catch (error) { + req.payload.logger.error(error, 'Error in chat endpoint: ') + const message = + error && typeof error === 'object' && 'message' in error + ? (error as any).message + : String(error) + + return new Response(JSON.stringify({ error: message }), { + headers: { 'Content-Type': 'application/json' }, + status: + message.includes('Authentication required') || + message.includes('Insufficient permissions') + ? 401 + : 500, + }) + } + }, + method: 'post', + path: PLUGIN_API_ENDPOINT_AGENT_CHAT, +}) diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts index af23f3c..20b4e73 100644 --- a/src/endpoints/index.ts +++ b/src/endpoints/index.ts @@ -21,28 +21,10 @@ import { asyncHandlebars } from '../libraries/handlebars/asyncHandlebars.js' import { registerEditorHelper } from '../libraries/handlebars/helpers.js' import { handlebarsHelpersMap } from '../libraries/handlebars/helpersMap.js' import { replacePlaceholders } from '../libraries/handlebars/replacePlaceholders.js' +import { checkAccess } from '../utilities/checkAccess.js' import { extractImageData } from '../utilities/extractImageData.js' import { getGenerationModels } from '../utilities/getGenerationModels.js' - -const requireAuthentication = (req: PayloadRequest) => { - if (!req.user) { - throw new Error('Authentication required. Please log in to use AI features.') - } - return true -} - -const checkAccess = async (req: PayloadRequest, pluginConfig: PluginConfig) => { - requireAuthentication(req) - - if (pluginConfig.access?.generate) { - const hasAccess = await pluginConfig.access.generate({ req }) - if (!hasAccess) { - throw new Error('Insufficient permissions to use AI generation features.') - } - } - - return true -} +import { Chat } from './chat.js' const extendContextWithPromptFields = ( data: object, @@ -57,13 +39,13 @@ const extendContextWithPromptFields = ( ) return new Proxy(data, { get: (target, prop: string) => { - const field = fieldsMap.get(prop as string) + const field = fieldsMap.get(prop) if (field?.getter) { const value = field.getter(data, ctx) return Promise.resolve(value).then((v) => new asyncHandlebars.SafeString(v)) } // {{prop}} escapes content by default. Here we make sure it won't be escaped. - const value = typeof target === "object" ? (target as any)[prop] : undefined + const value = typeof target === 'object' ? (target as any)[prop] : undefined return typeof value === 'string' ? new asyncHandlebars.SafeString(value) : value }, // It's used by the handlebars library to determine if the property is enumerable @@ -167,6 +149,7 @@ const assignPrompt = async ( export const endpoints: (pluginConfig: PluginConfig) => Endpoints = (pluginConfig) => ({ + chat: Chat(pluginConfig), textarea: { //TODO: This is the main endpoint for generating content - its just needs to be renamed to 'generate' or something. handler: async (req: PayloadRequest) => { @@ -359,9 +342,9 @@ export const endpoints: (pluginConfig: PluginConfig) => Endpoints = (pluginConfi const editImages = [] for (const img of images) { const serverURL = - req.payload.config?.serverURL || - process.env.SERVER_URL || - process.env.NEXT_PUBLIC_SERVER_URL + req.payload.config?.serverURL || + process.env.SERVER_URL || + process.env.NEXT_PUBLIC_SERVER_URL let url = img.image.thumbnailURL || img.image.url if (!url.startsWith('http')) { @@ -369,7 +352,6 @@ export const endpoints: (pluginConfig: PluginConfig) => Endpoints = (pluginConfi } try { - const response = await fetch(url, { headers: { //TODO: Further testing needed or so find a proper way. diff --git a/src/exports/client.ts b/src/exports/client.ts index 273d9de..5f9fc63 100644 --- a/src/exports/client.ts +++ b/src/exports/client.ts @@ -1,3 +1,4 @@ export { LexicalEditorFeatureClient } from '../fields/LexicalEditor/feature.client.js' export { InstructionsContext } from '../providers/InstructionsProvider/context.js' export { InstructionsProvider } from '../providers/InstructionsProvider/InstructionsProvider.js' +export { AgentProvider } from '../providers/AgentProvider/AgentProvider.js' diff --git a/src/plugin.ts b/src/plugin.ts index b465f77..d1678fb 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -113,6 +113,9 @@ const payloadAiPlugin = { path: '@ai-stack/payloadcms/client#InstructionsProvider', }, + { + path: '@ai-stack/payloadcms/client#AgentProvider', + }, ] incomingConfig.admin = { @@ -142,6 +145,7 @@ const payloadAiPlugin = ...(incomingConfig.endpoints ?? []), pluginEndpoints.textarea, pluginEndpoints.upload, + pluginEndpoints.chat, fetchFields(pluginConfig), ], globals: globals.map((global) => { diff --git a/src/providers/AgentProvider/AgentProvider.tsx b/src/providers/AgentProvider/AgentProvider.tsx new file mode 100644 index 0000000..5a3e766 --- /dev/null +++ b/src/providers/AgentProvider/AgentProvider.tsx @@ -0,0 +1,30 @@ +'use client' + +import React from 'react' + +import styles from '../../ui/AgentSidebar/agent-sidebar.module.css' +import { AgentSidebar } from '../../ui/AgentSidebar/AgentSidebar.js' +import { PluginIcon } from '../../ui/Icons/Icons.js' + +export const AgentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [open, setOpen] = React.useState(true) + + return ( + <> + {children} + + + setOpen(false)} open={open} /> + + ) +} + +export default AgentProvider diff --git a/src/types.ts b/src/types.ts index 80661f4..5eff073 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,12 +21,12 @@ export interface PluginConfigAccess { * Control access to AI generation features (generate text, images, audio) * @default () => !!req.user (requires authentication) */ - generate?: ({ req }: { req: PayloadRequest }) => Promise | boolean + generate?: ({ req }: { req: PayloadRequest }) => boolean | Promise /** * Control access to AI settings/configuration * @default () => !!req.user (requires authentication) */ - settings?: ({ req }: { req: PayloadRequest }) => Promise | boolean + settings?: ({ req }: { req: PayloadRequest }) => boolean | Promise } export interface PluginOptions { @@ -116,6 +116,7 @@ export type GenerateTextarea = (args: { export interface Endpoints { textarea: Omit upload: Omit + chat: Omit } export type ActionMenuItems = @@ -151,13 +152,12 @@ export type SeedPromptOptions = { export type SeedPromptData = Omit -export type SeedPromptResult = { - data?: SeedPromptData - prompt: string - system: string -} | { - data?: SeedPromptData -} | false | undefined | void +export type SeedPromptResult = + | { data?: SeedPromptData } + | { data?: SeedPromptData; prompt: string; system: string } + | false + | undefined + | void export type SeedPromptFunction = (options: SeedPromptOptions) => Promise | SeedPromptResult diff --git a/src/ui/AgentInput/AgentInput.tsx b/src/ui/AgentInput/AgentInput.tsx new file mode 100644 index 0000000..2a45ee1 --- /dev/null +++ b/src/ui/AgentInput/AgentInput.tsx @@ -0,0 +1,99 @@ +'use client' + +import React from 'react' + +import styles from './agent-input.module.css' + +export type AgentInputProps = { + autoFocus?: boolean + className?: string + disabled?: boolean + maxHeight?: number + onSend: (message: string) => void + placeholder?: string +} + +export const AgentInput: React.FC = ({ + autoFocus = false, + className, + disabled = false, + maxHeight = 200, + onSend, + placeholder = 'How can I help you…', +}) => { + const [value, setValue] = React.useState('') + const [isComposing, setIsComposing] = React.useState(false) + const editorRef = React.useRef(null) + + const resize = React.useCallback(() => { + const el = editorRef.current + if (!el) { + return + } + // Reset to auto so scrollHeight measures full content height + el.style.height = 'auto' + const limit = Math.max(0, maxHeight) + const next = Math.min(el.scrollHeight, limit) + el.style.height = `${next}px` + el.style.overflowY = el.scrollHeight > limit ? 'auto' : 'hidden' + }, [maxHeight]) + + React.useEffect(() => { + resize() + }, [value, resize]) + + const send = () => { + if (disabled) { + return + } + const msg = value.trim() + if (!msg) { + return + } + onSend(msg) + setValue('') + // Ensure height resets after clearing + requestAnimationFrame(resize) + } + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + // Shift+Enter = newline (default). Enter alone = send (when not composing). + if (e.key === 'Enter' && !e.shiftKey && !isComposing) { + e.preventDefault() + send() + } + } + + return ( +
+