Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions packages/root-cms/core/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ const LEGACY_MODEL_RENAME: Record<string, string> = {
'vertexai/gemini-2.0-pro': 'gemini-2.0-pro',
};

/**
* Resolves the AI model from the CMS plugin options. Checks the top-level `ai`
* config first, then falls back to the deprecated `experiments.ai` config.
*/
function resolveAiModel(options: CMSPluginOptions): RootAiModel {
if (options.ai?.model) {
return options.ai.model;
}
if (
typeof options.experiments?.ai === 'object' &&
options.experiments.ai.model
) {
return options.experiments.ai.model;
}
return DEFAULT_MODEL;
}

/**
* Returns whether AI is enabled in the CMS plugin options.
*/
export function isAiEnabled(options: CMSPluginOptions): boolean {
return !!options.ai || !!options.experiments?.ai;
}

export interface SummarizeDiffOptions {
before: Record<string, any> | null;
after: Record<string, any> | null;
Expand Down Expand Up @@ -111,10 +135,7 @@ export async function generatePublishMessage(
): Promise<string> {
const cmsPluginOptions = cmsClient.cmsPlugin.getConfig();
const firebaseConfig = cmsPluginOptions.firebaseConfig;
const model: RootAiModel =
(typeof cmsPluginOptions.experiments?.ai === 'object'
? cmsPluginOptions.experiments.ai.model
: undefined) || DEFAULT_MODEL;
const model: RootAiModel = resolveAiModel(cmsPluginOptions);

const ai = genkit({
plugins: [
Expand Down Expand Up @@ -270,11 +291,7 @@ export class Chat {
this.id = id;
this.history = options?.history ?? [];
this.model = cleanModelName(
options?.model ||
(typeof this.cmsPluginOptions.experiments?.ai === 'object'
? this.cmsPluginOptions.experiments.ai.model
: undefined) ||
DEFAULT_MODEL
options?.model || resolveAiModel(this.cmsPluginOptions)
);
const firebaseConfig = this.cmsPluginOptions.firebaseConfig;
this.ai = genkit({
Expand Down Expand Up @@ -533,10 +550,7 @@ export async function translateString(
): Promise<Record<string, string>> {
const cmsPluginOptions = cmsClient.cmsPlugin.getConfig();
const firebaseConfig = cmsPluginOptions.firebaseConfig;
const model: RootAiModel =
(typeof cmsPluginOptions.experiments?.ai === 'object'
? cmsPluginOptions.experiments.ai.model
: undefined) || DEFAULT_MODEL;
const model: RootAiModel = resolveAiModel(cmsPluginOptions);

const ai = genkit({
plugins: [
Expand Down
3 changes: 3 additions & 0 deletions packages/root-cms/core/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ export async function renderApp(
gapi: cmsConfig.gapi,
collections: collections,
sidebar: cmsConfig.sidebar,
ai: cmsConfig.ai
? {enabled: true, endpoint: cmsConfig.ai.endpoint}
: undefined,
experiments: cmsConfig.experiments,
preview: {
channel: cmsConfig.preview?.channel ?? false,
Expand Down
75 changes: 69 additions & 6 deletions packages/root-cms/core/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,64 @@ export interface CMSUser {
email: string;
}

/**
* @deprecated Use `CMSAIProviderConfig` instead.
*/
export interface CMSAIConfig {
/** Custom API endpoint for chat prompts. */
endpoint?: string;
/** Gen AI model to use. */
model?: RootAiModel;
}

/**
* Configuration for AI providers. At least one provider must be configured
* for AI features to be enabled.
*
* Example:
* ```ts
* cmsPlugin({
* ai: {
* model: 'gemini-2.5-flash',
* gemini: {
* apiKey: process.env.GEMINI_API_KEY,
* },
* },
* });
* ```
*/
export interface CMSAIProviderConfig {
/** The default model to use for AI generation. */
model?: string;
/** Custom API endpoint for chat prompts. */
endpoint?: string;
/** Gemini AI provider configuration. */
gemini?: {
/**
* API key for Gemini. Obtain from https://aistudio.google.com/apikey.
* If not provided, falls back to Vertex AI with the Firebase project
* credentials.
*/
apiKey?: string;
/** GCP project ID. Falls back to `firebaseConfig.projectId`. */
projectId?: string;
/** GCP region. Defaults to `'us-central1'`. */
location?: string;
};
/** OpenAI provider configuration. */
openai?: {
/** API key for OpenAI. Obtain from https://platform.openai.com/api-keys. */
apiKey: string;
/** Optional organization ID. */
orgId?: string;
};
/** Anthropic provider configuration. */
anthropic?: {
/** API key for Anthropic. Obtain from https://console.anthropic.com/settings/keys. */
apiKey: string;
};
}

export interface CMSSidebarTool {
/** URL for the sidebar icon image. */
icon?: string;
Expand Down Expand Up @@ -245,6 +296,7 @@ export type CMSPluginOptions = {
experiments?: {
/**
* Enables the Root CMS AI page.
* @deprecated Use the top-level `ai` config instead.
*/
ai?: boolean | CMSAIConfig;

Expand Down Expand Up @@ -275,6 +327,22 @@ export type CMSPluginOptions = {
channel: boolean | 'to-preview' | 'from-preview';
};

/**
* AI provider configuration. Enables AI features in the CMS.
*
* Example:
* ```ts
* cmsPlugin({
* ai: {
* gemini: {
* apiKey: process.env.GEMINI_API_KEY,
* },
* },
* });
* ```
*/
ai?: CMSAIProviderConfig;

/**
* Checks that can be run on-demand from the CMS UI to validate document
* content. Each check defines a server-side function that returns a
Expand Down Expand Up @@ -597,12 +665,7 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin {
// Handle body-parser errors (e.g. PayloadTooLargeError) gracefully
// instead of letting them bubble up as unhandled 500 errors.
server.use(
(
err: any,
req: Request,
res: Response,
next: NextFunction
) => {
(err: any, req: Request, res: Response, next: NextFunction) => {
if (err.type === 'entity.too.large' || err.status === 413) {
res.status(413).json({error: 'Payload too large'});
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ export function DocDiffViewer(props: DocDiffViewerProps) {
const [rightDoc, setRightDoc] = useState<CMSDoc | null>(null);

const experiments = (window as any).__ROOT_CTX?.experiments || {};
const aiEnabled =
!!(window as any).__ROOT_CTX?.ai?.enabled || !!experiments.ai;
const showAiSummary =
experiments.ai &&
aiEnabled &&
left.docId === right.docId &&
props.showAiSummary !== false;

Expand Down
4 changes: 3 additions & 1 deletion packages/root-cms/ui/components/DocEditor/DocEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,8 @@ DocEditor.ArrayField = (props: FieldProps) => {
const deeplink = useDeeplink();
const virtualClipboard = useVirtualClipboard();
const experiments = window.__ROOT_CTX.experiments || {};
const aiEnabled =
!!window.__ROOT_CTX.ai?.enabled || !!experiments.ai;

const data = value ?? {};
const order = data._array || [];
Expand Down Expand Up @@ -1517,7 +1519,7 @@ DocEditor.ArrayField = (props: FieldProps) => {
>
Edit JSON
</Menu.Item>
{experiments.ai && (
{aiEnabled && (
<Menu.Item
className="DocEditor__ArrayField__menu__item"
icon={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,8 @@ FileField.Preview = () => {
const [copied, setCopied] = useState(false);
const chat = useChat();
const experiments = window.__ROOT_CTX.experiments || {};
const aiEnabled =
!!window.__ROOT_CTX.ai?.enabled || !!experiments.ai;

// Videos and images are the only files that get the canvas preview.
// Other types just show the info panel. Videos with zero dimensions
Expand Down Expand Up @@ -1034,7 +1036,7 @@ FileField.Preview = () => {
ctx?.setAltText(e.currentTarget.value);
}}
/>
{experiments.ai &&
{aiEnabled &&
!ctx.altText &&
ctx.value?.src?.startsWith('http') && (
<Tooltip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ export function GenerateImageForm(props: GenerateImageFormProps) {

const experiments = (window as any).__ROOT_CTX?.experiments || {};

const aiEnabled = !!experiments.ai;
const aiEnabled =
!!(window as any).__ROOT_CTX?.ai?.enabled || !!experiments.ai;

async function handleGenerate() {
if (!prompt) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ export function EditTranslationsModal(

function shouldShowAiButton() {
const experiments = (window as any).__ROOT_CTX?.experiments || {};
const aiEnabled = !!experiments.ai;
const aiEnabled =
!!(window as any).__ROOT_CTX?.ai?.enabled || !!experiments.ai;

if (!aiEnabled) return false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,8 @@ LocalizationModal.Translations = (props: TranslationsProps) => {

function shouldShowAiButton() {
const experiments = (window as any).__ROOT_CTX?.experiments || {};
const aiEnabled = !!experiments.ai;
const aiEnabled =
!!(window as any).__ROOT_CTX?.ai?.enabled || !!experiments.ai;
if (!aiEnabled || !selectedLocale) return false;
// Show if any translations are missing for the selected locale.
return sourceStrings.some((source) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export function PublishDocModal(
const modals = useModals();
const modalTheme = useModalTheme();
const experiments = window.__ROOT_CTX.experiments || {};
const aiEnabled =
!!window.__ROOT_CTX.ai?.enabled || !!experiments.ai;

const {roles, loading: rolesLoading} = useProjectRoles();
const currentUserEmail = window.firebase.user.email || '';
Expand Down Expand Up @@ -283,7 +285,7 @@ export function PublishDocModal(
setPublishMessage(target.value);
}}
/>
{experiments.ai && (
{aiEnabled && (
<button
type="button"
className="PublishDocModal__form__publishMessage__sparkle"
Expand Down Expand Up @@ -332,7 +334,7 @@ export function PublishDocModal(
</form>
<div className="PublishDocModal__DiffWrapper">
<ReferenceDocs docId={props.docId} />
{experiments.ai && (
{aiEnabled && (
<AiSummary
docId={props.docId}
beforeVersion="published"
Expand Down
5 changes: 3 additions & 2 deletions packages/root-cms/ui/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Layout.Side = () => {
const isBuiltInHidden = (tool: CMSBuiltInSidebarTool) =>
hiddenBuiltInTools.has(tool);
const experiments = window.__ROOT_CTX.experiments || {};
const aiEnabled = !!window.__ROOT_CTX.ai?.enabled || !!experiments.ai;

const onSignOut = async () => {
await window.firebase.auth.signOut();
Expand Down Expand Up @@ -150,9 +151,9 @@ Layout.Side = () => {
</Layout.SideButton>
)}

{experiments.ai && !isBuiltInHidden('ai') && (
{aiEnabled && !isBuiltInHidden('ai') && (
<Layout.SideButton
label="Root AI (experimental)"
label="Root AI"
url="/cms/ai"
active={currentUrl.startsWith('/cms/ai')}
>
Expand Down
7 changes: 5 additions & 2 deletions packages/root-cms/ui/pages/AIPage/AIPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ export function useChat(): ChatController {
// Allow users to provide a custom api endpoint via the
// `{ai: {endpoint: '/api/...}}` config.
let endpoint = '/cms/api/ai.chat';
if (typeof window.__ROOT_CTX.experiments?.ai === 'object') {
if (window.__ROOT_CTX.ai?.endpoint) {
endpoint = window.__ROOT_CTX.ai.endpoint;
} else if (typeof window.__ROOT_CTX.experiments?.ai === 'object') {
if (window.__ROOT_CTX.experiments.ai.endpoint) {
endpoint = window.__ROOT_CTX.experiments.ai.endpoint;
}
Expand Down Expand Up @@ -156,7 +158,8 @@ export function AIPage() {
usePageTitle('AI');
const chat = useChat();

const isEnabled = window.__ROOT_CTX.experiments?.ai || false;
const isEnabled =
window.__ROOT_CTX.ai?.enabled || window.__ROOT_CTX.experiments?.ai || false;

return (
<Layout>
Expand Down
8 changes: 8 additions & 0 deletions packages/root-cms/ui/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ declare global {
}
>;
};
/** AI provider configuration. */
ai?: {
enabled: boolean;
endpoint?: string;
};
/**
* @deprecated Use `ai` instead.
*/
experiments?: {
ai?: boolean | {endpoint?: string};
};
Expand Down
Loading